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 11d5c84c7..3d5aa28e9 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. @@ -371,7 +374,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", @@ -391,7 +394,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), @@ -432,7 +436,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 d98950c03..eb38b2dce 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 9737709d7..060f6d3e6 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -65,7 +65,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 && ( + + + + )}