Skip to content

Commit ad92979

Browse files
lxsmnsycclaudebrenelz
authored
feat: add ssr: "data-only" (#2352)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent 9ce2a5d commit ad92979

13 files changed

Lines changed: 364 additions & 7 deletions

File tree

‎.changeset/spa-server-exports.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Export `getServerFunctionMeta`, `decorateHandler` and `decorateMiddleware` from `@solidjs/start/server` in SPA mode (`ssr: false`), so apps using them build.

‎.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.
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
// @refresh skip
22
import type { JSX } from "solid-js";
33
import { render, type MountableElement } from "solid-js/web";
4+
import { seedInitialData } from "./initial-data.ts";
45

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

915
export { StartClient } from "../StartClient.tsx";
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.ts";
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/config/index.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,13 @@ export interface SolidStartOptions {
5555
*
5656
* - `true` — SSR (default)
5757
* - `false` — client-side rendering only (SPA mode)
58+
* - `"data-only"` — SPA mode, but the server runs the route `preload`s for the
59+
* requested page and sends their query results with the HTML shell. The
60+
* client renders without hydrating and without fetching that data again.
5861
*
5962
* @default true
6063
*/
61-
ssr?: boolean;
64+
ssr?: boolean | "data-only";
6265

6366
/**
6467
* Show the SolidStart development overlay (error overlay, etc.) in development.
@@ -371,7 +374,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
371374
resolve: {
372375
alias: {
373376
"@solidjs/start/server/entry": handlers.server,
374-
...(!start.ssr
377+
...(start.ssr !== true
375378
? {
376379
"@solidjs/start/server": "@solidjs/start/server/spa",
377380
"@solidjs/start/client": "@solidjs/start/client/spa",
@@ -391,7 +394,8 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
391394
},
392395
define: {
393396
"import.meta.env.MANIFEST": `globalThis.MANIFEST`,
394-
"import.meta.env.START_SSR": JSON.stringify(start.ssr),
397+
"import.meta.env.START_SSR": JSON.stringify(start.ssr === true),
398+
"import.meta.env.START_SSR_DATA": JSON.stringify(start.ssr === "data-only"),
395399
// Use JSON.stringify so backslashes on Windows are escaped and
396400
// esbuild receives a valid JS string literal for the define value
397401
"import.meta.env.START_APP_ENTRY": JSON.stringify(appEntryPath),
@@ -432,7 +436,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
432436
ssr: new SolidStartServerFileRouter({
433437
dir: absolute(routeDir, root),
434438
extensions,
435-
dataOnly: !start.ssr,
439+
dataOnly: start.ssr !== true,
436440
}),
437441
},
438442
}),

‎packages/start/src/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface ImportMetaEnv extends SolidStartMetaEnv {}
99

1010
interface SolidStartMetaEnv {
1111
START_SSR: boolean;
12+
START_SSR_DATA: boolean;
1213
START_APP_ENTRY: string;
1314
START_CLIENT_ENTRY: string;
1415
START_CLIENT_ENTRY_URL: string;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function createBaseHandler(
6565
const mode = resolvedOptions.mode || "stream";
6666
if (resolvedOptions.nonce) context.nonce = resolvedOptions.nonce;
6767

68-
if (mode === "sync" || !import.meta.env.START_SSR) {
68+
if (mode === "sync" || (!import.meta.env.START_SSR && !import.meta.env.START_SSR_DATA)) {
6969
const html = renderToString(() => {
7070
(sharedConfig.context as any).event = context;
7171
return fn(context);

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
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 { getSsrManifest } from "../manifest/ssr-manifest.ts";
66

77
import { TopErrorBoundary } from "../../shared/ErrorBoundary.tsx";
88
import { useAssets } from "../assets/index.ts";
99
import PatchVirtualDevStyles from "../assets/PatchVirtualDevStyles.tsx";
10+
import { InitialData } from "./initial-data.ts";
1011
import type { DocumentComponentProps, PageEvent } from "../types.ts";
1112

1213
const docType = ssr("<!DOCTYPE html>");
@@ -26,9 +27,23 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
2627
{docType as unknown as any}
2728
<TopErrorBoundary>
2829
<props.document
30+
assets={
31+
import.meta.env.START_SSR_DATA ? (
32+
// Defines `_$HY.r` for the streamed data. Nothing hydrates, so it
33+
// doesn't need to capture events for replay. The client types
34+
// don't list `eventNames`, the server implementation takes it.
35+
// @ts-expect-error
36+
<HydrationScript eventNames={[]} />
37+
) : undefined
38+
}
2939
scripts={
3040
<>
3141
<PatchVirtualDevStyles nonce={nonce} />
42+
{import.meta.env.START_SSR_DATA && (
43+
<Hydration>
44+
<InitialData />
45+
</Hydration>
46+
)}
3247
<script
3348
type="module"
3449
nonce={nonce}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { JSX } from "solid-js";
33
import { createBaseHandler } from "../handler.ts";
44
import { getSsrManifest } from "../manifest/ssr-manifest.ts";
55
import type { FetchEvent, HandlerOptions, PageEvent, StartHandler } from "../types.ts";
6+
import { loadInitialData } from "./initial-data.ts";
67

78
/**
89
*
@@ -17,6 +18,7 @@ export function createHandler(
1718
}
1819

1920
async function createPageEvent(ctx: FetchEvent) {
21+
ctx.response.headers.set("Content-Type", "text/html");
2022
const manifest = getSsrManifest("ssr");
2123
const pageEvent: PageEvent = Object.assign(ctx, {
2224
manifest: "json" in manifest ? await manifest.json() : {},
@@ -26,5 +28,7 @@ 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)