From ef2391dba570b2095bc5a7900680b9579c0b7541 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 14 Oct 2026 13:25:49 +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. Data-only mode doesn't prerender `/` the way `ssr: false` does, because each request needs its own data, and the client is compiled without hydration markers. Also export `getServerFunctionMeta` from the SPA server entry, as the SSR entry does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/ssr-data-only.md | 5 + packages/start/config/index.d.ts | 13 +- packages/start/config/index.js | 25 +-- packages/start/env.d.ts | 1 + 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/server/handler.ts | 2 +- packages/start/src/server/spa/StartServer.tsx | 27 ++- 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 | 96 ++++++++++ 13 files changed, 388 insertions(+), 15 deletions(-) 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/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/config/index.d.ts b/packages/start/config/index.d.ts index bb1613f64..5b9052b52 100644 --- a/packages/start/config/index.d.ts +++ b/packages/start/config/index.d.ts @@ -15,7 +15,18 @@ type ViteCustomizableConfig = CustomizableConfig & { type SolidStartInlineConfig = { publicDir?: string; - ssr?: boolean; + /** + * Enable or disable server-side rendering. + * + * - `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 | "data-only"; solid?: Options; extensions?: string[]; server?: AppOptions["server"]; diff --git a/packages/start/config/index.js b/packages/start/config/index.js index 53d1946af..7fb6f3758 100644 --- a/packages/start/config/index.js +++ b/packages/start/config/index.js @@ -97,7 +97,9 @@ export function defineConfig(baseConfig = {}) { const routeDir = join(start.appRoot, start.routeDir); let server = start.server; - if (!start.ssr) { + // Data-only mode runs each page's preloads per request, so it can't use a + // prerendered shell. + if (start.ssr === false) { server = { ...server, prerender: { routes: ["/"] } }; } let entryExtension = ".tsx"; @@ -129,7 +131,7 @@ export function defineConfig(baseConfig = {}) { }, handler: `${start.appRoot}/entry-server${entryExtension}`, middleware: start.middleware, - routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }), + routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }), extensions, target: "server", plugins: async () => { @@ -158,7 +160,7 @@ export function defineConfig(baseConfig = {}) { alias: { "#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`), "~": join(process.cwd(), start.appRoot), - ...(!start.ssr + ...(start.ssr !== true ? { "@solidjs/start/server": "@solidjs/start/server/spa" } @@ -173,7 +175,8 @@ export function defineConfig(baseConfig = {}) { ), "import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands), "import.meta.env.SSR": JSON.stringify(true), - "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"), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define @@ -214,7 +217,7 @@ export function defineConfig(baseConfig = {}) { ...plugins, SolidStartServerFnsPlugin.client, start.experimental.islands ? serverComponents.client() : null, - solid({ ...start.solid, ssr: start.ssr, extensions: extensions.map(ext => `.${ext}`) }), + solid({ ...start.solid, ssr: start.ssr === true, extensions: extensions.map(ext => `.${ext}`) }), config("app-client", { resolve: { alias: { @@ -225,7 +228,7 @@ export function defineConfig(baseConfig = {}) { "@solidjs/start/client": "@solidjs/start/client/islands" } : {}), - ...(!start.ssr + ...(start.ssr !== true ? { "@solidjs/start/client": "@solidjs/start/client/spa" } @@ -240,7 +243,8 @@ export function defineConfig(baseConfig = {}) { ), "import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands), "import.meta.env.SSR": JSON.stringify(false), - "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"), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), "import.meta.env.SERVER_BASE_URL": JSON.stringify(server?.baseURL ?? ""), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), @@ -259,7 +263,7 @@ export function defineConfig(baseConfig = {}) { ), middleware: start.middleware, target: "server", - routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }), + routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }), plugins: async () => { const userConfig = typeof vite === "function" ? await vite({ router: "server-function" }) : { ...vite }; @@ -287,7 +291,7 @@ export function defineConfig(baseConfig = {}) { alias: { "#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`), "~": join(process.cwd(), start.appRoot), - ...(!start.ssr + ...(start.ssr !== true ? { "@solidjs/start/server": "@solidjs/start/server/spa" } @@ -301,7 +305,8 @@ export function defineConfig(baseConfig = {}) { ), "import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands), "import.meta.env.SSR": JSON.stringify(true), - "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"), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), "import.meta.env.SEROVAL_MODE": JSON.stringify(serializationMode), ...userConfig.define diff --git a/packages/start/env.d.ts b/packages/start/env.d.ts index 14f796189..5da13e884 100644 --- a/packages/start/env.d.ts +++ b/packages/start/env.d.ts @@ -20,6 +20,7 @@ interface ImportMetaEnv extends Record<`VITE_${string}`, any>, SolidStartMetaEnv interface SolidStartMetaEnv { START_SSR: boolean; + START_SSR_DATA: boolean; START_ISLANDS: boolean; START_DEV_OVERLAY: boolean; SERVER_BASE_URL: string; diff --git a/packages/start/src/client/spa/index.tsx b/packages/start/src/client/spa/index.tsx index f18442ff8..c09939a6f 100644 --- a/packages/start/src/client/spa/index.tsx +++ b/packages/start/src/client/spa/index.tsx @@ -2,9 +2,15 @@ import type { JSX } from "solid-js"; import { render, type MountableElement } from "solid-js/web"; import "vinxi/client"; +import { seedInitialData } from "./initial-data"; 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, StartClientTanstack } from "../StartClient"; 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..35693541c --- /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"; + +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/server/handler.ts b/packages/start/src/server/handler.ts index 01714c1ac..bb1d443a5 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -70,7 +70,7 @@ export function createBaseHandler( // @ts-ignore 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 1173e2afe..2c49a887e 100644 --- a/packages/start/src/server/spa/StartServer.tsx +++ b/packages/start/src/server/spa/StartServer.tsx @@ -1,10 +1,11 @@ // @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 { TopErrorBoundary } from "../../shared/ErrorBoundary"; import { renderAsset } from "../renderAsset"; import type { DocumentComponentProps, PageEvent } from "../types"; +import { InitialData } from "./initial-data"; const docType = ssr(""); @@ -21,10 +22,27 @@ export function StartServer(props: { document: Component {docType as unknown as any} {context.assets.map((m: any) => renderAsset(m))}} + assets={ + <> + {import.meta.env.START_SSR_DATA && ( + // Defines `_$HY.r` for the streamed data. Nothing hydrates, so + // it doesn't need to capture events for replay. The client + // types don't list `eventNames`, the server implementation + // takes it. + // @ts-ignore + + )} + {context.assets.map((m: any) => renderAsset(m))} + + } scripts={ nonce ? ( <> + {import.meta.env.START_SSR_DATA && ( + + + + )}