diff --git a/.changeset/start-render-mode.md b/.changeset/start-render-mode.md new file mode 100644 index 0000000..b4586c4 --- /dev/null +++ b/.changeset/start-render-mode.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': minor +--- + +`start.renderMode: 'stream' | 'async'` (default `'stream'`), plus a per-request form and a runtime override — the fix for streaming SSR leaving `` fallbacks unresolved for clients that never run JavaScript (solidjs/solid#3280). `'async'` makes the generated handler adopt the `renderToStream` result's thenable, which resolves with the complete HTML once every boundary has settled: nothing has flushed, so each boundary's content is spliced in place of its placeholder — no fallback markup, no swap templates or scripts — while hydration data still serializes and JavaScript clients hydrate as before. The string then takes `createSSRResponse`'s string path: the response head commits, the document gets the doctype and client-entry injection, and a `Location` written mid-render becomes a real 3xx instead of the post-flush script redirect. The tradeoffs are inherent and documented: time-to-first-byte waits for the slowest boundary and the whole page buffers in memory; `deferStream` is moot (everything defers). The per-request form follows the `middleware`/`setup` convention — `renderMode: './src/render-mode.ts'`, a module default-exporting `(event) => 'stream' | 'async' | Promise<...>` run inside the request scope after the middleware chain — for policies like "complete documents for crawler user agents or `?nojs`, streaming for everyone else". Hosts driving the handler directly pass `handleRequest(request, { renderMode })`; precedence is that runtime option, then the module function, then the static config, and an invalid value from any source is rejected with an actionable error (unknown literals and missing module paths fail at config time). Works identically for authored entries. Generated entries also commit the response head at render completion (`onCompleteAll`) so `httpStatus`/`httpHeader` declarations survive the runtime's dispose-before-resolve in the awaited path; stream mode is unchanged. diff --git a/README.md b/README.md index dfda345..c523f1b 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,8 @@ same server functions. The object form carries the options (`start: true` is pure sugar for `start: {}` — both mean the identical start mode with defaults, and `false`/absent means off): `app`, `document`, `entryServer`, `entryClient`, -`middleware`, `setup`, `env`, `devtools`, `errorBoundary`, `css`, `external`, -all documented below. +`middleware`, `setup`, `renderMode`, `env`, `devtools`, `errorBoundary`, +`css`, `external`, all documented below. Install `@solidjs/start-devtools` as a development dependency to add the development toolbar with runtime errors and server function calls: @@ -376,6 +376,76 @@ whatever the hook renders must be matched client-side for hydration — routers that own both sides (their client entry re-creates the router and hydrates the same tree) fit naturally. +**`renderMode`** — how a page render becomes a response body: `'stream'` +(the default) or `'async'`, or a module path deciding per request. + +Streaming flushes the document shell as soon as it is ready, with every +`` fallback in place, and streams the boundaries' content behind it +in later chunks; inline scripts swap that content into the page as it +arrives. That is the best time-to-first-byte a server render can have, but a +client that never runs JavaScript — a crawler, `curl`, a browser with +scripts disabled — is left looking at the fallbacks forever +([solidjs/solid#3280](https://github.com/solidjs/solid/issues/3280)). +`'async'` is the other end of that trade: the handler awaits the render until +every boundary has settled and sends one complete document. + +```ts +solid({ start: { renderMode: 'async' }, ssr: true }); +``` + +Because nothing has flushed when a boundary resolves, its content is spliced +in place of its placeholder — the document carries no fallback markup, no +swap templates, no swap scripts — while hydration data still serializes +exactly as before, so JavaScript clients hydrate the settled document the +same way they hydrate a streamed one. The tradeoffs are inherent: the +response waits for the slowest boundary before its first byte, and the whole +page buffers in memory before it goes out. Two consequences worth knowing: +`deferStream` is moot under `'async'` (everything defers), and a `Location` +header written mid-render — the post-flush script redirect in stream mode — +becomes a real 3xx with no body, which is exactly what a no-JS client needs. + +Most apps want streaming for browsers and a complete document for the few +clients that cannot run the swap. The per-request form is a module path +(relative to the Vite root, following the `middleware`/`setup` convention +— a Vite config cannot serialize a closure into the generated handler) +default-exporting `(event) => 'stream' | 'async' | Promise<'stream' | +'async'>`. It runs inside the request scope after the middleware chain, so +`event.locals` is decorated by the time it decides: + +```ts +// vite.config.ts +solid({ start: { renderMode: './src/render-mode.ts' }, ssr: true }); + +// src/render-mode.ts +import type { RequestEvent } from '@solidjs/web'; + +const CRAWLER = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i; + +export default function renderMode(event: RequestEvent) { + const { request } = event; + if (new URL(request.url).searchParams.has('nojs')) return 'async'; + if (CRAWLER.test(request.headers.get('user-agent') ?? '')) return 'async'; + return 'stream'; +} +``` + +Hosts driving the handler directly can decide per call instead: +`handleRequest(request, { renderMode: 'async' })`. Precedence is that +runtime option, then the module function's result, then the static config; +an unknown value from any of the three is an error naming its source. The +mode applies to generated and authored entries alike — an authored +`render()` returning a `renderToStream` result is awaited the same way (and +in production its client-entry reference is still rewritten). One caveat for +authored entries: `httpStatus()` / `httpHeader()` declarations made during +the render are reverted when the runtime disposes it, which under `'async'` +happens before the response head is committed — the generated entry commits +the head at render completion (`renderToStream`'s `onCompleteAll`) to keep +them, so an authored entry that needs them under `'async'` should pass the +same hook (`onCompleteAll: () => commitResponseStub(getRequestEvent().response)`); +a `Location` written straight onto `event.response.headers` is unaffected. +Server mode only — in client mode the served shell has no boundaries to +settle, so the option is a documented no-op there. + **`env`** — first-party typed environment variables. A schema file at the project root — `env.ts` (or `env.js`), probed automatically; point elsewhere with `start: { env: './path' }`, disable with `env: false` — diff --git a/examples/start-ssr/src/render-mode.ts b/examples/start-ssr/src/render-mode.ts new file mode 100644 index 0000000..5493f3d --- /dev/null +++ b/examples/start-ssr/src/render-mode.ts @@ -0,0 +1,21 @@ +// Per-request render-mode policy for the render-mode e2e mode +// (SSR_RENDER_MODE=module wires it through `start.renderMode` in +// vite.config.ts). Server-only: only the generated handler imports it. The +// recipe from the README: serve one complete, settled document to clients +// that will never run the streaming swap scripts — crawlers and an explicit +// `?nojs` opt-in — and stream for everyone else. The `x-render-mode` header +// is the test's deterministic switch. Runs inside the request scope after +// the middleware chain (`event.locals` is decorated by then), and may be +// async — the handler awaits it before the render starts. +import type { RequestEvent } from '@solidjs/web'; + +const CRAWLER_UA = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i; + +export default function renderMode(event: RequestEvent): 'stream' | 'async' { + const { request } = event; + const header = request.headers.get('x-render-mode'); + if (header === 'stream' || header === 'async') return header; + if (new URL(request.url).searchParams.has('nojs')) return 'async'; + if (CRAWLER_UA.test(request.headers.get('user-agent') || '')) return 'async'; + return 'stream'; +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index ada2273..5b16dda 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -101,6 +101,16 @@ // - `vite preview` serves the production artifact with no server file: // dist/client statically, everything else (pages, /_server, middleware, // the lifecycle) through the built handler, +// - `start.renderMode` (render-mode mode, SSR_RENDER_MODE): 'async' sends +// one settled document — no Loading fallback markup, no swap scripts, +// boundary content in place, hydration data intact (the streamed page's +// browser checks pass against it) — with httpStatus/httpHeader still on +// the wire and a mid-render Location as a real 3xx; the per-request +// module form (src/render-mode.ts) switches modes within one server by +// header / crawler UA / `?nojs`; the `handleRequest(request, +// { renderMode })` override wins over both; authored entries get the same +// treatment; invalid config and runtime values are rejected with the fix +// in the message, // - lazy asset keys survive module identities beyond plain root-relative // paths (the /lazy-assets surface, dev and prod): a query-suffixed lazy // import keeps its query through the manifest key / dev URL (#299), and @@ -112,7 +122,7 @@ // // Requires the plugin built (pnpm build at the repo root) and Google Chrome. // Usage: node test/run.mjs -// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|base|builder-order|builder-prepare|babel-hmr|frames] +// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|babel-hmr|frames] // (default: all) import { spawn, execSync } from 'node:child_process'; @@ -792,11 +802,15 @@ async function runBrowserChecks( await cdp.waitFor('document.querySelector("[data-solid-dev-toolbar]") !== null'), ); } else if (devtools === false) { + const toolbar = await cdp.evalJs( + 'document.querySelector("[data-solid-dev-toolbar]")?.outerHTML.slice(0, 200) ?? null', + ); record( mode, 'devtools', 'no development toolbar in the DOM', - (await cdp.evalJs('document.querySelector("[data-solid-dev-toolbar]")')) === null, + toolbar === null, + `found: ${JSON.stringify(toolbar)}`, ); } @@ -2968,6 +2982,622 @@ async function runPreviewMode() { } } +// `start.renderMode` (SSR_RENDER_MODE in vite.config.ts): the fix for +// solidjs/solid#3280 — streaming leaves `` fallbacks unresolved for +// clients that never run the swap scripts. Asserted in dev, prod, and on +// direct handler dispatch: +// - 'async' (static config): the handler adopts the renderToStream +// thenable, so one complete document goes out — no Loading fallback +// markup, no swap templates/scripts, every boundary's content in place — +// with hydration data still serialized (the same browser checks as the +// streamed page: clean hydration, interactivity, server functions), +// - the response-head lifecycle under async: httpStatus/httpHeader still +// reach the wire (the generated entry commits the head at completion, +// before the runtime disposes the render), and a Location written +// mid-render — the post-flush script fallback in stream mode — becomes a +// real 3xx with no body, +// - stream mode is byte-for-byte the streaming story it always was (the +// default: dev/prod modes above; here re-asserted next to async), +// - the per-request module form (src/render-mode.ts) switches modes within +// one server — a header, a crawler user agent, `?nojs` → async; everyone +// else streams, +// - the `handleRequest(request, { renderMode })` runtime override wins over +// both the module function and the static config, and rejects an invalid +// value with an actionable error, +// - authored entries get the same treatment (their renderToStream result +// has the same thenable), the prod client-entry rewrite included, +// - config validation: an unknown literal and a missing module path are +// rejected at config time with the fix in the message. +async function runRenderModeMode() { + const mode = 'render-mode'; + console.log(`\n=== ${mode.toUpperCase()} ===`); + + // A settled document, as a no-JS client sees it: no fallback markup, no + // swap machinery, the streamed boundary's content sitting where the + // fallback would have been — and still hydratable. + const assertComplete = (tag, phase, html, { app = true } = {}) => { + record( + tag, + phase, + 'complete document (doctype … in one piece)', + html.startsWith(''), + ); + record( + tag, + phase, + 'no Loading fallback markup', + !html.includes('stream-fallback') && !html.includes('streaming…'), + ); + record( + tag, + phase, + 'no swap templates or swap scripts', + !/