Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: RSC sub route #1858

Draft
wants to merge 23 commits into
base: v1.x-2022-07
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/hydrogen/src/entry-server.tsx
Expand Up @@ -68,7 +68,7 @@ const DOCTYPE = '<!DOCTYPE html>';
const CONTENT_TYPE = 'Content-Type';
const HTML_CONTENT_TYPE = 'text/html; charset=UTF-8';

export const renderHydrogen = (App: any) => {
export const renderHydrogen = (App: any, outlets?: Record<string, any>) => {
const handleRequest: RequestHandler = async function (rawRequest, options) {
const {cache, context, buyerIpHeader, headers} = options;

Expand All @@ -92,6 +92,7 @@ export const renderHydrogen = (App: any) => {
const hydrogenConfig: ResolvedHydrogenConfig = {
...inlineHydrogenConfig,
routes: hydrogenRoutes,
outlets,
};

request.ctx.hydrogenConfig = hydrogenConfig;
Expand Down Expand Up @@ -347,6 +348,8 @@ async function runSSR({
log,
revalidate,
}: RunSsrParams) {
console.log('runSSR');

let ssrDidError: Error | undefined;
const didError = () => rsc.didError() ?? ssrDidError;

Expand Down Expand Up @@ -654,11 +657,17 @@ async function runSSR({
* Run the RSC/Flight part of the App
*/
function runRSC({App, state, log, request, response}: RunRscParams) {
console.log('runRSC');

const serverProps = {...state, request, response, log};
request.ctx.router.serverProps = serverProps;
preloadRequestCacheData(request);

const AppRSC = (
const AppRSC = state?.outlet ? (
<ServerRequestProvider request={request}>
<App {...serverProps} />
</ServerRequestProvider>
) : (
<ServerRequestProvider request={request}>
<App {...serverProps} />
<Suspense fallback={null}>
Expand Down
35 changes: 31 additions & 4 deletions packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx
Expand Up @@ -40,6 +40,7 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
() => createPageRoutes(routes!, basePath, dirPrefix),
[routes, basePath, dirPrefix]
);
const appOutlets = request.ctx.hydrogenConfig!.outlets;

let foundRoute, foundRouteDetails;

Expand All @@ -52,18 +53,35 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
}
}

console.log('Found route', foundRoute, foundRouteDetails.params);

if (foundRoute) {
request.ctx.router.routeRendered = true;
request.ctx.router.routeParams = foundRouteDetails.params;

let withProps: any;
if (serverProps.outlet) {
console.log('Found outlet:', serverProps.outlet);
const FoundOutlet = (appOutlets[serverProps.outlet] ||
foundRoute.outlets[serverProps.outlet]) as keyof JSX.IntrinsicElements;
withProps = (
<FoundOutlet params={foundRouteDetails.params} {...serverProps} />
);
} else {
withProps = (
<foundRoute.component
params={foundRouteDetails.params}
{...serverProps}
/>
);
}

return (
<RouteParamsProvider
routeParams={foundRouteDetails.params}
basePath={basePath}
>
<foundRoute.component
params={foundRouteDetails.params}
{...serverProps}
/>
{withProps}
</RouteParamsProvider>
);
}
Expand All @@ -74,6 +92,7 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
interface HydrogenRoute {
component: any;
path: string;
outlets: Record<string, any>;
exact: boolean;
}

Expand Down Expand Up @@ -102,9 +121,17 @@ export function createPageRoutes(
);
}

const outlets: Record<string, any> = {};
Object.keys(pages[key]).forEach((exportName: any) => {
if (exportName !== 'default' && exportName !== 'api') {
outlets[exportName] = pages[key][exportName];
}
});

return {
path: topLevelPrefix + path,
component: pages[key].default,
outlets,
exact,
};
})
Expand Down
Expand Up @@ -154,21 +154,26 @@ export class HydrogenRequest extends Request {
}

public getPreloadQueries(): PreloadQueriesByURL | undefined {
if (preloadCache.has(this.normalizedUrl)) {
const url = new URL(this.url);
const state: any = JSON.parse(url.searchParams.get('state') || '{}');

if (preloadCache.has(this.url)) {
const combinedPreloadQueries: PreloadQueriesByURL = new Map();
const urlPreloadCache = preloadCache.get(this.normalizedUrl);
const urlPreloadCache = preloadCache.get(this.url);

mergeMapEntries(combinedPreloadQueries, urlPreloadCache);
mergeMapEntries(combinedPreloadQueries, preloadCache.get(PRELOAD_ALL));
if (!state.outlet) {
mergeMapEntries(combinedPreloadQueries, preloadCache.get(PRELOAD_ALL));
}

return combinedPreloadQueries;
} else if (preloadCache.has(PRELOAD_ALL)) {
} else if (!state.outlet && preloadCache.has(PRELOAD_ALL)) {
return preloadCache.get(PRELOAD_ALL);
}
}

public savePreloadQueries() {
preloadCache.set(this.normalizedUrl, this.ctx.preloadQueries);
preloadCache.set(this.url, this.ctx.preloadQueries);
}

/**
Expand Down
126 changes: 126 additions & 0 deletions packages/hydrogen/src/foundation/RSCSubRoute/RSCSubRoute.client.tsx
@@ -0,0 +1,126 @@
import React, {
useEffect,
useState,
ReactElement,
useTransition,
Suspense,
} from 'react';
import {ErrorBoundary} from 'react-error-boundary';
import {
createFromFetch,
// @ts-ignore
} from '@shopify/hydrogen/vendor/react-server-dom-vite';
import {RSC_PATHNAME} from '../../constants';

type RSCSubRouteClientProps = {
outletName: string;
/** The state of this RSC route */
state: any;
isRSC: boolean;
children?: ReactElement;
};

const DEFAULT_MAX_AGE = 500;
const cache = new Map();

export function RSCSubRouteClient({
outletName,
state,
isRSC,
children,
}: RSCSubRouteClientProps) {
console.log('RSCSubRouteClient', outletName);
const [_, startTransition] = useTransition();
const [response, setResponse] = useState(<Suspense>{children}</Suspense>);
const [expiry, setExpiry] = useState(Date.now() + DEFAULT_MAX_AGE);

useEffect(() => {
if (isRSC || expiry < Date.now()) {
console.log('isRsc', isRSC);
startTransition(() => {
const cachedEntry = getSubServerResponse({
...state,
pathname: window.location.pathname,
search: window.location.search,
outlet: outletName,
});
if (cachedEntry.expiry > expiry) {
setExpiry(cachedEntry.expiry);
setResponse(cachedEntry.response);
}
});
}
}, [isRSC, expiry]);

return (
<ErrorBoundary
fallbackRender={() => {
return null;
}}
>
{/* @ts-ignore */}
{response && response.readRoot ? response.readRoot() : response}
</ErrorBoundary>
);
}

function getSubServerResponse(state: any) {
const key = JSON.stringify(state);

const cacheEntry = cache.get(key);

console.log('cache', state.outlet, cacheEntry);

if (cacheEntry && Date.now() < cacheEntry.expiry) {
console.log('cached', cacheEntry);
return cacheEntry.response;
}

console.log('fetch', key);

const response = createFromFetch(
fetch(`${RSC_PATHNAME}?state=` + encodeURIComponent(key)).then((res) => {
// let maxAge: number = DEFAULT_MAX_AGE;
// const cacheControl = res.headers.get('cache-control');

// if (cacheControl) {
// const maxAgeMatch = cacheControl.match(/max-age=(\d*)/);
// if (maxAgeMatch) {
// try {
// maxAge = parseInt(maxAgeMatch[1]) * 1000;
// } catch {
// maxAge = DEFAULT_MAX_AGE;
// }
// }
// }

// mergeCacheEntry(key, {
// expiry:
// Math.floor(res.status / 100) === 2
// ? Date.now() + maxAge
// : Date.now() + DEFAULT_MAX_AGE,
// });

return res;
})
);

// mergeCacheEntry(key, {
// response,
// });

cache.set(key, {
response,
expiry: Date.now() + DEFAULT_MAX_AGE,
});

return cache.get(key);
}

// function mergeCacheEntry(key: string, data: any) {
// const cachedData = cache.get(key);
// cache.set(key, {
// ...cachedData,
// ...data,
// });
// }
@@ -0,0 +1,93 @@
import React, {ReactElement} from 'react';
import {CachingStrategy} from '../../types';
import {CacheShort} from '../Cache/strategies';
// import {ErrorBoundary} from 'react-error-boundary';
import {useServerRequest} from '../ServerRequestProvider';
import {RSCSubRouteClient} from './RSCSubRoute.client';

export type RSCSubRouteProps = {
outletName: string;
/** The server props of this RSC route */
serverProps: any;
/** The state of this RSC route */
state: any;
cache: CachingStrategy;
/** A reference to a React Server Component that's rendered when the route is active. */
component: ({...componentProps}: any) => JSX.Element | undefined;
fallback?: ReactElement;
};

export function RSCSubRoute({
outletName,
serverProps,
state,
component,
cache,
fallback,
}: RSCSubRouteProps) {
// console.log('RSCSubRoute', state, component);
const request = useServerRequest();
const isRSC = request.isRscRequest();

if (serverProps.outlet && component) {
console.log('RSCSub', serverProps.outlet, serverProps.response.cache);
serverProps.response.cache(cache);
return component ? component(state) : null;
} else if (isRSC) {
console.log('RSCSub - RSC');
return (
<RSCSubRouteClient outletName={outletName} state={state} isRSC={isRSC} />
);
} else {
console.log('RSCSub - SSR');
return (
<RSCSubRouteClient outletName={outletName} state={state} isRSC={isRSC}>
{component(serverProps)}
</RSCSubRouteClient>
);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frandiox I want to gather on your thoughts on this

So I have been noodling on this pattern for the outlets and I am wondering if it is do-able for the full page SSR as well.

What this code piece is doing ...

Say we have a server component

<Product handle={handle} />  => <p>Product title</p>

On SSR pass for /product/123, this will render the SSR output of Product and a RSCSubRouteClient. We don't need a RSC payload to hydrate because the children of RSCSubRouteClient is the exact content we need.

<RSCSubRouteClient state={{handle}}>
  <p>Product title</p>
</RSCSubRouteClient>

On RSC pass for /product/123, this will just render RSCSubRouteClient with the state

<RSCSubRouteClient state={{handle}} />

The content of this route <p>Product title</p> will be replaced by rscResponse.readRoot()

Do you think it is possible to do the same for the full page SSR render and we can drop the 2nd RSC pass entirely?

}
}

export function defineRSCOutlet({
outletName,
component,
dependency = [],
cache = CacheShort(),
fallback,
}: {
outletName: string;
component: ({...componentProps}: any) => JSX.Element;
dependency?: string[];
cache?: CachingStrategy;
fallback?: ReactElement;
}) {
return (serverProps: any) => {
console.log('defineOutlet', component);

const dependencyState = dependency.reduce(function (obj: any, key) {
if (key in serverProps) obj[key] = serverProps[key];
return obj;
}, {});

serverProps.response?.cache(cache);

return (
<>
{/* @ts-ignore */}
<RSCSubRoute
outletName={outletName}
serverProps={serverProps}
state={{
...dependencyState,
pathname: serverProps.pathname,
search: serverProps.search,
outlet: serverProps.outlet,
}}
cache={cache}
component={component}
fallback={fallback}
/>
</>
);
};
}