diff --git a/e2e/fixtures/rsc-basic/src/entries.tsx b/e2e/fixtures/rsc-basic/src/entries.tsx index 763a08931..bfa0619e3 100644 --- a/e2e/fixtures/rsc-basic/src/entries.tsx +++ b/e2e/fixtures/rsc-basic/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async () => { throw new Error('SSR is should not be used in this test.'); diff --git a/e2e/fixtures/rsc-router/src/entries.tsx b/e2e/fixtures/rsc-router/src/entries.tsx index 073472121..6dccba3f4 100644 --- a/e2e/fixtures/rsc-router/src/entries.tsx +++ b/e2e/fixtures/rsc-router/src/entries.tsx @@ -1,10 +1,10 @@ import { defineRouter } from 'waku/router/server'; +const STATIC_PATHS = ['/', '/foo']; + export default defineRouter( - // getRoutePaths - async () => ({ - static: ['/', '/foo'], - }), + // existsPath + async (path: string) => (STATIC_PATHS.includes(path) ? 'static' : null), // getComponent (id is "**/layout" or "**/page") async (id) => { switch (id) { @@ -18,4 +18,6 @@ export default defineRouter( return null; } }, + // getPathsForBuild + async () => STATIC_PATHS.map((path) => ({ path })), ); diff --git a/e2e/fixtures/ssr-basic/src/entries.tsx b/e2e/fixtures/ssr-basic/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/e2e/fixtures/ssr-basic/src/entries.tsx +++ b/e2e/fixtures/ssr-basic/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/01_counter/src/components/App.tsx b/examples/01_counter/src/components/App.tsx index 272a35d0b..c185621bb 100644 --- a/examples/01_counter/src/components/App.tsx +++ b/examples/01_counter/src/components/App.tsx @@ -7,6 +7,7 @@ const App = ({ name }: { name: string }) => {

Hello {name}!!

This is a server component.

+
{new Date().toISOString()}
); }; diff --git a/examples/01_counter/src/entries.tsx b/examples/01_counter/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/01_counter/src/entries.tsx +++ b/examples/01_counter/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/02_async/src/components/App.tsx b/examples/02_async/src/components/App.tsx index 37092716e..276216b53 100644 --- a/examples/02_async/src/components/App.tsx +++ b/examples/02_async/src/components/App.tsx @@ -14,6 +14,7 @@ const App = ({ name }: { name: string }) => { +
{new Date().toISOString()}
); }; diff --git a/examples/02_async/src/entries.tsx b/examples/02_async/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/02_async/src/entries.tsx +++ b/examples/02_async/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/03_promise/src/entries.tsx b/examples/03_promise/src/entries.tsx index 76acfeb8e..35bbec422 100644 --- a/examples/03_promise/src/entries.tsx +++ b/examples/03_promise/src/entries.tsx @@ -16,7 +16,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/04_callserver/src/components/App.tsx b/examples/04_callserver/src/components/App.tsx index bae8abbe8..16910497d 100644 --- a/examples/04_callserver/src/components/App.tsx +++ b/examples/04_callserver/src/components/App.tsx @@ -12,6 +12,7 @@ const App = ({ name }: { name: string }) => {

Hello {name}!!

This is a server component.

} /> +
{new Date().toISOString()}
); }; diff --git a/examples/04_callserver/src/entries.tsx b/examples/04_callserver/src/entries.tsx index f0f188f87..6bbd45e2b 100644 --- a/examples/04_callserver/src/entries.tsx +++ b/examples/04_callserver/src/entries.tsx @@ -11,9 +11,12 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '', isStatic: true }] }], // getSsrConfig - async ({ pathname }) => { + async ({ pathname }, isPrd) => { + if (isPrd) { + return null; + } switch (pathname) { case '/': return { diff --git a/examples/05_mutation/src/entries.tsx b/examples/05_mutation/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/05_mutation/src/entries.tsx +++ b/examples/05_mutation/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/06_nesting/src/entries.tsx b/examples/06_nesting/src/entries.tsx index e16ed1ae7..b13ef6feb 100644 --- a/examples/06_nesting/src/entries.tsx +++ b/examples/06_nesting/src/entries.tsx @@ -23,12 +23,12 @@ export default defineEntries( { pathname: '/', entries: [ - [''], - ['InnerApp=1', true], - ['InnerApp=2', true], - ['InnerApp=3', true], - ['InnerApp=4', true], - ['InnerApp=5', true], + { input: '' }, + { input: 'InnerApp=1', skipPrefetch: true }, + { input: 'InnerApp=2', skipPrefetch: true }, + { input: 'InnerApp=3', skipPrefetch: true }, + { input: 'InnerApp=4', skipPrefetch: true }, + { input: 'InnerApp=5', skipPrefetch: true }, ], }, ], diff --git a/examples/07_router/src/entries.tsx b/examples/07_router/src/entries.tsx index 91bd7d7a1..f2e22fc2a 100644 --- a/examples/07_router/src/entries.tsx +++ b/examples/07_router/src/entries.tsx @@ -1,12 +1,13 @@ import { defineRouter } from 'waku/router/server'; +const STATIC_PATHS = ['/', '/foo', '/bar', '/nested/baz', '/nested/qux']; + export default defineRouter( - // getRoutePaths - async () => ({ - static: ['/', '/foo', '/bar', '/nested/baz', '/nested/qux'], - }), + // existsPath + async (path: string) => (STATIC_PATHS.includes(path) ? 'static' : null), // getComponent (id is "**/layout" or "**/page") - async (id) => { + async (id, unstable_setShouldSkip) => { + unstable_setShouldSkip({}); // always skip if possible switch (id) { case 'layout': return import('./routes/layout.js'); @@ -26,4 +27,6 @@ export default defineRouter( return null; } }, + // getPathsForBuild + async () => STATIC_PATHS.map((path) => ({ path })), ); diff --git a/examples/07_router/src/main.tsx b/examples/07_router/src/main.tsx index 6f65cfd69..f29f42a78 100644 --- a/examples/07_router/src/main.tsx +++ b/examples/07_router/src/main.tsx @@ -7,7 +7,7 @@ import { ErrorBoundary } from './components/ErrorBoundary.js'; const rootElement = (

{String(error)}

}> - true} /> +
); diff --git a/examples/08_cookies/src/entries.tsx b/examples/08_cookies/src/entries.tsx index 96210fac5..5ab2abdb8 100644 --- a/examples/08_cookies/src/entries.tsx +++ b/examples/08_cookies/src/entries.tsx @@ -25,7 +25,9 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']], context: { count: 0 } }], + async () => [ + { pathname: '/', entries: [{ input: '' }], context: { count: 0 } }, + ], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/09_cssmodules/src/entries.tsx b/examples/09_cssmodules/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/09_cssmodules/src/entries.tsx +++ b/examples/09_cssmodules/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/10_dynamicroute/src/entries.tsx b/examples/10_dynamicroute/src/entries.tsx index efcd596e0..8aab18b19 100644 --- a/examples/10_dynamicroute/src/entries.tsx +++ b/examples/10_dynamicroute/src/entries.tsx @@ -54,24 +54,27 @@ const getMappingAndItems = async (id: string) => { return { mapping, items }; }; +const getStaticPaths = async () => { + const files = await glob('**/page.{tsx,js}', { cwd: routesDir }); + return files + .filter((file) => !/(^|\/)(\[\w+\]|_\w+_)\//.test(file)) + .map((file) => '/' + file.slice(0, Math.max(0, file.lastIndexOf('/')))); +}; + export default defineRouter( - // getRoutePaths - async () => { - const files = await glob('**/page.{tsx,js}', { cwd: routesDir }); - const staticRoutes = files - .filter((file) => !/(^|\/)(\[\w+\]|_\w+_)\//.test(file)) - .map((file) => '/' + file.slice(0, Math.max(0, file.lastIndexOf('/')))); - const dynamicRoutes = async (path: string) => { - const result = await getMappingAndItems(path + '/page'); - return result !== null; - }; - return { - static: staticRoutes, - dynamic: dynamicRoutes, - }; + // existsPath + async (path: string) => { + if ((await getStaticPaths()).includes(path)) { + return 'static'; + } + if ((await getMappingAndItems(path + '/page')) !== null) { + return 'dynamic'; + } + return null; }, // getComponent (id is "**/layout" or "**/page") - async (id) => { + async (id, unstable_setShouldSkip) => { + unstable_setShouldSkip({}); // always skip if possible const result = await getMappingAndItems(id); if (result === null) { return null; @@ -83,4 +86,6 @@ export default defineRouter( ); return Component; }, + // getPathsForBuild + async () => (await getStaticPaths()).map((path) => ({ path })), ); diff --git a/examples/10_dynamicroute/src/main.tsx b/examples/10_dynamicroute/src/main.tsx index 6f65cfd69..f29f42a78 100644 --- a/examples/10_dynamicroute/src/main.tsx +++ b/examples/10_dynamicroute/src/main.tsx @@ -7,7 +7,7 @@ import { ErrorBoundary } from './components/ErrorBoundary.js'; const rootElement = (

{String(error)}

}> - true} /> +
); diff --git a/examples/11_form/src/entries.tsx b/examples/11_form/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/11_form/src/entries.tsx +++ b/examples/11_form/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/examples/12_css/src/entries.tsx b/examples/12_css/src/entries.tsx index f0f188f87..ee9f43e82 100644 --- a/examples/12_css/src/entries.tsx +++ b/examples/12_css/src/entries.tsx @@ -11,7 +11,7 @@ export default defineEntries( }; }, // getBuildConfig - async () => [{ pathname: '/', entries: [['']] }], + async () => [{ pathname: '/', entries: [{ input: '' }] }], // getSsrConfig async ({ pathname }) => { switch (pathname) { diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index 3094b73d3..2380d3f81 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -314,9 +314,13 @@ const emitRscFiles = async ( return Array.from(idSet || []); }; const rscFileSet = new Set(); // XXX could be implemented better + const staticInputSet = new Set(); await Promise.all( Array.from(buildConfig).map(async ({ entries, context }) => { - for (const [input] of entries || []) { + for (const { input, isStatic } of entries || []) { + if (isStatic) { + staticInputSet.add(input); + } const destRscFile = joinPath( rootDir, config.distDir, @@ -347,6 +351,13 @@ const emitRscFiles = async ( } }), ); + const skipRenderRscCode = ` +const staticInputSet = new Set(${JSON.stringify(Array.from(staticInputSet))}); +export function skipRenderRsc(input) { + return staticInputSet.has(input); +} +`; + await appendFile(distEntriesFile, skipRenderRscCode); return { buildConfig, getClientModules, rscFiles: Array.from(rscFileSet) }; }; @@ -388,7 +399,7 @@ const emitHtmlFiles = async ( ); const inputsForPrefetch = new Set(); const moduleIdsForPrefetch = new Set(); - for (const [input, skipPrefetch] of entries || []) { + for (const { input, skipPrefetch } of entries || []) { if (!skipPrefetch) { inputsForPrefetch.add(input); for (const id of getClientModules(input)) { @@ -428,6 +439,7 @@ const emitHtmlFiles = async ( }), isDev: false, entries: distEntries, + isBuild: true, })); await mkdir(joinPath(destHtmlFile, '..'), { recursive: true }); if (htmlReadable) { diff --git a/packages/waku/src/lib/builder/output-vercel.ts b/packages/waku/src/lib/builder/output-vercel.ts index 0677b7858..fa565ee1a 100644 --- a/packages/waku/src/lib/builder/output-vercel.ts +++ b/packages/waku/src/lib/builder/output-vercel.ts @@ -58,10 +58,26 @@ export const emitVercelOutput = async ( path.join(serverlessDir, 'serve.js'), ` import path from 'node:path'; +import fs from 'node:fs'; import { connectMiddleware } from 'waku'; const entries = import(path.resolve('${config.distDir}', '${config.entriesJs}')); -export default async function handler(req, res) { +export default function handler(req, res) { connectMiddleware({ entries, ssr: ${ssr} })(req, res, () => { + const fname = path.join( + '${config.distDir}', + '${config.publicDir}', + req.url, + path.extname(req.url) ? '' : '${config.indexHtml}', + ); + if (fs.existsSync(fname)) { + if (fname.endsWith('.html')) { + res.setHeader('content-type', 'text/html; charset=utf-8'); + } else if (fname.endsWith('.txt')) { + res.setHeader('content-type', 'text/plain'); + } + fs.createReadStream(fname).pipe(res); + return; + } res.statusCode = 404; res.end(); }); diff --git a/packages/waku/src/lib/handlers/handler-prd.ts b/packages/waku/src/lib/handlers/handler-prd.ts index 2c91570e7..00f246e87 100644 --- a/packages/waku/src/lib/handlers/handler-prd.ts +++ b/packages/waku/src/lib/handlers/handler-prd.ts @@ -24,8 +24,6 @@ export function createHandler< } const configPromise = resolveConfig(config || {}); - const loadHtmlHeadPromise = entries.then(({ loadHtmlHead }) => loadHtmlHead); - return async (req, res, next) => { const config = await configPromise; const basePrefix = config.basePath + config.rscPath + '/'; @@ -47,8 +45,8 @@ export function createHandler< } if (ssr) { try { - const loadHtmlHead = await loadHtmlHeadPromise; const resolvedEntries = await entries; + const { loadHtmlHead } = resolvedEntries; const readable = await renderHtml({ config, reqUrl: req.url, @@ -64,6 +62,7 @@ export function createHandler< }), isDev: false, entries: resolvedEntries, + isBuild: false, }); if (readable) { unstable_posthook?.(req, res, context as Context); @@ -82,27 +81,31 @@ export function createHandler< if (method !== 'GET' && method !== 'POST') { throw new Error(`Unsupported method '${method}'`); } + const { skipRenderRsc } = await entries; try { const input = decodeInput( req.url.toString().slice(req.url.origin.length + basePrefix.length), ); - const readable = await renderRsc({ - config, - input, - method, - context, - body: req.stream, - contentType, - isDev: false, - entries: await entries, - }); - unstable_posthook?.(req, res, context as Context); - deepFreeze(context); - readable.pipeTo(res.stream); + if (!skipRenderRsc(input)) { + const readable = await renderRsc({ + config, + input, + method, + context, + body: req.stream, + contentType, + isDev: false, + entries: await entries, + }); + unstable_posthook?.(req, res, context as Context); + deepFreeze(context); + readable.pipeTo(res.stream); + return; + } } catch (e) { handleError(e); + return; } - return; } next(); }; diff --git a/packages/waku/src/lib/renderers/html-renderer.ts b/packages/waku/src/lib/renderers/html-renderer.ts index 678b44394..bf632884a 100644 --- a/packages/waku/src/lib/renderers/html-renderer.ts +++ b/packages/waku/src/lib/renderers/html-renderer.ts @@ -241,7 +241,7 @@ export const renderHtml = async ( htmlHead: string; renderRscForHtml: (input: string) => Promise; } & ( - | { isDev: false; entries: EntriesPrd } + | { isDev: false; entries: EntriesPrd; isBuild: boolean } | { isDev: true; entries: EntriesDev } ), ): Promise => { @@ -252,7 +252,7 @@ export const renderHtml = async ( loadModule, } = entries as (EntriesDev & { loadModule: undefined }) | EntriesPrd; const [ - { createElement }, + { createElement, Fragment }, { renderToReadableStream }, { createFromReadableStream }, { ServerRoot, Slot }, @@ -270,7 +270,7 @@ export const renderHtml = async ( ? import(WAKU_CLIENT_MODULE_VALUE) : loadModule!('public/' + WAKU_CLIENT_MODULE), ]); - const ssrConfig = await getSsrConfig?.(reqUrl); + const ssrConfig = await getSsrConfig?.(reqUrl, !isDev && !opts.isBuild); if (!ssrConfig) { return null; } @@ -374,7 +374,7 @@ export const renderHtml = async ( Omit, 'children'> >, { elements }, - ssrConfig.unstable_render({ createElement, Slot }), + ssrConfig.unstable_render({ createElement, Fragment, Slot }), ), ), { diff --git a/packages/waku/src/lib/renderers/utils.ts b/packages/waku/src/lib/renderers/utils.ts index f4a21a33f..6424a34e3 100644 --- a/packages/waku/src/lib/renderers/utils.ts +++ b/packages/waku/src/lib/renderers/utils.ts @@ -1,19 +1,23 @@ // This file should not include Node specific code. +// TODO This might be too naive. +// Should we use pathname and searchParams instead of input string? + export const encodeInput = (input: string) => { if (input === '') { - return '_'; - } else if (!input.startsWith('_')) { - return input; + return 'index.txt'; } - throw new Error("Input must not start with '_'"); + const [first, second] = input.split('?', 2); + return first + '.txt' + (second ? '?' + second : ''); }; export const decodeInput = (encodedInput: string) => { - if (encodedInput === '_') { + if (encodedInput === 'index.txt') { return ''; - } else if (!encodedInput.startsWith('_')) { - return encodedInput; + } + const [first, second] = encodedInput.split('?', 2); + if (first?.endsWith('.txt')) { + return first.slice(0, -'.txt'.length) + (second ? '?' + second : ''); } const err = new Error('Invalid encoded input'); (err as any).statusCode = 400; diff --git a/packages/waku/src/router/client.ts b/packages/waku/src/router/client.ts index 48e238585..a7cdc21e2 100644 --- a/packages/waku/src/router/client.ts +++ b/packages/waku/src/router/client.ts @@ -14,8 +14,10 @@ import { import type { ComponentProps, FunctionComponent, ReactNode } from 'react'; import { Root, Slot, useRefetch } from '../client.js'; -import { getComponentIds, getInputString } from './common.js'; -import type { RouteProps } from './common.js'; +import { getComponentIds, getInputString, SHOULD_SKIP_ID } from './common.js'; +import type { RouteProps, ShouldSkip } from './common.js'; +// FIXME this depends on an internal function +import { encodeInput } from '../lib/renderers/utils.js'; const parseLocation = (): RouteProps => { const { pathname, search } = window.location; @@ -107,32 +109,41 @@ export function Link({ return ele; } -type ShouldSkip = ( - componentId: string, - props: RouteProps, - prevProps: RouteProps, -) => boolean; - const getSkipList = ( componentIds: readonly string[], props: RouteProps, cached: Record, - shouldSkip?: ShouldSkip, -): string[] => - shouldSkip - ? componentIds.filter((id) => { - const prevProps = cached[id]; - return prevProps && shouldSkip(id, props, prevProps); - }) - : []; - -function InnerRouter({ - basePath, - shouldSkip, -}: { - basePath: string; - shouldSkip?: ShouldSkip | undefined; -}) { +): string[] => { + const ele: any = document.querySelector('meta[name="waku-should-skip"]'); + if (!ele) { + return []; + } + const shouldSkip: ShouldSkip = JSON.parse(ele.content); + return componentIds.filter((id) => { + const prevProps = cached[id]; + if (!prevProps) { + return false; + } + const shouldCheck = shouldSkip?.[id]; + if (!shouldCheck) { + return false; + } + if (shouldCheck.path && props.path !== prevProps.path) { + return false; + } + if ( + shouldCheck.keys?.some( + (key) => + props.searchParams.get(key) !== prevProps.searchParams.get(key), + ) + ) { + return false; + } + return true; + }); +}; + +function InnerRouter({ basePath }: { basePath: string }) { const refetch = useRefetch(); const [loc, setLoc] = useState(parseLocation); @@ -163,13 +174,8 @@ function InnerRouter({ const loc = parseLocation(); setLoc(loc); const componentIds = getComponentIds(loc.path); - const skip = getSkipList( - componentIds, - loc, - cachedRef.current, - shouldSkip, - ); - if (skip.length === componentIds.length) { + const skip = getSkipList(componentIds, loc, cachedRef.current); + if (componentIds.every((id) => skip.includes(id))) { return; // everything is cached } const input = getInputString(loc.path, loc.searchParams, skip); @@ -181,30 +187,25 @@ function InnerRouter({ ), })); }, - [refetch, shouldSkip], + [refetch], ); const prefetchLocation: PrefetchLocation = useCallback( (path, searchParams) => { const componentIds = getComponentIds(path); const routeProps: RouteProps = { path, searchParams }; - const skip = getSkipList( - componentIds, - routeProps, - cachedRef.current, - shouldSkip, - ); - if (skip.length === componentIds.length) { + const skip = getSkipList(componentIds, routeProps, cachedRef.current); + if (componentIds.every((id) => skip.includes(id))) { return; // everything is cached } const input = getInputString(path, searchParams, skip); const prefetched = ((globalThis as any).__WAKU_PREFETCHED__ ||= {}); if (!prefetched[input]) { - prefetched[input] = fetch(basePath + input); + prefetched[input] = fetch(basePath + encodeInput(input)); } (globalThis as any).__WAKU_ROUTER_PREFETCH__?.(path, searchParams); }, - [basePath, shouldSkip], + [basePath], ); useEffect(() => { @@ -223,24 +224,23 @@ function InnerRouter({ ); return createElement( - RouterContext.Provider, - { value: { loc, changeLocation, prefetchLocation } }, - children, + Fragment, + null, + createElement(Slot, { id: SHOULD_SKIP_ID }), + createElement( + RouterContext.Provider, + { value: { loc, changeLocation, prefetchLocation } }, + children, + ), ); } -export function Router({ - basePath = '/RSC/', - shouldSkip, -}: { - basePath?: string; - shouldSkip?: ShouldSkip; -}) { +export function Router({ basePath = '/RSC/' }: { basePath?: string }) { const loc = parseLocation(); const initialInput = getInputString(loc.path, loc.searchParams); return createElement( Root as FunctionComponent, 'children'>>, { initialInput, basePath }, - createElement(InnerRouter, { basePath, shouldSkip }), + createElement(InnerRouter, { basePath }), ); } diff --git a/packages/waku/src/router/common.ts b/packages/waku/src/router/common.ts index eb5c81d64..6d6430cf4 100644 --- a/packages/waku/src/router/common.ts +++ b/packages/waku/src/router/common.ts @@ -28,7 +28,7 @@ export function getInputString( let input = search ? '=' + path.replace(/\/$/, '/__INDEX__') + '/' + search : '-' + path.replace(/\/$/, '/__INDEX__'); - if (skip) { + if (skip && skip.length) { const params = new URLSearchParams(); skip.forEach((id) => params.append('skip', id)); input += '?' + params; @@ -62,3 +62,15 @@ export function parseInputString(input: string): { throw err; } } + +// It starts with "/" to avoid conflicing with normal component ids. +export const SHOULD_SKIP_ID = '/SHOULD_SKIP'; + +// The key is componentId +export type ShouldSkip = Record< + string, + { + path?: boolean; // if we compare path + keys?: string[]; // searchParams keys to compare + } +>; diff --git a/packages/waku/src/router/server.ts b/packages/waku/src/router/server.ts index 75c614dfe..3fa857bdb 100644 --- a/packages/waku/src/router/server.ts +++ b/packages/waku/src/router/server.ts @@ -1,62 +1,41 @@ -import ReactExports from 'react'; -import type { FunctionComponent, ReactNode } from 'react'; +import { createElement } from 'react'; +import type { Fragment, FunctionComponent, ReactNode } from 'react'; import { defineEntries } from '../server.js'; import type { RenderEntries, GetBuildConfig, GetSsrConfig } from '../server.js'; import { Children } from '../client.js'; import type { Slot } from '../client.js'; -import { getComponentIds, getInputString, parseInputString } from './common.js'; -import type { RouteProps } from './common.js'; - -// eslint-disable-next-line import/no-named-as-default-member -const { createElement } = ReactExports; - -// We have to make prefetcher consistent with client behavior -const prefetcher = ( - path: string, - searchParamsList: Iterable | undefined, -) => - Array.from(searchParamsList || []).map( - (searchParams) => [getInputString(path, searchParams)] as const, - ); +import { + getComponentIds, + getInputString, + parseInputString, + SHOULD_SKIP_ID, +} from './common.js'; +import type { RouteProps, ShouldSkip } from './common.js'; const Default = ({ children }: { children: ReactNode }) => children; -// TODO will review this again -type RoutePaths = { - static?: Iterable; - staticSearchParams?: (path: string) => Iterable; - dynamic?: (path: string) => Promise; -}; +const ShoudSkipComponent = ({ shouldSkip }: { shouldSkip: ShouldSkip }) => + createElement('meta', { + name: 'waku-should-skip', + content: JSON.stringify(shouldSkip), + }); export function defineRouter

( - getRoutePaths: () => Promise, + existsPath: (path: string) => Promise<'static' | 'dynamic' | null>, getComponent: ( - componentId: string, + componentId: string, // "**/layout" or "**/page" + unstable_setShouldSkip: (val?: ShouldSkip[string]) => void, ) => Promise | { default: FunctionComponent

} | null>, + getPathsForBuild?: () => Promise< + Iterable<{ path: string; searchParams?: URLSearchParams }> + >, ): ReturnType { - const routePathsPromise = getRoutePaths(); - const existsRoutePathPromise = routePathsPromise.then((routePaths) => { - const staticPathSet = new Set(); - for (const path of routePaths.static || []) { - staticPathSet.add(path); - } - const existsRoutePath = async (path: string) => { - if (staticPathSet.has(path)) { - return true; - } - if (await routePaths.dynamic?.(path)) { - return true; - } - return false; - }; - return existsRoutePath; - }); + const shouldSkip: ShouldSkip = {}; const renderEntries: RenderEntries = async (input) => { const { path, searchParams, skip } = parseInputString(input); - const existsRoutePath = await existsRoutePathPromise; - if (!(await existsRoutePath(path))) { + if (!(await existsPath(path))) { return null; } const componentIds = getComponentIds(path); @@ -67,7 +46,13 @@ export function defineRouter

( if (skip?.includes(id)) { return []; } - const mod = await getComponent(id); + const mod = await getComponent(id, (val) => { + if (val) { + shouldSkip[id] = val; + } else { + delete shouldSkip[id]; + } + }); const component = typeof mod === 'function' ? mod : mod?.default || Default; const element = createElement( @@ -79,24 +64,39 @@ export function defineRouter

( }), ) ).flat(); + entries.push([ + SHOULD_SKIP_ID, + createElement(ShoudSkipComponent, { shouldSkip }) as any, + ]); return Object.fromEntries(entries); }; const getBuildConfig: GetBuildConfig = async ( unstable_collectClientModules, ) => { - const routePaths = await routePathsPromise; + const pathsForBuild = await getPathsForBuild?.(); + const pathMap = new Map< + string, + { isStatic: boolean; searchParamsList: URLSearchParams[] } + >(); const path2moduleIds: Record = {}; - for (const path of routePaths.static || []) { - for (const searchParams of [ - new URLSearchParams(), - ...(routePaths.staticSearchParams?.(path) || []), - ]) { - const input = getInputString(path, searchParams); - const moduleIds = await unstable_collectClientModules(input); - const search = searchParams.toString(); - path2moduleIds[path + (search ? '?' + search : '')] = moduleIds; + for (const { + path, + searchParams = new URLSearchParams(), + } of pathsForBuild || []) { + let item = pathMap.get(path); + if (!item) { + item = { + isStatic: (await existsPath(path)) === 'static', + searchParamsList: [], + }; + pathMap.set(path, item); } + item.searchParamsList.push(searchParams); + const input = getInputString(path, searchParams); + const moduleIds = await unstable_collectClientModules(input); + const search = searchParams.toString(); + path2moduleIds[path + (search ? '?' + search : '')] = moduleIds; } const customCode = ` globalThis.__WAKU_ROUTER_PREFETCH__ = (path, searchParams) => { @@ -107,30 +107,39 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path, searchParams) => { import(id); } };`; - return Array.from(routePaths.static || []).map((path) => { - return { - pathname: path, - entries: prefetcher(path, routePaths.staticSearchParams?.(path)), - customCode, - }; - }); + return Array.from(pathMap.entries()).map( + ([path, { isStatic, searchParamsList }]) => { + const entries = searchParamsList.map((searchParams) => ({ + input: getInputString(path, searchParams), + isStatic, + })); + return { pathname: path, entries, customCode }; + }, + ); }; - const getSsrConfig: GetSsrConfig = async (reqUrl) => { - const existsRoutePath = await existsRoutePathPromise; - if (!(await existsRoutePath(reqUrl.pathname))) { + // TODO this API is not very understandable and not consistent with RSC + const getSsrConfig: GetSsrConfig = async (reqUrl, isPrd) => { + const pathType = await existsPath(reqUrl.pathname); + if (isPrd ? pathType !== 'dynamic' : pathType === null) { return null; } const componentIds = getComponentIds(reqUrl.pathname); const input = getInputString(reqUrl.pathname, reqUrl.searchParams); type Opts = { createElement: typeof createElement; + Fragment: typeof Fragment; Slot: typeof Slot; }; - const render = ({ createElement, Slot }: Opts) => - componentIds.reduceRight( - (acc: ReactNode, id) => createElement(Slot, { id }, acc), + const render = ({ createElement, Fragment, Slot }: Opts) => + createElement( + Fragment, null, + createElement(Slot, { id: SHOULD_SKIP_ID }), + componentIds.reduceRight( + (acc: ReactNode, id) => createElement(Slot, { id }, acc), + null, + ), ); return { input, unstable_render: render }; }; diff --git a/packages/waku/src/server.ts b/packages/waku/src/server.ts index 375f698bc..34325b90a 100644 --- a/packages/waku/src/server.ts +++ b/packages/waku/src/server.ts @@ -1,4 +1,4 @@ -import type { createElement, ReactNode } from 'react'; +import type { createElement, Fragment, ReactNode } from 'react'; import type { Slot } from './client.js'; @@ -19,16 +19,24 @@ export type GetBuildConfig = ( ) => Promise< Iterable<{ pathname: string; - entries?: Iterable; + entries?: Iterable<{ + input: string; + skipPrefetch?: boolean; + isStatic?: boolean; + }>; customCode?: string; // optional code to inject TODO hope to remove this context?: unknown; }> >; -export type GetSsrConfig = (reqUrl: URL) => Promise<{ +export type GetSsrConfig = ( + reqUrl: URL, + isPrd: boolean, +) => Promise<{ input: string; unstable_render: (opts: { createElement: typeof createElement; + Fragment: typeof Fragment; Slot: typeof Slot; }) => ReactNode; } | null>; @@ -48,4 +56,5 @@ export type EntriesDev = { export type EntriesPrd = EntriesDev & { loadModule: (id: string) => Promise; loadHtmlHead: (pathname: string) => string; + skipRenderRsc: (input: string) => boolean; }; diff --git a/packages/website/src/entries.tsx b/packages/website/src/entries.tsx index 1628bd2f7..8e2b08349 100644 --- a/packages/website/src/entries.tsx +++ b/packages/website/src/entries.tsx @@ -1,12 +1,13 @@ import { defineRouter } from 'waku/router/server'; +const STATIC_PATHS = ['/', '/blog/introducing-waku']; + export default defineRouter( - // getRoutePaths - async () => ({ - static: ['/', '/blog/introducing-waku'], - }), + // existsPath + async (path: string) => (STATIC_PATHS.includes(path) ? 'static' : null), // getComponent (id is "**/layout" or "**/page") - async (id) => { + async (id, unstable_setShouldSkip) => { + unstable_setShouldSkip({}); // always skip if possible switch (id) { case 'layout': return import('./routes/layout.js'); @@ -18,4 +19,6 @@ export default defineRouter( return null; } }, + // getPathsForBuild + async () => STATIC_PATHS.map((path) => ({ path })), ); diff --git a/packages/website/src/main.tsx b/packages/website/src/main.tsx index 1a0727218..cf977ce15 100644 --- a/packages/website/src/main.tsx +++ b/packages/website/src/main.tsx @@ -4,7 +4,7 @@ import { Router } from 'waku/router/client'; const rootElement = ( - true} /> + ); diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 209077196..27d26e893 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -4,6 +4,11 @@ export default defineConfig(({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; if (mode === 'development') { return { + optimizeDeps: { + include: [ + // '@uidotdev/usehooks', + ], + }, ssr: { external: ['next-mdx-remote'], },