From fd9e80aebdfde38757f1ff68b7891d84d0ee0a32 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 13:18:48 +0800 Subject: [PATCH] feat: add ssr: "data-only" The app renders on the client without hydration, as with `ssr: false`, but the server runs the requested page's route preloads and streams their query results with the HTML shell. The client's queries read those results on the first render instead of fetching them again. The server collects the queries with the router's data-only pass, the same one single-flight mutations use, and streams them through Solid's own serializer under a `` boundary. On the client, `mount()` points `sharedConfig.has`/`load` at `_$HY.r` for the first `render()`, which is where `query` already looks for serialized data. Also export `getServerFunctionMeta`, `decorateHandler` and `decorateMiddleware` from the SPA server entry, so SPA apps that import them build. Closes #2287 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/spa-server-exports.md | 5 + .changeset/ssr-data-only.md | 5 + packages/start/src/client/spa/index.tsx | 8 +- .../start/src/client/spa/initial-data.spec.ts | 29 +++ packages/start/src/client/spa/initial-data.ts | 25 +++ packages/start/src/config/index.ts | 12 +- packages/start/src/env.d.ts | 1 + packages/start/src/server/handler.ts | 2 +- packages/start/src/server/spa/StartServer.tsx | 17 +- packages/start/src/server/spa/handler.ts | 4 + packages/start/src/server/spa/index.tsx | 3 + .../start/src/server/spa/initial-data.spec.ts | 165 ++++++++++++++++++ packages/start/src/server/spa/initial-data.ts | 95 ++++++++++ 13 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 .changeset/spa-server-exports.md create mode 100644 .changeset/ssr-data-only.md create mode 100644 packages/start/src/client/spa/initial-data.spec.ts create mode 100644 packages/start/src/client/spa/initial-data.ts create mode 100644 packages/start/src/server/spa/initial-data.spec.ts create mode 100644 packages/start/src/server/spa/initial-data.ts diff --git a/.changeset/spa-server-exports.md b/.changeset/spa-server-exports.md new file mode 100644 index 000000000..0b93f41f4 --- /dev/null +++ b/.changeset/spa-server-exports.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Export `getServerFunctionMeta`, `decorateHandler` and `decorateMiddleware` from `@solidjs/start/server` in SPA mode (`ssr: false`), so apps using them build. diff --git a/.changeset/ssr-data-only.md b/.changeset/ssr-data-only.md new file mode 100644 index 000000000..6cb06a6f1 --- /dev/null +++ b/.changeset/ssr-data-only.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +Add `ssr: "data-only"`. The app renders on the client without hydration, as with `ssr: false`, but the server runs the requested page's route `preload`s and streams their query results with the HTML shell, so the client doesn't fetch that data again. diff --git a/packages/start/src/client/spa/index.tsx b/packages/start/src/client/spa/index.tsx index 77ad443bc..7ec13dc73 100644 --- a/packages/start/src/client/spa/index.tsx +++ b/packages/start/src/client/spa/index.tsx @@ -1,9 +1,15 @@ // @refresh skip import type { JSX } from "solid-js"; import { render, type MountableElement } from "solid-js/web"; +import { seedInitialData } from "./initial-data.ts"; export function mount(fn: () => JSX.Element, el: MountableElement) { - render(fn, el); + const restore = import.meta.env.START_SSR_DATA ? seedInitialData() : undefined; + try { + render(fn, el); + } finally { + restore?.(); + } } export { StartClient } from "../StartClient.tsx"; diff --git a/packages/start/src/client/spa/initial-data.spec.ts b/packages/start/src/client/spa/initial-data.spec.ts new file mode 100644 index 000000000..abd491678 --- /dev/null +++ b/packages/start/src/client/spa/initial-data.spec.ts @@ -0,0 +1,29 @@ +import { sharedConfig } from "solid-js"; +import { afterEach, describe, expect, it } from "vitest"; + +import { seedInitialData } from "./initial-data.ts"; + +describe("seedInitialData", () => { + afterEach(() => { + delete (globalThis as any)._$HY; + }); + + it("does nothing without streamed data", () => { + expect(seedInitialData()).toBeUndefined(); + expect(sharedConfig.has).toBeUndefined(); + }); + + it("lets the first render read the streamed data, then puts sharedConfig back", () => { + const value = Promise.resolve({ id: 1 }); + (globalThis as any)._$HY = { r: { 'user["1"]': value } }; + + const restore = seedInitialData()!; + expect(sharedConfig.has!('user["1"]')).toBe(true); + expect(sharedConfig.has!("other[]")).toBe(false); + expect(sharedConfig.load!('user["1"]')).toBe(value); + + restore(); + expect(sharedConfig.has).toBeUndefined(); + expect(sharedConfig.load).toBeUndefined(); + }); +}); diff --git a/packages/start/src/client/spa/initial-data.ts b/packages/start/src/client/spa/initial-data.ts new file mode 100644 index 000000000..e1a60e8c4 --- /dev/null +++ b/packages/start/src/client/spa/initial-data.ts @@ -0,0 +1,25 @@ +import { sharedConfig } from "solid-js"; + +/** + * With `ssr: "data-only"`, the HTML shell streams the query results of the + * requested page into `_$HY.r`, which is where hydration keeps serialized + * data. The router's `query` already reads that through `sharedConfig.has` and + * `sharedConfig.load` on a cache miss, but those are only set while + * hydrating. Set them for the first render so the queries take the streamed + * results instead of fetching them again. + * + * Returns a function that puts `sharedConfig` back once the first render has + * made its queries. + */ +export function seedInitialData(): (() => void) | undefined { + const records = (globalThis as { _$HY?: { r?: Record } })._$HY?.r; + if (!records) return; + + const { has, load } = sharedConfig; + sharedConfig.has = key => key in records; + sharedConfig.load = key => records[key]; + return () => { + sharedConfig.has = has; + sharedConfig.load = load; + }; +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 658391c81..e3da30ddc 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -55,10 +55,13 @@ export interface SolidStartOptions { * * - `true` — SSR (default) * - `false` — client-side rendering only (SPA mode) + * - `"data-only"` — SPA mode, but the server runs the route `preload`s for the + * requested page and sends their query results with the HTML shell. The + * client renders without hydrating and without fetching that data again. * * @default true */ - ssr?: boolean; + ssr?: boolean | "data-only"; /** * Show the SolidStart development overlay (error overlay, etc.) in development. @@ -347,7 +350,7 @@ export function solidStart(options?: SolidStartOptions): Array { resolve: { alias: { "@solidjs/start/server/entry": handlers.server, - ...(!start.ssr + ...(start.ssr !== true ? { "@solidjs/start/server": "@solidjs/start/server/spa", "@solidjs/start/client": "@solidjs/start/client/spa", @@ -367,7 +370,8 @@ export function solidStart(options?: SolidStartOptions): Array { }, define: { "import.meta.env.MANIFEST": `globalThis.MANIFEST`, - "import.meta.env.START_SSR": JSON.stringify(start.ssr), + "import.meta.env.START_SSR": JSON.stringify(start.ssr === true), + "import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"), // Use JSON.stringify so backslashes on Windows are escaped and // esbuild receives a valid JS string literal for the define value "import.meta.env.START_APP_ENTRY": JSON.stringify(appEntryPath), @@ -404,7 +408,7 @@ export function solidStart(options?: SolidStartOptions): Array { ssr: new SolidStartServerFileRouter({ dir: absolute(routeDir, root), extensions, - dataOnly: !start.ssr, + dataOnly: start.ssr !== true, }), }, }), diff --git a/packages/start/src/env.d.ts b/packages/start/src/env.d.ts index da45f74d6..3d32bc363 100644 --- a/packages/start/src/env.d.ts +++ b/packages/start/src/env.d.ts @@ -9,6 +9,7 @@ interface ImportMetaEnv extends SolidStartMetaEnv {} interface SolidStartMetaEnv { START_SSR: boolean; + START_SSR_DATA: boolean; START_APP_ENTRY: string; START_CLIENT_ENTRY: string; START_CLIENT_ENTRY_URL: string; diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index 550785857..1b2a4831b 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -64,7 +64,7 @@ export function createBaseHandler( const mode = resolvedOptions.mode || "stream"; if (resolvedOptions.nonce) context.nonce = resolvedOptions.nonce; - if (mode === "sync" || !import.meta.env.START_SSR) { + if (mode === "sync" || (!import.meta.env.START_SSR && !import.meta.env.START_SSR_DATA)) { const html = renderToString(() => { (sharedConfig.context as any).event = context; return fn(context); diff --git a/packages/start/src/server/spa/StartServer.tsx b/packages/start/src/server/spa/StartServer.tsx index 17d7bfb8b..db0148108 100644 --- a/packages/start/src/server/spa/StartServer.tsx +++ b/packages/start/src/server/spa/StartServer.tsx @@ -1,12 +1,13 @@ // @refresh skip // @ts-ignore import type { Component } from "solid-js"; -import { NoHydration, getRequestEvent, ssr } from "solid-js/web"; +import { Hydration, HydrationScript, NoHydration, getRequestEvent, ssr } from "solid-js/web"; import { getSsrManifest } from "../manifest/ssr-manifest.ts"; import { TopErrorBoundary } from "../../shared/ErrorBoundary.tsx"; import { useAssets } from "../assets/index.ts"; import PatchVirtualDevStyles from "../assets/PatchVirtualDevStyles.tsx"; +import { InitialData } from "./initial-data.ts"; import type { DocumentComponentProps, PageEvent } from "../types.ts"; const docType = ssr(""); @@ -26,9 +27,23 @@ export function StartServer(props: { document: Component {docType as unknown as any} + ) : undefined + } scripts={ <> + {import.meta.env.START_SSR_DATA && ( + + + + )}