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 all 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
1 change: 1 addition & 0 deletions packages/hydrogen/src/entry-client.tsx
Expand Up @@ -239,6 +239,7 @@ function useServerResponse(
}

if (rscReader) {
console.log('SSR flight chunks', flightChunks.join(''));
// The flight response was inlined during SSR, use it directly.
response = createFromReadableStream(rscReader);
rscReader = null;
Expand Down
15 changes: 13 additions & 2 deletions packages/hydrogen/src/entry-server.tsx
Expand Up @@ -266,7 +266,10 @@ async function processRequest(
cacheResponse(response, request, [buffered], revalidate);

return new Response(buffered, {
headers: response.headers,
headers: {
...response.headers,
'cache-control': response.cacheControlHeader,
},
});
}

Expand Down Expand Up @@ -347,6 +350,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 +659,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?.section ? (
<ServerRequestProvider request={request}>
<App {...serverProps} />
</ServerRequestProvider>
) : (
<ServerRequestProvider request={request}>
<App {...serverProps} />
<Suspense fallback={null}>
Expand Down
63 changes: 58 additions & 5 deletions packages/hydrogen/src/foundation/FileRoutes/FileRoutes.server.tsx
Expand Up @@ -14,14 +14,20 @@ interface FileRoutesProps {
basePath?: string;
/** The portion of the file route path that shouldn't be a part of the URL. You need to modify this if you want to import routes from a location other than the default `src/routes`. */
dirPrefix?: string | RegExp;
sections?: ImportGlobEagerOutput;
}

/**
* The `FileRoutes` component builds a set of default Hydrogen routes based on the output provided by Vite's
* [import.meta.globEager](https://vitejs.dev/guide/features.html#glob-import) method. You can have multiple
* instances of this component to source file routes from multiple locations.
*/
export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
export function FileRoutes({
routes,
basePath,
dirPrefix,
sections,
}: FileRoutesProps) {
const request = useServerRequest();
const {routeRendered, serverProps} = request.ctx.router;

Expand All @@ -32,6 +38,7 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
routes = fileRoutes.files;
dirPrefix ??= fileRoutes.dirPrefix;
basePath ??= fileRoutes.basePath;
sections ??= fileRoutes.sections;
}

basePath ??= '/';
Expand All @@ -40,6 +47,7 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
() => createPageRoutes(routes!, basePath, dirPrefix),
[routes, basePath, dirPrefix]
);
const appSections = useMemo(() => createSections(sections!), [sections]);

let foundRoute, foundRouteDetails;

Expand All @@ -55,15 +63,29 @@ export function FileRoutes({routes, basePath, dirPrefix}: FileRoutesProps) {
if (foundRoute) {
request.ctx.router.routeRendered = true;
request.ctx.router.routeParams = foundRouteDetails.params;

let withProps: any;
if (serverProps.section) {
const FoundSection = appSections[
serverProps.section
] as keyof JSX.IntrinsicElements;

withProps = <FoundSection {...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 @@ -86,6 +108,7 @@ export function createPageRoutes(

const keys = Object.keys(pages);

const outlets: Record<string, any> = {};
const routes = keys
.map((key) => {
const path = extractPathFromRoutesKey(key, dirPrefix);
Expand All @@ -102,6 +125,12 @@ export function createPageRoutes(
);
}

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,
Expand All @@ -118,3 +147,27 @@ export function createPageRoutes(
...routes.filter((route) => route.path.includes(':')),
];
}

export function createSections(
pages: ImportGlobEagerOutput
): Record<string, any> {
const keys = Object.keys(pages);

const sections: Record<string, any> = {};
keys.forEach((key) => {
const exportKeys = Object.keys(pages[key]);
if (pages[key].default && exportKeys.length === 1) {
log?.warn(
`${key} has a default export which will not be registered as a section`
);
}

exportKeys.forEach((exportName: any) => {
if (exportName !== 'default') {
sections[exportName] = pages[key][exportName];
}
});
});

return sections;
}
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.section) {
mergeMapEntries(combinedPreloadQueries, preloadCache.get(PRELOAD_ALL));
}

return combinedPreloadQueries;
} else if (preloadCache.has(PRELOAD_ALL)) {
} else if (!state.section && 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
@@ -0,0 +1,123 @@
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 HydrogenSectionClientProps = {
section: string;
/** The state of this RSC route */
state: any;
isRSC: boolean;
children?: ReactElement;
fallback?: JSX.Element;
};

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

export function HydrogenSectionClient({
section,
state,
isRSC,
children,
fallback,
}: HydrogenSectionClientProps) {
console.log('HydrogenSectionClient', section);
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,
section,
});
if (cachedEntry.expiry > expiry) {
setExpiry(cachedEntry.expiry);
setResponse(cachedEntry.response);
}
});
}
}, [isRSC, expiry]);

return (
<ErrorBoundary fallback={fallback || <></>}>
{/* @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.section, 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,
// });
// }