Skip to content

Commit a60b288

Browse files
Forward store options to the server projection so ssrSource works on stores
The server createStore dropped its options argument on the floor, making ssrSource inert for derived stores: "client" sources ran (and streamed) on the server (#2972) and "server" lost its serialization path (#2971). Explicit ssrSource:"server" now also serializes SYNCHRONOUS results — for stores, memos, signals, and effects alike. Without it nothing rode the payload, so the client re-ran a source that may not be client-safe and the in-memory value silently diverged from the claimed DOM. The default (no ssrSource) is unchanged: sync sources recompute on the client as their own parity mechanism, keeping payloads lean. Harness scenarios cover all three shapes: client-sourced async store (server renders seed, no stream hold), server-sourced async store (awaited, serialized, isServer probe proves no client re-run), and server-sourced sync store (probe surfaces the in-memory value to catch silent divergence). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0cd35f0 commit a60b288

6 files changed

Lines changed: 122 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"solid-js": patch
3+
---
4+
5+
Fix `ssrSource` on derived stores during SSR: the server `createStore` dropped the options argument entirely, so `ssrSource: "client"` sources still ran on the server (#2972) and `ssrSource: "server"` lost its serialization hints (#2971). Explicit `ssrSource: "server"` now also serializes synchronous results (stores, memos, signals, effects) so the client adopts the server value instead of silently re-running a source that may not be client-safe.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "store-ssr-source-client",
3+
"shell": "<div _hk=1>V: <!--$-->seed<!--/--></div>",
4+
"rest": ""
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "store-ssr-source-server-sync",
3+
"shell": "<div _hk=1>V: <!--$-->fromServer<!--/--> P: <!--$-->?<!--/--></div><script>(self.$R=self.$R||{})[\"\"]=[];_$HY.r[\"0\"]=($R[0]={v:\"fromServer\"});</script>",
4+
"rest": ""
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "store-ssr-source-server",
3+
"shell": "<div _hk=1>V: <!--$-->fromServer<!--/--></div><script>(self.$R=self.$R||{})[\"\"]=[];_$HY.r[\"0\"]=$R[0]=($R[1]=($R[2]=() => {\n const resolver = {\n p: 0,\n s: 0,\n f: 0\n };\n resolver.p = new Promise((resolve, reject) => {\n resolver.s = resolve;\n resolver.f = reject;\n });\n return resolver;\n})()).p;($R[4]=(resolver, data) => {\n resolver.s(data);\n resolver.p.s = 1;\n resolver.p.v = data;\n})($R[1],$R[3]={v:\"fromServer\"});</script>",
4+
"rest": ""
5+
}

packages/solid-web/test/harness/scenarios.tsx

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
* - Keep async delays short (5-15ms) — the specs own the settle waits.
2323
*/
2424
import { createSignal, createMemo, createStore, Show, For, Loading, Errored } from "solid-js";
25-
import { Portal, httpStatus, httpHeader, clientOnly } from "@solidjs/web";
25+
import { Portal, httpStatus, httpHeader, clientOnly, isServer } from "@solidjs/web";
2626

2727
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
2828

@@ -707,6 +707,59 @@ function LateBoundaryAfterDone() {
707707
);
708708
}
709709

710+
// ---------------------------------------------------------------------------
711+
// Derived store with ssrSource:"client" (#2972): the server must never run
712+
// the source — it renders the seed and serializes nothing — and the client
713+
// re-derives after hydration. The source is async, so a server-side run would
714+
// also hold the stream; the serverText probe catches both failure modes.
715+
function StoreSsrSourceClient() {
716+
const [store] = createStore<{ v: string }>(
717+
async () => {
718+
await sleep(10);
719+
return { v: "computed" };
720+
},
721+
{ v: "seed" },
722+
{ ssrSource: "client" }
723+
);
724+
return <div>V: {store.v}</div>;
725+
}
726+
727+
// ---------------------------------------------------------------------------
728+
// Derived async store with ssrSource:"server" (#2971): the server awaits the
729+
// source and serializes the settled state; the client adopts it and never
730+
// re-runs the source (the isServer probe would flip the text if it did).
731+
function StoreSsrSourceServer() {
732+
const [store] = createStore<{ v: string }>(
733+
async () => {
734+
await sleep(10);
735+
return { v: isServer ? "fromServer" : "fromClient" };
736+
},
737+
{ v: "seed" },
738+
{ ssrSource: "server" }
739+
);
740+
return <div>V: {store.v}</div>;
741+
}
742+
743+
// ---------------------------------------------------------------------------
744+
// SYNC source with ssrSource:"server": serialization is async-only, so
745+
// nothing rides the payload. The probe surfaces the client store's actual
746+
// in-memory value post-hydration to detect a silent DOM/store divergence.
747+
let probeSyncStore!: () => void;
748+
function StoreSsrSourceServerSync() {
749+
const [label, setLabel] = createSignal("?");
750+
const [store] = createStore<{ v: string }>(
751+
() => ({ v: isServer ? "fromServer" : "fromClient" }),
752+
{ v: "seed" },
753+
{ ssrSource: "server" }
754+
);
755+
probeSyncStore = () => setLabel(store.v);
756+
return (
757+
<div>
758+
V: {store.v} P: {label()}
759+
</div>
760+
);
761+
}
762+
710763
export const scenarios: Scenario[] = [
711764
{
712765
name: "text-hole",
@@ -966,5 +1019,30 @@ export const scenarios: Scenario[] = [
9661019
expectedText: "lead late content tail",
9671020
serverText: "waiting",
9681021
stableSelector: "div, span"
1022+
},
1023+
{
1024+
name: "store-ssr-source-client",
1025+
App: StoreSsrSourceClient,
1026+
async: true,
1027+
expectedText: "V: computed",
1028+
serverText: "V: seed",
1029+
stableSelector: "div"
1030+
},
1031+
{
1032+
name: "store-ssr-source-server",
1033+
App: StoreSsrSourceServer,
1034+
async: true,
1035+
expectedText: "V: fromServer",
1036+
serverText: "V: fromServer",
1037+
stableSelector: "div"
1038+
},
1039+
{
1040+
name: "store-ssr-source-server-sync",
1041+
App: StoreSsrSourceServerSync,
1042+
expectedText: "V: fromServer P: ?",
1043+
serverText: "V: fromServer P: ?",
1044+
update: () => probeSyncStore(),
1045+
expectedTextAfterUpdate: "V: fromServer P: fromServer",
1046+
stableSelector: "div"
9691047
}
9701048
];

packages/solid/src/server/signals.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,7 +1040,13 @@ function processResult<T>(
10401040
return;
10411041
}
10421042

1043-
// Synchronous value
1043+
// Synchronous value. Explicit "server" serializes it so the client adopts
1044+
// instead of re-running (async results already do, via the deferred
1045+
// promise) — without this, a non-client-safe sync compute re-runs during
1046+
// hydration and silently diverges from the claimed DOM (#2971, memo/signal/
1047+
// effect parity with the store fix). Absent ssrSource stays lean.
1048+
if (ssrSource === "server" && ctx?.async && ctx.serialize && id && !noHydrate)
1049+
ctx.serialize(id, result, deferStream);
10441050
comp.value = result;
10451051
}
10461052

@@ -1211,14 +1217,19 @@ export function createStore<T extends object>(
12111217
): [get: Store<T>, set: StoreSetter<T>];
12121218
export function createStore<T extends object>(
12131219
fn: (store: T) => void | T | Promise<void | T>,
1214-
store: Partial<T> | Store<T>
1220+
store: Partial<T> | Store<T>,
1221+
options?: ServerSsrOptions & { name?: string; shallow?: boolean }
12151222
): [get: Store<T>, set: StoreSetter<T>];
12161223
export function createStore<T extends object>(
12171224
first: T | Store<T> | ((store: T) => void | T | Promise<void | T>),
1218-
second?: T | Store<T>
1225+
second?: T | Store<T>,
1226+
options?: ServerSsrOptions & { name?: string; shallow?: boolean }
12191227
): [get: Store<T>, set: StoreSetter<T>] {
12201228
if (typeof first === "function") {
1221-
const store = createProjection(first as any, second as T);
1229+
// Forward options: dropping them made ssrSource inert for derived stores —
1230+
// "client" sources ran on the server (#2972) and "server" ones lost their
1231+
// deferStream/serialization hints (#2971).
1232+
const store = createProjection(first as any, second as T, options);
12221233
return [store as Store<T>, ((fn: (state: T) => void) => fn(store as T)) as StoreSetter<T>];
12231234
}
12241235
const state = first as T;
@@ -1480,6 +1491,14 @@ export function createProjection<T extends object>(
14801491
if (result !== undefined && result !== state && result !== draft) {
14811492
replaceState(state, result as T);
14821493
}
1494+
// Explicit "server" promises the client adopts this value without re-running
1495+
// the source. Async results keep that promise through the deferred-promise
1496+
// serialization above; sync results must ride the payload too, or the client
1497+
// re-runs a source that may not be client-safe and silently diverges from
1498+
// the claimed DOM (#2971). Default (no ssrSource) stays lean: sync sources
1499+
// recompute on the client as their own parity mechanism.
1500+
if (ssrSource === "server" && ctx?.async && !getContext(NoHydrateContext) && owner.id)
1501+
ctx.serialize(owner.id, state, options?.deferStream);
14831502
return state;
14841503
}
14851504

0 commit comments

Comments
 (0)