From cd7f9c605cfa9b652f989443f70d8711a493fa3d Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Mon, 25 May 2026 12:47:49 +0900 Subject: [PATCH] feat(pages): support client-only page rendering --- docs/guide/pages-routing/loaders.md | 2 +- docs/guide/pages-routing/overview.md | 37 +++++- docs/reference/config.md | 2 +- packages/react/README.md | 4 + packages/react/src/plugin-pages.ts | 2 + packages/react/src/runtime/pages-client.ts | 16 ++- .../integration/react-pages-client.test.ts | 29 +++++ packages/react/test/unit/plugin-pages.test.ts | 15 +++ packages/solid/README.md | 4 + packages/solid/src/plugin-pages.ts | 9 +- packages/svelte/README.md | 4 + packages/svelte/src/plugin-pages.ts | 12 +- packages/void/src/cli/deploy.ts | 3 +- packages/void/src/pages/protocol.ts | 92 ++++++++++---- packages/void/src/pages/scan.ts | 117 +++++++++++++----- packages/void/src/pages/types.ts | 22 +++- packages/void/src/router/compile.ts | 31 +++-- .../test/integration/static-pages.test.ts | 22 ++++ packages/void/test/unit/compile.test.ts | 4 + packages/void/test/unit/pages-compile.test.ts | 75 ++++++++++- .../void/test/unit/pages-protocol.test.ts | 68 +++++++++- packages/void/test/unit/pages-scan.test.ts | 86 ++++++++++++- packages/void/test/unit/route-types.test.ts | 15 +++ packages/vue/README.md | 4 + packages/vue/src/plugin-pages.ts | 11 +- .../react-pages/pages/client-only.server.ts | 15 +++ playground/react-pages/pages/client-only.tsx | 15 +++ playground/react-pages/pages/index.tsx | 1 + playground/react-pages/pages/layout.tsx | 4 + .../static-pages/pages/client-only.server.ts | 11 ++ playground/static-pages/pages/client-only.vue | 16 +++ playground/static-pages/pages/layout.vue | 4 +- 32 files changed, 638 insertions(+), 114 deletions(-) create mode 100644 playground/react-pages/pages/client-only.server.ts create mode 100644 playground/react-pages/pages/client-only.tsx create mode 100644 playground/static-pages/pages/client-only.server.ts create mode 100644 playground/static-pages/pages/client-only.vue diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md index eb175d8d7..0f61c369a 100644 --- a/docs/guide/pages-routing/loaders.md +++ b/docs/guide/pages-routing/loaders.md @@ -237,7 +237,7 @@ export default function Dashboard(props: Props) { ### How Streaming Works -On the initial page load (SSR), React uses React 19 streaming SSR and renders the nearest Suspense fallback for deferred props; the other adapters render their loading state. As each deferred function resolves, the server streams an inline ` +
+ ${assetTags.body} + +`; +} + function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response { return Response.json(pageObj, { headers: pageResponseHeaders(c, { @@ -342,8 +378,9 @@ export async function handlePageGet( renderFn: RenderPage, getAssetTags: GetAssetTags, configHead?: HeadConfig, - island?: boolean, + options?: PageRenderOptions, ): Promise { + const renderMode = options?.renderMode ?? 'server'; const shared = (c.get('shared') as Record | undefined) ?? {}; // Run loader and check for redirect/error responses before extracting props @@ -401,7 +438,7 @@ export async function handlePageGet( }; // Island pages always render HTML — no Inertia JSON navigation - if (!island && isPagesRequest(c)) { + if (renderMode !== 'island' && isPagesRequest(c)) { // Prefetch requests: resolve all deferred props inline (plain JSON, no streaming) const isPrefetch = c.req.header('purpose') === 'prefetch'; if (isPrefetch && hasDeferred) { @@ -447,7 +484,10 @@ export async function handlePageGet( } const assetTags = prepareAssetTags(await getAssetTags(c.req.raw, c.env, componentId)); - const html = await renderWithAssetTags(renderFn, pageObj, assetTags); + const html = + renderMode === 'client' + ? renderClientOnlyPage(pageObj, assetTags, renderFn) + : await renderWithAssetTags(renderFn, pageObj, assetTags); if (!hasDeferred) { return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); @@ -477,8 +517,9 @@ export async function handlePageMutation( renderFn: RenderPage, getAssetTags: GetAssetTags, configHead?: HeadConfig, - island?: boolean, + options?: PageRenderOptions, ): Promise { + const renderMode = options?.renderMode ?? 'server'; const shared = (c.get('shared') as Record | undefined) ?? {}; const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined; @@ -504,7 +545,7 @@ export async function handlePageMutation( result = mutationHandler ? await mutationHandler(c) : undefined; } catch (e) { if (e instanceof ValidationError) { - if (island) { + if (renderMode === 'island') { return Response.json({ errors: e.errors }, { status: 422 }); } const rawProps = getHandler ? await extractProps(await getHandler(c)) : {}; @@ -514,12 +555,11 @@ export async function handlePageMutation( return pageJsonResponse(c, pageObj); } const assetTags = prepareAssetTags(await getAssetTags(c.req.raw, c.env, componentId)); - return renderedResponse( - c, - await renderWithAssetTags(renderFn, pageObj, assetTags), - 'text/html; charset=utf-8', - assetTags, - ); + const html = + renderMode === 'client' + ? renderClientOnlyPage(pageObj, assetTags, renderFn) + : await renderWithAssetTags(renderFn, pageObj, assetTags); + return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); } throw e; } @@ -535,7 +575,7 @@ export async function handlePageMutation( if (status === 400) { const body = await result.json(); const errors = flattenValidationErrors(body); - if (island) { + if (renderMode === 'island') { return Response.json({ errors }, { status: 422 }); } const rawProps = getHandler ? await extractProps(await getHandler(c)) : {}; @@ -545,12 +585,11 @@ export async function handlePageMutation( return pageJsonResponse(c, pageObj); } const assetTags = prepareAssetTags(await getAssetTags(c.req.raw, c.env, componentId)); - return renderedResponse( - c, - await renderWithAssetTags(renderFn, pageObj, assetTags), - 'text/html; charset=utf-8', - assetTags, - ); + const html = + renderMode === 'client' + ? renderClientOnlyPage(pageObj, assetTags, renderFn) + : await renderWithAssetTags(renderFn, pageObj, assetTags); + return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); } // 204 (void from withValidator) — fall through to re-run GET if (status !== 204) { @@ -559,7 +598,7 @@ export async function handlePageMutation( } // void — re-run GET with fresh props - if (island) { + if (renderMode === 'island') { return Response.json({ success: true }); } const rawProps = getHandler ? await extractProps(await getHandler(c)) : {}; @@ -569,10 +608,9 @@ export async function handlePageMutation( return pageJsonResponse(c, pageObj); } const assetTags = prepareAssetTags(await getAssetTags(c.req.raw, c.env, componentId)); - return renderedResponse( - c, - await renderWithAssetTags(renderFn, pageObj, assetTags), - 'text/html; charset=utf-8', - assetTags, - ); + const html = + renderMode === 'client' + ? renderClientOnlyPage(pageObj, assetTags, renderFn) + : await renderWithAssetTags(renderFn, pageObj, assetTags); + return renderedResponse(c, html, 'text/html; charset=utf-8', assetTags); } diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index c0135ea84..1f98323ac 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -5,27 +5,79 @@ import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import { parseRouteFilename } from '../router/parse-filename.ts'; import { resolveNamedLayout } from './layout-resolution.ts'; import type { + ExplicitBoolean, PageDefinition, + PageRenderMode, + PrerenderMode, LayoutDefinition, NamedLayoutDefinition, PageScanResult, + SsrMode, } from './types.ts'; const COMPONENT_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'md']; const SERVER_EXTENSIONS = ['ts', 'tsx', 'js', 'mjs', 'mts']; const PAGES_EXPORT_RE = /export\s+(?:const|function|async\s+function)\s+(loader|actions?)\b/g; -const REVALIDATE_RE = /export\s+const\s+revalidate\s*=\s*(\d+)\s*;/; -const PRERENDER_RE = /export\s+const\s+prerender\s*=\s*true\s*;/; -const PRERENDER_FALSE_RE = /export\s+const\s+prerender\s*=\s*false\s*;/; +const REVALIDATE_RE = /export\s+const\s+revalidate\s*=\s*(\d+)\s*;?/; +const PRERENDER_RE = /export\s+const\s+prerender\s*=\s*true\s*;?/; +const PRERENDER_FALSE_RE = /export\s+const\s+prerender\s*=\s*false\s*;?/; +const SSR_TRUE_RE = /export\s+const\s+ssr\s*=\s*true\s*;?/; +const SSR_FALSE_RE = /export\s+const\s+ssr\s*=\s*false\s*;?/; const GET_PRERENDER_PATHS_RE = /export\s+(?:async\s+)?function\s+getPrerenderPaths\b/; const LAYOUT_STRING_RE = /export\s+const\s+layout\s*=\s*["']([^"']+)["']\s*;?/; const LAYOUT_FALSE_RE = /export\s+const\s+layout\s*=\s*false\s*;?/; export const MAX_REVALIDATE = 31536000; // 1 year in seconds -export async function scanPages( - root: string, - options?: { output?: 'server' | 'static'; paths?: ProjectPaths; pagesDir?: string }, -): Promise { +type PageRenderingSource = { + componentPath: string; + island?: boolean; + ssr?: ExplicitBoolean; + prerender?: ExplicitBoolean; + methods: Array; + params: Array; + catchAll: boolean; + hasGetPrerenderPaths?: boolean; +}; + +type PageScanOptions = { output?: 'server' | 'static'; paths?: ProjectPaths; pagesDir?: string }; + +type ResolvedPageRendering = { + ssrMode: SsrMode; + prerenderMode: PrerenderMode; + renderMode: PageRenderMode; + shouldPrerender: boolean; +}; + +function inferAutoPrerender(page: PageRenderingSource, options?: PageScanOptions): boolean { + const isDynamic = page.params.length > 0 || page.catchAll; + + if (options?.output === 'static') { + return !isDynamic || page.hasGetPrerenderPaths === true; + } + + return page.island === true && !page.methods.includes('loader') && !isDynamic; +} + +function resolvePageRendering( + page: PageRenderingSource, + options?: PageScanOptions, +): ResolvedPageRendering { + if (page.island && page.ssr === false) { + throw new Error(`pages: ssr = false is not valid for island page '${page.componentPath}'.`); + } + + const ssrMode: SsrMode = page.ssr === true ? 'server' : page.ssr === false ? 'client' : 'auto'; + const prerenderMode: PrerenderMode = + page.prerender === true ? 'force' : page.prerender === false ? 'disabled' : 'auto'; + const renderMode: PageRenderMode = + page.island === true ? 'island' : ssrMode === 'client' ? 'client' : 'server'; + const shouldPrerender = + prerenderMode === 'force' || (prerenderMode === 'auto' && inferAutoPrerender(page, options)); + + return { ssrMode, prerenderMode, renderMode, shouldPrerender }; +} + +export async function scanPages(root: string, options?: PageScanOptions): Promise { const pagesDir = options?.pagesDir ?? options?.paths?.pagesDir ?? readProjectPaths(root).pagesDir; if (!existsSync(pagesDir)) { return { pages: [], layouts: [], namedLayouts: [] }; @@ -106,8 +158,8 @@ export async function scanPages( let serverPath: string | null = null; const methods: Array = []; let revalidate: number | undefined; - let prerender: boolean | undefined; - let prerenderExplicitlyFalse = false; + let prerender: ExplicitBoolean; + let ssr: ExplicitBoolean; let hasGetPrerenderPaths: boolean | undefined; const serverBase = hasIslandSuffix ? file.replace(`.island${ext}`, '') : file.replace(ext, ''); for (const serverExt of SERVER_EXTENSIONS) { @@ -124,9 +176,13 @@ export async function scanPages( } if (PRERENDER_RE.test(content)) { prerender = true; + } else if (PRERENDER_FALSE_RE.test(content)) { + prerender = false; } - if (PRERENDER_FALSE_RE.test(content)) { - prerenderExplicitlyFalse = true; + if (SSR_TRUE_RE.test(content)) { + ssr = true; + } else if (SSR_FALSE_RE.test(content)) { + ssr = false; } if (GET_PRERENDER_PATHS_RE.test(content)) { hasGetPrerenderPaths = true; @@ -135,31 +191,22 @@ export async function scanPages( } } - const isStaticOutput = options?.output === 'static'; - if (isStaticOutput) { - // Static output: default prerender=true for all pages unless explicitly false. - // Dynamic pages without getPrerenderPaths stay false (can't prerender without known paths). - if (!prerenderExplicitlyFalse && prerender !== true) { - const isDynamic = route.params.length > 0 || route.catchAll; - if (!isDynamic || hasGetPrerenderPaths) { - prerender = true; - } - } - } else { - // Auto-static: island page with no loader and no params → prerender - if ( - island && - !methods.includes('loader') && - route.params.length === 0 && - !route.catchAll && - !prerenderExplicitlyFalse - ) { - prerender = true; - } - } + const rendering = resolvePageRendering( + { + componentPath: file, + island, + ssr, + prerender, + methods, + params: route.params, + catchAll: route.catchAll, + hasGetPrerenderPaths, + }, + options, + ); // Prerender → max revalidate (applies to all pages) - if (prerender && revalidate === undefined) { + if (rendering.shouldPrerender && revalidate === undefined) { revalidate = MAX_REVALIDATE; } @@ -190,6 +237,8 @@ export async function scanPages( componentId, revalidate, prerender, + ssr, + ...rendering, hasGetPrerenderPaths, island, metadata, diff --git a/packages/void/src/pages/types.ts b/packages/void/src/pages/types.ts index 9e17e0190..e2b515adf 100644 --- a/packages/void/src/pages/types.ts +++ b/packages/void/src/pages/types.ts @@ -1,3 +1,11 @@ +export type ExplicitBoolean = true | false | undefined; + +export type SsrMode = 'auto' | 'server' | 'client'; + +export type PrerenderMode = 'auto' | 'force' | 'disabled'; + +export type PageRenderMode = 'server' | 'client' | 'island'; + export type PageDefinition = { /** URL pattern, e.g. "/users/:id" */ pattern: string; @@ -15,8 +23,18 @@ export type PageDefinition = { componentId: string; /** Edge caching revalidate TTL in seconds, from .server.ts export */ revalidate?: number; - /** Whether `export const prerender = true` is set in server handler */ - prerender?: boolean; + /** Whether `export const prerender = true/false` is set in server handler */ + prerender?: ExplicitBoolean; + /** Whether `export const ssr = true/false` is set in server handler */ + ssr?: ExplicitBoolean; + /** Normalized SSR mode from the public `ssr` export */ + ssrMode: SsrMode; + /** Normalized prerender mode from the public `prerender` export */ + prerenderMode: PrerenderMode; + /** Normalized page render mode */ + renderMode: PageRenderMode; + /** Whether this page should participate in prerender collection */ + shouldPrerender: boolean; /** Whether `export function getPrerenderPaths` is defined in server handler */ hasGetPrerenderPaths?: boolean; /** Whether this is an island page (.island.tsx/.island.vue) */ diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index dff42c6d1..dd2fad021 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1705,11 +1705,13 @@ function compilePrerenderCollector( options: CompileOptions, ): void { const staticPaths = scan.pages.pages - .filter((page) => page.prerender && page.params.length === 0 && !page.catchAll) + .filter((page) => page.shouldPrerender && page.params.length === 0 && !page.catchAll) .map((page) => page.pattern); const dynamicPages = scan.pages.pages.filter( (page) => - page.prerender && page.hasGetPrerenderPaths && (page.params.length > 0 || page.catchAll), + page.shouldPrerender && + page.hasGetPrerenderPaths && + (page.params.length > 0 || page.catchAll), ); const explicitPaths = options.explicitPrerenderPaths ?? []; @@ -1791,17 +1793,22 @@ function compilePageRoutes( lines.push( `import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from ${JSON.stringify(pages.headPath)};`, ); - lines.push(`import { renderPage } from "virtual:void-pages-renderer";`); + lines.push(`import * as __voidPagesRenderer from "virtual:void-pages-renderer";`); if (options.dev) { lines.push(`import { getVoidPagesDevCss } from "virtual:void-pages-dev-assets";`); } // Import island renderer if any island pages exist - const hasIslandPages = scan.pages.pages.some((p) => p.island); + const hasIslandPages = scan.pages.pages.some((p) => p.renderMode === 'island'); if (hasIslandPages) { lines.push(`import { renderIslandPage } from "virtual:void-islands-renderer";`); } lines.push(''); + lines.push(`const renderPage = __voidPagesRenderer.renderPage;`); + lines.push(`if (typeof __voidPagesRenderer.renderPreambleScript === "string") {`); + lines.push(` renderPage.preambleScript = __voidPagesRenderer.renderPreambleScript;`); + lines.push(`}`); + lines.push(''); // Emit head config constant from void.json lines.push(`const __voidHeadConfig = ${JSON.stringify(pages.headConfig ?? null)};`); @@ -2138,11 +2145,13 @@ function compilePageRoutes( mdHeadRef = headVarName; } - // Choose renderer and asset tags based on island flag - const renderer = page.island ? 'renderIslandPage' : 'renderPage'; + // Choose renderer and asset tags based on normalized render mode + const renderer = page.renderMode === 'island' ? 'renderIslandPage' : 'renderPage'; const assetTags = - page.island && pages.islandClientEntryId ? 'getIslandsAssetTags' : 'getPagesAssetTags'; - const islandFlag = page.island ? ', true' : ''; + page.renderMode === 'island' && pages.islandClientEntryId + ? 'getIslandsAssetTags' + : 'getPagesAssetTags'; + const renderOptions = `, ${JSON.stringify({ renderMode: page.renderMode })}`; // Loader (always registered — pages always handle GET) lines.push(`app.get(${JSON.stringify(page.pattern)}, async (c) => {`); @@ -2150,12 +2159,12 @@ function compilePageRoutes( const loaderName = `_pload_${safeId}`; lines.push(` const mod = await ${loaderName}();`); lines.push( - ` return handlePageGet(mod.loader, mod.head, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${islandFlag});`, + ` return handlePageGet(mod.loader, mod.head, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${renderOptions});`, ); } else { const headArg = mdHeadRef ?? 'undefined'; lines.push( - ` return handlePageGet(undefined, ${headArg}, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${islandFlag});`, + ` return handlePageGet(undefined, ${headArg}, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${renderOptions});`, ); } lines.push(`});`); @@ -2183,7 +2192,7 @@ function compilePageRoutes( } lines.push( - ` return handlePageMutation(handler, mod.loader, mod.head, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${islandFlag});`, + ` return handlePageMutation(handler, mod.loader, mod.head, c, ${JSON.stringify(page.componentId)}, ${renderer}, ${assetTags}, __voidHeadConfig${renderOptions});`, ); lines.push(`});`); } diff --git a/packages/void/test/integration/static-pages.test.ts b/packages/void/test/integration/static-pages.test.ts index be96cf918..dbc8ca66e 100644 --- a/packages/void/test/integration/static-pages.test.ts +++ b/packages/void/test/integration/static-pages.test.ts @@ -78,6 +78,27 @@ describe('playground/static-pages build (output: static)', () => { expect(aboutData.props.content).toBe('About page content'); }); + it('prerenders client-only pages as shells with static page data', () => { + const clientOnlyPath = join(DIST, 'client/client-only.html'); + expect(existsSync(clientOnlyPath)).toBe(true); + + const html = readFileSync(clientOnlyPath, 'utf-8'); + expect(html).toContain(''); + expect(html).toContain('__VOID_PAGE_DATA__'); + expect(html).toContain('data-void-ssr="false"'); + expect(html).toContain('Client-only Static Page'); + expect(html).not.toContain('data-testid="client-only-title"'); + expect(html).not.toContain('client-only static page component rendered on the server'); + + const pageData = JSON.parse( + readFileSync(join(DIST, 'client/_void/pages/client-only.json'), 'utf-8'), + ) as { component: string; props: Record; url: string }; + expect(pageData.component).toBe('client-only'); + expect(pageData.url).toBe('/client-only'); + expect(pageData.props.title).toBe('Client-only Static Page'); + expect(pageData.props.marker).toBe('static-loader-props'); + }); + it('prerenders dynamic pages with getPrerenderPaths', () => { // posts/[slug] exports getPrerenderPaths returning [{slug:"hello"},{slug:"world"}] const helloPath = join(DIST, 'client/posts/hello.html'); @@ -102,6 +123,7 @@ describe('playground/static-pages build (output: static)', () => { expect.arrayContaining([ '/', '/about', + '/client-only', '/posts/hello', '/posts/world', '/tags/vue', diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 64a8ddd6b..777765d90 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -2146,6 +2146,10 @@ describe('default-target dev parity', () => { params: [], catchAll: false, methods: [], + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, }, ], layouts: [], diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index 295272a15..188d7410f 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; +import type { PageDefinition, PageScanResult } from '../../src/pages/types.ts'; import { compileRouteTable, type PagesCompileOptions } from '../../src/router/compile.ts'; import type { ScanResult } from '../../src/router/scan.ts'; @@ -22,13 +23,36 @@ const pagesOptions: PagesCompileOptions = { clientEntryId: 'virtual:void-pages-client', }; -function makeScan(pages: ScanResult['pages']): ScanResult { +type TestPageDefinition = Omit< + PageDefinition, + 'ssrMode' | 'prerenderMode' | 'renderMode' | 'shouldPrerender' +> & + Partial>; + +function withRenderingDefaults(page: TestPageDefinition): PageDefinition { + const renderMode = + page.renderMode ?? (page.island ? 'island' : page.ssr === false ? 'client' : 'server'); + return { + ...page, + ssrMode: + page.ssrMode ?? (page.ssr === true ? 'server' : page.ssr === false ? 'client' : 'auto'), + prerenderMode: + page.prerenderMode ?? + (page.prerender === true ? 'force' : page.prerender === false ? 'disabled' : 'auto'), + renderMode, + shouldPrerender: page.shouldPrerender ?? page.prerender === true, + }; +} + +function makeScan( + pages: Omit & { pages: Array }, +): ScanResult { return { routes: [], middleware: [], jobs: [], queues: [], - pages, + pages: { ...pages, pages: pages.pages.map(withRenderingDefaults) }, }; } @@ -66,7 +90,7 @@ describe('compileRouteTable with pages', () => { ); }); - it('imports renderPage from virtual module', () => { + it('imports renderPage from the virtual module namespace', () => { const scan = makeScan({ pages: [ { @@ -94,7 +118,8 @@ describe('compileRouteTable with pages', () => { pagesOptions, ); - expect(code).toContain(`import { renderPage } from "virtual:void-pages-renderer"`); + expect(code).toContain(`import * as __voidPagesRenderer from "virtual:void-pages-renderer"`); + expect(code).toContain('const renderPage = __voidPagesRenderer.renderPage'); }); it('imports the dev CSS asset bridge in dev builds', () => { @@ -277,6 +302,43 @@ describe('compileRouteTable with pages', () => { expect(code).toContain('handlePageMutation(handler, mod.loader, mod.head'); }); + it('passes normalized client render mode to page handlers', () => { + const scan = makeScan({ + pages: [ + { + pattern: '/analytics', + params: [], + catchAll: false, + componentPath: 'analytics.vue', + serverPath: 'analytics.server.ts', + methods: ['loader', 'action'], + componentId: 'analytics', + ssr: false, + }, + ], + layouts: [], + namedLayouts: [], + }); + + const code = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + pagesOptions, + ); + + expect(code).toContain( + 'handlePageGet(mod.loader, mod.head, c, "analytics", renderPage, getPagesAssetTags, __voidHeadConfig, {"renderMode":"client"})', + ); + expect(code).toContain( + 'handlePageMutation(handler, mod.loader, mod.head, c, "analytics", renderPage, getPagesAssetTags, __voidHeadConfig, {"renderMode":"client"})', + ); + }); + it('generates catch-all for SPA navigation', () => { const scan = makeScan({ pages: [ @@ -447,7 +509,7 @@ describe('compileRouteTable with pages', () => { expect(code).toContain(`import { renderIslandPage } from "virtual:void-islands-renderer"`); expect(code).toContain('renderIslandPage'); expect(code).toContain('getIslandsAssetTags'); - expect(code).toContain(', true)'); + expect(code).toContain('{"renderMode":"island"}'); }); it('non-island page does not use island renderer', () => { @@ -526,7 +588,8 @@ describe('compileRouteTable with pages', () => { islandPagesOptions, ); - expect(code).toContain(`import { renderPage } from "virtual:void-pages-renderer"`); + expect(code).toContain(`import * as __voidPagesRenderer from "virtual:void-pages-renderer"`); + expect(code).toContain('const renderPage = __voidPagesRenderer.renderPage'); expect(code).toContain(`import { renderIslandPage } from "virtual:void-islands-renderer"`); }); diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts index 2d9d9a06a..cc9d73462 100644 --- a/packages/void/test/unit/pages-protocol.test.ts +++ b/packages/void/test/unit/pages-protocol.test.ts @@ -262,7 +262,7 @@ describe('handlePageGet', () => { renderFn, mockGetAssetTags, undefined, - true, + { renderMode: 'island' }, ); expect(response.headers.get('content-type')).toContain('text/html'); @@ -283,7 +283,7 @@ describe('handlePageGet', () => { renderFn, mockGetAssetTags, undefined, - false, + { renderMode: 'server' }, ); expect(response.headers.get('X-VoidPages')).toBe('true'); @@ -704,7 +704,7 @@ describe('handlePageMutation', () => { mockRenderFn, mockGetAssetTags, undefined, - true, + { renderMode: 'island' }, ); expect(response.status).toBe(422); @@ -737,7 +737,7 @@ describe('handlePageMutation', () => { mockRenderFn, mockGetAssetTags, undefined, - true, + { renderMode: 'island' }, ); expect(response.status).toBe(422); @@ -759,7 +759,7 @@ describe('handlePageMutation', () => { mockRenderFn, mockGetAssetTags, undefined, - true, + { renderMode: 'island' }, ); expect(response.status).toBe(200); @@ -785,7 +785,7 @@ describe('handlePageMutation', () => { mockRenderFn, mockGetAssetTags, undefined, - true, + { renderMode: 'island' }, ); expect(response.status).toBe(302); @@ -924,6 +924,62 @@ describe('handlePageGet with deferred props', () => { expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); }); + it('client-only page — returns an empty app shell with initial page data', async () => { + const c = await createTestContext('GET', '/analytics'); + + const handler = vi.fn(() => ({ title: 'Analytics' })); + const renderFn = Object.assign( + vi.fn(async () => 'server rendered'), + { + preambleScript: '', + }, + ); + const response = await handlePageGet( + handler, + () => ({ title: 'Analytics | App' }), + c, + 'analytics', + renderFn, + mockGetAssetTags, + undefined, + { renderMode: 'client' }, + ); + + const html = await response.text(); + expect(renderFn).not.toHaveBeenCalled(); + expect(html).toContain('Analytics | App'); + expect(html).toContain('window.__reactPreamble = true'); + expect(html).toContain('
'); + expect(html).toContain('__VOID_PAGE_DATA__'); + expect(html).toContain('"component":"analytics"'); + expect(html).toContain('"title":"Analytics"'); + expect(html).not.toContain('server rendered'); + }); + + it('client-only page — streams deferred script tags after the shell', async () => { + const c = await createTestContext('GET', '/analytics'); + + const response = await handlePageGet( + () => ({ + title: 'Analytics', + stats: defer(() => Promise.resolve({ visits: 12 })), + }), + undefined, + c, + 'analytics', + mockRenderFn, + mockGetAssetTags, + undefined, + { renderMode: 'client' }, + ); + + const html = await response.text(); + expect(html).toContain('
'); + expect(html).toContain('"deferred":["stats"]'); + expect(html).toContain('window.__resolveDeferred("stats",'); + expect(html).toContain('"visits":12'); + }); + it('deferred named group — resolves all keys together', async () => { const c = await createTestContext('GET', '/dashboard', { headers: { 'x-voidpages': 'true' }, diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 02f47b785..006a47fb3 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -200,6 +200,41 @@ describe('scanPages', () => { const page = result.pages.find((p) => p.componentId === 'products'); expect(page).toBeDefined(); expect(page!.prerender).toBe(true); + expect(page!.prerenderMode).toBe('force'); + expect(page!.shouldPrerender).toBe(true); + }); + + it('detects ssr = false from .server.ts and resolves client render mode', async () => { + writeFile('analytics.vue', ''); + writeFile('analytics.server.ts', 'export const ssr = false;\nexport const loader = () => [];'); + + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'analytics'); + expect(page).toBeDefined(); + expect(page!.ssr).toBe(false); + expect(page!.ssrMode).toBe('client'); + expect(page!.renderMode).toBe('client'); + }); + + it('detects ssr = true from .server.ts and resolves server render mode', async () => { + writeFile('analytics.vue', ''); + writeFile('analytics.server.ts', 'export const ssr = true;\nexport const loader = () => [];'); + + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'analytics'); + expect(page).toBeDefined(); + expect(page!.ssr).toBe(true); + expect(page!.ssrMode).toBe('server'); + expect(page!.renderMode).toBe('server'); + }); + + it('throws when ssr = false is used on an island page', async () => { + writeFile('analytics.island.vue', ''); + writeFile('analytics.server.ts', 'export const ssr = false;'); + + await expect(scanPages(TMP)).rejects.toThrow( + "pages: ssr = false is not valid for island page 'analytics.island.vue'.", + ); }); it('detects getPrerenderPaths from .server.ts', async () => { @@ -213,6 +248,8 @@ describe('scanPages', () => { const page = result.pages.find((p) => p.componentId === 'blog/[slug]'); expect(page).toBeDefined(); expect(page!.prerender).toBe(true); + expect(page!.prerenderMode).toBe('force'); + expect(page!.shouldPrerender).toBe(true); expect(page!.hasGetPrerenderPaths).toBe(true); }); @@ -288,6 +325,8 @@ describe('scanPages', () => { const page = result.pages.find((p) => p.componentId === 'about'); expect(page).toBeDefined(); expect(page!.prerender).toBeUndefined(); + expect(page!.prerenderMode).toBe('auto'); + expect(page!.shouldPrerender).toBe(false); expect(page!.hasGetPrerenderPaths).toBeUndefined(); }); @@ -295,7 +334,9 @@ describe('scanPages', () => { writeFile('pricing.island.vue', ''); const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'pricing'); - expect(page!.prerender).toBe(true); + expect(page!.prerender).toBeUndefined(); + expect(page!.prerenderMode).toBe('auto'); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(MAX_REVALIDATE); }); @@ -306,7 +347,8 @@ describe('scanPages', () => { ); const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'pricing'); - expect(page!.prerender).toBe(true); + expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(MAX_REVALIDATE); }); @@ -316,6 +358,7 @@ describe('scanPages', () => { const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'products'); expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(false); }); it('does not auto-prerender dynamic island page', async () => { @@ -323,6 +366,7 @@ describe('scanPages', () => { const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'blog/[slug]'); expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(false); }); it('auto-sets max revalidate for explicit prerender on any page', async () => { @@ -331,6 +375,8 @@ describe('scanPages', () => { const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'about'); expect(page!.prerender).toBe(true); + expect(page!.prerenderMode).toBe('force'); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(MAX_REVALIDATE); }); @@ -340,9 +386,37 @@ describe('scanPages', () => { const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'about'); expect(page!.prerender).toBe(true); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(3600); }); + it('detects render config exports without semicolons', async () => { + writeFile('about.vue', ''); + writeFile( + 'about.server.ts', + 'export const ssr = false\nexport const prerender = true\nexport const revalidate = 120', + ); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'about'); + expect(page!.ssr).toBe(false); + expect(page!.prerender).toBe(true); + expect(page!.renderMode).toBe('client'); + expect(page!.prerenderMode).toBe('force'); + expect(page!.shouldPrerender).toBe(true); + expect(page!.revalidate).toBe(120); + }); + + it('preserves explicit prerender = false as disabled prerender mode', async () => { + writeFile('pricing.island.vue', ''); + writeFile('pricing.server.ts', 'export const prerender = false;'); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'pricing'); + expect(page!.prerender).toBe(false); + expect(page!.prerenderMode).toBe('disabled'); + expect(page!.shouldPrerender).toBe(false); + expect(page!.revalidate).toBeUndefined(); + }); + it('scans .md files as island pages', async () => { writeFile('docs/getting-started.md', '# Getting Started\n\nHello world.'); const result = await scanPages(TMP); @@ -358,7 +432,8 @@ describe('scanPages', () => { writeFile('docs/guide.md', '# Guide'); const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'docs/guide'); - expect(page!.prerender).toBe(true); + expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(MAX_REVALIDATE); }); @@ -368,6 +443,7 @@ describe('scanPages', () => { const page = result.pages.find((p) => p.componentId === 'blog/[slug]'); expect(page!.island).toBe(true); expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(false); }); it('picks up companion .server.ts for .md page', async () => { @@ -379,6 +455,7 @@ describe('scanPages', () => { expect(page!.methods).toEqual(['loader']); // Has loader → no auto-prerender expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(false); }); it('scans .md index file correctly', async () => { @@ -395,7 +472,8 @@ describe('scanPages', () => { writeFile('pricing.server.ts', 'export const revalidate = 60;'); const result = await scanPages(TMP); const page = result.pages.find((p) => p.componentId === 'pricing'); - expect(page!.prerender).toBe(true); + expect(page!.prerender).toBeUndefined(); + expect(page!.shouldPrerender).toBe(true); expect(page!.revalidate).toBe(60); }); diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index 16db4b8ea..8f36dc239 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vite-plus/test'; import { generateRouteTypes } from '../../src/codegen/route-types.ts'; +import type { PageDefinition } from '../../src/pages/types.ts'; import type { ScanResult } from '../../src/router/scan.ts'; +const pageRenderingDefaults = { + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, +} satisfies Pick; + describe('generateRouteTypes', () => { it('generates route type declarations for single-method routes', () => { const scan: ScanResult = { @@ -196,6 +204,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users', params: [], catchAll: false, @@ -227,6 +236,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users/:id', params: ['id'], catchAll: false, @@ -289,6 +299,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/about', params: [], catchAll: false, @@ -318,6 +329,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users', params: [], catchAll: false, @@ -350,6 +362,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users/:id', params: ['id'], catchAll: false, @@ -381,6 +394,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users', params: [], catchAll: false, @@ -409,6 +423,7 @@ describe('generateRouteTypes', () => { pages: { pages: [ { + ...pageRenderingDefaults, pattern: '/users', params: [], catchAll: false, diff --git a/packages/vue/README.md b/packages/vue/README.md index ea55b9e59..9096b2df3 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -105,6 +105,10 @@ import { defineHandler } from 'void'; export const loader = defineHandler(() => ({ title: 'Home' })); ``` +Export `ssr = false` from a page's `.server.ts` file to skip server-rendered +component HTML for that route while still running its loader on the first +request. + ## Tests - `test/unit/use-form.test.ts` diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index a2844d623..860225e10 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -326,7 +326,7 @@ function generateClientEntry( staticPageData?: StaticPageDataBuildOptions, ): string { return ` -import { createSSRApp, h, reactive, provide, shallowRef, nextTick, ref } from "vue"; +import { createApp, createSSRApp, h, reactive, provide, shallowRef, nextTick, ref } from "vue"; import { createDeferred, resolveDeferred, rejectDeferred } from "@void/vue/deferred"; import { setActionRouter } from "@void/vue"; import { createRouterFacade, createVoidRouter, idleNavigationState } from "void/pages-client"; @@ -398,7 +398,10 @@ currentProps.value = initialPageData.props; currentComponent.value = initialComponent; currentLayouts.value = initialLayouts; -const app = createSSRApp({ +const appEl = document.getElementById("app"); +if (!appEl) throw new Error("pages: Missing #app element for Void Vue pages hydration."); +const createVoidApp = appEl?.getAttribute("data-void-ssr") === "false" ? createApp : createSSRApp; +const app = createVoidApp({ setup() { provide("__void_shared", shared); provide("__void_errors", errors); @@ -419,8 +422,8 @@ const app = createSSRApp({ } }); -app.mount("#app"); -document.getElementById("app").setAttribute("data-hydrated", "true"); +app.mount(appEl); +appEl.setAttribute("data-hydrated", "true"); export { reactiveRouter as router }; start(); `; diff --git a/playground/react-pages/pages/client-only.server.ts b/playground/react-pages/pages/client-only.server.ts new file mode 100644 index 000000000..4b0be33dc --- /dev/null +++ b/playground/react-pages/pages/client-only.server.ts @@ -0,0 +1,15 @@ +import { defineHandler, defineHead, type InferProps } from 'void'; + +export const ssr = false; + +export const loader = defineHandler(() => ({ + title: 'Client-only Route', + marker: 'loader-props-available', +})); + +export type Props = InferProps; + +export const head = defineHead((_c, props) => ({ + title: props.title, + meta: [{ name: 'description', content: 'React Pages client-only route' }], +})); diff --git a/playground/react-pages/pages/client-only.tsx b/playground/react-pages/pages/client-only.tsx new file mode 100644 index 000000000..570d09b80 --- /dev/null +++ b/playground/react-pages/pages/client-only.tsx @@ -0,0 +1,15 @@ +import type { Props } from './client-only.server.ts'; + +export default function ClientOnlyPage(props: Props) { + if (typeof window === 'undefined') { + throw new Error('client-only page component rendered on the server'); + } + + return ( +
+

Client-only Page

+

{props.title}

+

{props.marker}

+
+ ); +} diff --git a/playground/react-pages/pages/index.tsx b/playground/react-pages/pages/index.tsx index 8733d0d83..9b1722216 100644 --- a/playground/react-pages/pages/index.tsx +++ b/playground/react-pages/pages/index.tsx @@ -11,6 +11,7 @@ export default function HomePage({ title }: Props) { {' '} Link behavior{' '} Form behavior lab Live streams + Client-only route ); } diff --git a/playground/react-pages/pages/layout.tsx b/playground/react-pages/pages/layout.tsx index bf183e2f2..b02598ad6 100644 --- a/playground/react-pages/pages/layout.tsx +++ b/playground/react-pages/pages/layout.tsx @@ -32,6 +32,10 @@ export default function Layout({ children }: { children: React.ReactNode }) { Form Lab {' '} + |{' '} + + Client-only + {' '} | {shared.appName}
diff --git a/playground/static-pages/pages/client-only.server.ts b/playground/static-pages/pages/client-only.server.ts new file mode 100644 index 000000000..a1cbd0896 --- /dev/null +++ b/playground/static-pages/pages/client-only.server.ts @@ -0,0 +1,11 @@ +import { defineHandler, type InferProps } from 'void'; + +export const ssr = false; +export const prerender = true; + +export const loader = defineHandler(() => ({ + title: 'Client-only Static Page', + marker: 'static-loader-props', +})); + +export type Props = InferProps; diff --git a/playground/static-pages/pages/client-only.vue b/playground/static-pages/pages/client-only.vue new file mode 100644 index 000000000..69d2f60b6 --- /dev/null +++ b/playground/static-pages/pages/client-only.vue @@ -0,0 +1,16 @@ + + + diff --git a/playground/static-pages/pages/layout.vue b/playground/static-pages/pages/layout.vue index 0f4d15291..6bdbed102 100644 --- a/playground/static-pages/pages/layout.vue +++ b/playground/static-pages/pages/layout.vue @@ -1,8 +1,8 @@