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 && ( + + + + )}