Skip to content

Commit 0a027ee

Browse files
lxsmnsycclaude
andauthored
feat: add ssr: "data-only" (1.x) (#2353)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 8f03760 commit 0a027ee

13 files changed

Lines changed: 388 additions & 15 deletions

File tree

‎.changeset/ssr-data-only.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": minor
3+
---
4+
5+
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.

‎packages/start/config/index.d.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,18 @@ type ViteCustomizableConfig = CustomizableConfig & {
1515

1616
type SolidStartInlineConfig = {
1717
publicDir?: string;
18-
ssr?: boolean;
18+
/**
19+
* Enable or disable server-side rendering.
20+
*
21+
* - `true` — SSR (default)
22+
* - `false` — client-side rendering only (SPA mode)
23+
* - `"data-only"` — SPA mode, but the server runs the route `preload`s for the
24+
* requested page and sends their query results with the HTML shell. The
25+
* client renders without hydrating and without fetching that data again.
26+
*
27+
* @default true
28+
*/
29+
ssr?: boolean | "data-only";
1930
solid?: Options;
2031
extensions?: string[];
2132
server?: AppOptions["server"];

‎packages/start/config/index.js‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ export function defineConfig(baseConfig = {}) {
9999

100100
const routeDir = join(start.appRoot, start.routeDir);
101101
let server = start.server;
102-
if (!start.ssr) {
102+
// Data-only mode runs each page's preloads per request, so it can't use a
103+
// prerendered shell.
104+
if (start.ssr === false) {
103105
server = { ...server, prerender: { routes: ["/"] } };
104106
}
105107
let entryExtension = ".tsx";
@@ -131,7 +133,7 @@ export function defineConfig(baseConfig = {}) {
131133
},
132134
handler: `${start.appRoot}/entry-server${entryExtension}`,
133135
middleware: start.middleware,
134-
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }),
136+
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }),
135137
extensions,
136138
target: "server",
137139
plugins: async () => {
@@ -160,7 +162,7 @@ export function defineConfig(baseConfig = {}) {
160162
alias: {
161163
"#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`),
162164
"~": join(process.cwd(), start.appRoot),
163-
...(!start.ssr
165+
...(start.ssr !== true
164166
? {
165167
"@solidjs/start/server": "@solidjs/start/server/spa"
166168
}
@@ -175,7 +177,8 @@ export function defineConfig(baseConfig = {}) {
175177
),
176178
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
177179
"import.meta.env.SSR": JSON.stringify(true),
178-
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
180+
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
181+
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
179182
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
180183
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
181184
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
@@ -220,7 +223,7 @@ export function defineConfig(baseConfig = {}) {
220223
...plugins,
221224
SolidStartServerFnsPlugin.client,
222225
start.experimental.islands ? serverComponents.client() : null,
223-
solid({ ...start.solid, ssr: start.ssr, extensions: extensions.map(ext => `.${ext}`) }),
226+
solid({ ...start.solid, ssr: start.ssr === true, extensions: extensions.map(ext => `.${ext}`) }),
224227
config("app-client", {
225228
resolve: {
226229
alias: {
@@ -231,7 +234,7 @@ export function defineConfig(baseConfig = {}) {
231234
"@solidjs/start/client": "@solidjs/start/client/islands"
232235
}
233236
: {}),
234-
...(!start.ssr
237+
...(start.ssr !== true
235238
? {
236239
"@solidjs/start/client": "@solidjs/start/client/spa"
237240
}
@@ -246,7 +249,8 @@ export function defineConfig(baseConfig = {}) {
246249
),
247250
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
248251
"import.meta.env.SSR": JSON.stringify(false),
249-
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
252+
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
253+
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
250254
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
251255
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
252256
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
@@ -269,7 +273,7 @@ export function defineConfig(baseConfig = {}) {
269273
),
270274
middleware: start.middleware,
271275
target: "server",
272-
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: !start.ssr }),
276+
routes: solidStartServerFsRouter({ dir: routeDir, extensions, dataOnly: start.ssr !== true }),
273277
plugins: async () => {
274278
const userConfig =
275279
typeof vite === "function" ? await vite({ router: "server-function" }) : { ...vite };
@@ -297,7 +301,7 @@ export function defineConfig(baseConfig = {}) {
297301
alias: {
298302
"#start/app": join(process.cwd(), start.appRoot, `app${entryExtension}`),
299303
"~": join(process.cwd(), start.appRoot),
300-
...(!start.ssr
304+
...(start.ssr !== true
301305
? {
302306
"@solidjs/start/server": "@solidjs/start/server/spa"
303307
}
@@ -311,7 +315,8 @@ export function defineConfig(baseConfig = {}) {
311315
),
312316
"import.meta.env.START_ISLANDS": JSON.stringify(start.experimental.islands),
313317
"import.meta.env.SSR": JSON.stringify(true),
314-
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
318+
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
319+
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
315320
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
316321
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
317322
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(

‎packages/start/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface ImportMetaEnv extends Record<`VITE_${string}`, any>, SolidStartMetaEnv
2020

2121
interface SolidStartMetaEnv {
2222
START_SSR: boolean;
23+
START_SSR_DATA: boolean;
2324
START_ISLANDS: boolean;
2425
START_DEV_OVERLAY: boolean;
2526
START_EARLY_HINTS: boolean;

‎packages/start/src/client/spa/index.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
import type { JSX } from "solid-js";
33
import { render, type MountableElement } from "solid-js/web";
44
import "vinxi/client";
5+
import { seedInitialData } from "./initial-data";
56

67
export function mount(fn: () => JSX.Element, el: MountableElement) {
7-
render(fn, el);
8+
const restore = import.meta.env.START_SSR_DATA ? seedInitialData() : undefined;
9+
try {
10+
render(fn, el);
11+
} finally {
12+
restore?.();
13+
}
814
}
915

1016
export { StartClient, StartClientTanstack } from "../StartClient";
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { sharedConfig } from "solid-js";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
4+
import { seedInitialData } from "./initial-data";
5+
6+
describe("seedInitialData", () => {
7+
afterEach(() => {
8+
delete (globalThis as any)._$HY;
9+
});
10+
11+
it("does nothing without streamed data", () => {
12+
expect(seedInitialData()).toBeUndefined();
13+
expect(sharedConfig.has).toBeUndefined();
14+
});
15+
16+
it("lets the first render read the streamed data, then puts sharedConfig back", () => {
17+
const value = Promise.resolve({ id: 1 });
18+
(globalThis as any)._$HY = { r: { 'user["1"]': value } };
19+
20+
const restore = seedInitialData()!;
21+
expect(sharedConfig.has!('user["1"]')).toBe(true);
22+
expect(sharedConfig.has!("other[]")).toBe(false);
23+
expect(sharedConfig.load!('user["1"]')).toBe(value);
24+
25+
restore();
26+
expect(sharedConfig.has).toBeUndefined();
27+
expect(sharedConfig.load).toBeUndefined();
28+
});
29+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { sharedConfig } from "solid-js";
2+
3+
/**
4+
* With `ssr: "data-only"`, the HTML shell streams the query results of the
5+
* requested page into `_$HY.r`, which is where hydration keeps serialized
6+
* data. The router's `query` already reads that through `sharedConfig.has` and
7+
* `sharedConfig.load` on a cache miss, but those are only set while
8+
* hydrating. Set them for the first render so the queries take the streamed
9+
* results instead of fetching them again.
10+
*
11+
* Returns a function that puts `sharedConfig` back once the first render has
12+
* made its queries.
13+
*/
14+
export function seedInitialData(): (() => void) | undefined {
15+
const records = (globalThis as { _$HY?: { r?: Record<string, unknown> } })._$HY?.r;
16+
if (!records) return;
17+
18+
const { has, load } = sharedConfig;
19+
sharedConfig.has = key => key in records;
20+
sharedConfig.load = key => records[key];
21+
return () => {
22+
sharedConfig.has = has;
23+
sharedConfig.load = load;
24+
};
25+
}

‎packages/start/src/server/handler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function createBaseHandler(
7070
// @ts-ignore
7171
if (resolvedOptions.nonce) context.nonce = resolvedOptions.nonce;
7272

73-
if (mode === "sync" || !import.meta.env.START_SSR) {
73+
if (mode === "sync" || (!import.meta.env.START_SSR && !import.meta.env.START_SSR_DATA)) {
7474
const html = renderToString(() => {
7575
(sharedConfig.context as any).event = context;
7676
return fn(context);

‎packages/start/src/server/spa/StartServer.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// @refresh skip
22
// @ts-ignore
33
import type { Component } from "solid-js";
4-
import { NoHydration, getRequestEvent, ssr } from "solid-js/web";
4+
import { Hydration, HydrationScript, NoHydration, getRequestEvent, ssr } from "solid-js/web";
55
import { TopErrorBoundary } from "../../shared/ErrorBoundary";
66
import { renderAsset } from "../renderAsset";
77
import type { DocumentComponentProps, PageEvent } from "../types";
8+
import { InitialData } from "./initial-data";
89

910
const docType = ssr("<!DOCTYPE html>");
1011

@@ -21,10 +22,27 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
2122
{docType as unknown as any}
2223
<TopErrorBoundary>
2324
<props.document
24-
assets={<>{context.assets.map((m: any) => renderAsset(m))}</>}
25+
assets={
26+
<>
27+
{import.meta.env.START_SSR_DATA && (
28+
// Defines `_$HY.r` for the streamed data. Nothing hydrates, so
29+
// it doesn't need to capture events for replay. The client
30+
// types don't list `eventNames`, the server implementation
31+
// takes it.
32+
// @ts-ignore
33+
<HydrationScript eventNames={[]} />
34+
)}
35+
{context.assets.map((m: any) => renderAsset(m))}
36+
</>
37+
}
2538
scripts={
2639
nonce ? (
2740
<>
41+
{import.meta.env.START_SSR_DATA && (
42+
<Hydration>
43+
<InitialData />
44+
</Hydration>
45+
)}
2846
<script
2947
nonce={nonce}
3048
innerHTML={`window.manifest = ${JSON.stringify(context.manifest)}`}
@@ -40,6 +58,11 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
4058
</>
4159
) : (
4260
<>
61+
{import.meta.env.START_SSR_DATA && (
62+
<Hydration>
63+
<InitialData />
64+
</Hydration>
65+
)}
4366
<script innerHTML={`window.manifest = ${JSON.stringify(context.manifest)}`} />
4467
<script
4568
type="module"

‎packages/start/src/server/spa/handler.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createBaseHandler } from "../handler";
22
import { FetchEvent, HandlerOptions, PageEvent } from "../types";
3+
import { loadInitialData } from "./initial-data";
34

45
/**
56
*
@@ -15,6 +16,7 @@ export function createHandler(
1516

1617
export async function createPageEvent(ctx: FetchEvent) {
1718
const clientManifest = import.meta.env.MANIFEST["client"]!;
19+
ctx.response.headers.set("Content-Type", "text/html");
1820
const pageEvent: PageEvent = Object.assign(ctx, {
1921
manifest: await clientManifest.json(),
2022
assets: [...(await clientManifest.inputs[clientManifest.handler]!.assets())],
@@ -26,5 +28,7 @@ export async function createPageEvent(ctx: FetchEvent) {
2628
$islands: new Set<string>()
2729
});
2830

31+
if (import.meta.env.START_SSR_DATA) await loadInitialData(pageEvent);
32+
2933
return pageEvent;
3034
}

0 commit comments

Comments
 (0)