Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/shallow-store-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@solidjs/signals": patch
"solid-js": patch
---

Preserve shallow store leaf identity in computed drafts, loading snapshots, and SSR hydration. Wait for the server answer and hydration completion before hybrid store takeover, and suppress the first client yield only during the initial handoff.
29 changes: 20 additions & 9 deletions packages/signals/src/store/next/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ import type { StoreNextFamily } from "./target.js";
function wrapDraft(
inner: any,
isActive?: () => boolean,
aroundWrite?: (op: () => void) => void
aroundWrite?: (op: () => void) => void,
wrap: (value: any, active?: () => boolean, write?: (op: () => void) => void) => any = wrapDraft
): any {
const write = (op: () => void) => (aroundWrite ? aroundWrite(op) : op());
const traps: ProxyHandler<any> = {
Expand All @@ -79,9 +80,8 @@ function wrapDraft(
setWriteOverride(false);
setProjectionWriteActive(was);
}
if (prop === $TARGET) return value;
return typeof value === "object" && value !== null
? wrapDraft(value, isActive, aroundWrite)
return typeof value === "object" && value !== null && prop !== $TARGET
? wrap(value, isActive, aroundWrite)
: value;
},
has(_, prop) {
Expand Down Expand Up @@ -236,6 +236,16 @@ export function createStoreDerivedNext<T extends object = {}>(
];
}

function cloneProjection<T extends object>(value: T, shallow?: boolean): T {
return (
shallow
? Array.isArray(value)
? value.slice()
: { ...value }
: JSON.parse(JSON.stringify(value))
) as T;
}

export function runProjectionComputedNext<T extends object>(
wrappedStore: Store<T>,
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
Expand All @@ -244,19 +254,20 @@ export function runProjectionComputedNext<T extends object>(
aroundDraftWrite?: (op: () => void) => void
): Computed<void | T> {
const owner = getOwner() as Computed<void | T>;
const target = (wrappedStore as any)[$TARGET];
let settled = false;
let result: void | T | Promise<void | T> | AsyncIterable<void | T>;
// Open loading window (seedLoadingValue): the observable store IS commit #0
// for the whole first flight — the derive works a detached shadow of the
// seed so draft writes cannot tear through to readers (#2988). Every commit
// point reconciles the shadow through the normal commit path.
const shadow = owner._loading
? (JSON.parse(JSON.stringify((wrappedStore as any)[$TARGET][STORE_VALUE])) as T)
: null;
const shadow = owner._loading ? cloneProjection(target[STORE_VALUE], target.s) : null;
// Choose leaf wrapping once; Object is identity for the object-valued reads below.
const draft = wrapDraft(
wrappedStore,
() => !settled || owner._x?._inFlight === result,
aroundDraftWrite
aroundDraftWrite,
target.s ? Object : wrapDraft
);
storeSetterNext(
draft,
Expand All @@ -268,7 +279,7 @@ export function runProjectionComputedNext<T extends object>(
// (adoption takes the value by identity — handing it the live shadow
// would fuse the draft to the observable store).
if (shadow && (v === undefined || v === (shadow as any)))
v = JSON.parse(JSON.stringify(shadow)) as T;
v = cloneProjection(shadow, target.s);
if (v === (s as any) || v === undefined) return;
const write = () =>
storeSetterNext(wrappedStore, st => reconcileNextState(v, st, key, true), false);
Expand Down
55 changes: 55 additions & 0 deletions packages/signals/tests/store/shallow-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "vitest";
import {
createRoot,
createStore,
createOptimisticStore,
flush,
NotReadyError
} from "../../src/index.js";

describe("shallow loading projections", () => {
for (const optimistic of [false, true])
for (const array of [false, true])
for (const loading of [false, true]) {
test(`retains raw leaf identity: optimistic=${optimistic}, array=${array}, loading=${loading}`, async () => {
const original = Object.freeze({
time: new Date(0),
missing: undefined,
n: NaN,
key: {}
});
const replacement = Object.freeze({ ...original, time: new Date(1) });
let release!: () => void, dispose!: () => void, seen: unknown;
const gate = new Promise<void>(resolve => {
release = resolve;
});
const seed: { [key: number]: typeof original | undefined } = array
? [original]
: { 0: original };
const [state] = createRoot(d => {
dispose = d;
return (optimistic ? createOptimisticStore : createStore)(
async draft => {
seen = draft[0];
draft[0] = replacement;
await gate;
},
seed,
{ shallow: true, seedLoadingValue: loading }
);
});
try {
expect(seen).toBe(original);
if (loading) expect(state[0]).toBe(original);
else expect(() => state[0]).toThrow(NotReadyError);
release();
for (let i = 0; i < 30; i++) await Promise.resolve();
flush();
expect(state[0]).toBe(replacement);
} finally {
release();
dispose();
}
});
}
});
4 changes: 2 additions & 2 deletions packages/signals/tests/treeshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function bundleFixture(code: string): Promise<{
const chunk = result[0].output[0];
const retained = Object.entries(chunk.modules)
.filter(([, mod]) => mod.renderedLength > 0)
.map(([id]) => id.replace(SRC + "/", ""));
.map(([id]) => id.replace(SRC.replaceAll("\\", "/") + "/", ""));
// Vite lib-mode ES output is not truly minified; match the #2883 harness
// (esbuild minify + `_`-prefixed property mangling, as the dist build does).
const minified = await transformWithEsbuild(chunk.code, "out.js", {
Expand Down Expand Up @@ -414,7 +414,7 @@ describe("pay-for-use tree-shaking (#2883)", () => {
}
})) as Rollup.RollupOutput[];
const chunk = result[0].output[0];
const distRoot = dirname(DIST) + "/";
const distRoot = dirname(DIST).replaceAll("\\", "/") + "/";
return Object.entries(chunk.modules)
.filter(([, mod]) => mod.renderedLength > 0)
.map(([id]) => id.replace(distRoot, ""));
Expand Down
69 changes: 46 additions & 23 deletions packages/solid/src/client/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,12 @@ function isAsyncIterable(v: any): boolean {
return v != null && typeof v[Symbol.asyncIterator] === "function";
}

function createShadowDraft(realDraft: any) {
const shadow = JSON.parse(JSON.stringify(realDraft));
function createShadowDraft(realDraft: any, shallow?: boolean) {
const shadow = shallow
? Array.isArray(realDraft)
? realDraft.slice()
: { ...realDraft }
: JSON.parse(JSON.stringify(realDraft));
let useShadow = true;
return {
proxy: new Proxy(shadow, {
Expand Down Expand Up @@ -721,7 +725,7 @@ function hydrateStoreFromAsyncIterable(
// dependencies read before the first suspension are tracked. Writes go
// to a shadow of the draft and are discarded — the server iterator is
// authoritative and drives the real draft via the iterable below.
const { proxy } = createShadowDraft(draft);
const { proxy } = createShadowDraft(draft, options?.shallow);
subFetch(fn, proxy);
const process = (res: any) => {
if (res.done) {
Expand Down Expand Up @@ -1130,27 +1134,46 @@ function hydrateStoreLikeFn(
);
}
if (ssrSource === "hybrid") {
return withHydrationGate(hydrated =>
coreFn(
(draft: any) => {
const o = getOwner()!;
if (!hydrated()) {
if (sharedConfig.has!(o.id!))
return readHydratedValue(
sharedConfig.load!(o.id!),
() => subFetch(fn, draft),
options
);
return fn(draft);
}
const { proxy, activate } = createShadowDraft(draft);
const r = fn(proxy);
return isAsyncIterable(r) ? wrapFirstYield(r, activate) : r;
},
initialValue,
options
)
let live = false;
const id = peekNextChildId(getOwner()!);
const ready = sharedConfig.has!(id) ? sharedConfig.load!(id) : undefined;
const [hydrated, setHydrated] = coreSignal(false, { ownedWrite: true });
const result = coreFn(
(draft: any) => {
if (live) return fn(draft);
const o = getOwner()!;
if (!hydrated()) {
if (sharedConfig.has!(o.id!))
return readHydratedValue(sharedConfig.load!(o.id!), () => subFetch(fn, draft), options);
return fn(draft);
}
const { proxy, activate } = createShadowDraft(draft, options?.shallow);
const r = fn(proxy);
if (isAsyncIterable(r))
return wrapFirstYield(r, () => {
live = true;
activate();
});
live = true;
return r;
},
initialValue,
options
);
// A loading seed can hydrate before its first server answer arrives.
// Preserve that adoption AND the DOM claim before starting the live source.
onHydrationEnd(() => {
if (ready && typeof ready.then === "function")
ready.then(
() => setHydrated(true),
() => {
// Keep the server error until refresh, then allow a fresh client answer.
live = true;
}
);
else setHydrated(true);
});
return result;
}
const aiResult = hydrateStoreFromAsyncIterable(coreFn, fn, initialValue, options);
if (aiResult !== null) return aiResult;
Expand Down
40 changes: 29 additions & 11 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,8 @@ export type PatchOp =
export function createDeepProxy<T extends object>(
target: T,
patches: PatchOp[],
basePath: PropertyKey[] = []
basePath: PropertyKey[] = [],
shallow = false
): T {
const childProxies = new Map<PropertyKey, any>();

Expand Down Expand Up @@ -1169,7 +1170,7 @@ export function createDeepProxy<T extends object>(
}

const value = Reflect.get(obj, key, receiver);
if (value !== null && typeof value === "object" && typeof key !== "symbol") {
if (!shallow && value !== null && typeof value === "object" && typeof key !== "symbol") {
if (!childProxies.has(key)) {
childProxies.set(key, createDeepProxy(value, patches, [...basePath, key]));
}
Expand Down Expand Up @@ -2024,17 +2025,32 @@ export function getProjectionTrace(value: unknown): ProjectionTrace | undefined
return typeof value === "object" && value !== null ? projectionTraces.get(value) : undefined;
}

function cloneProjection<T extends object>(value: T, shallow?: boolean): T {
return (
shallow
? Array.isArray(value)
? value.slice()
: { ...value }
: JSON.parse(JSON.stringify(value))
) as T;
}

// Settles-once projections (promise-driven retry, thenable derives, hybrid
// iterables): the trace is one snapshot after settlement, then done — the
// border analogue of "reads pass through once markReady runs". A rejection
// propagates through the iterable so the consumer's read errors rather
// than hanging.
function registerSettledTrace(pending: object, ready: Promise<any>, state: object) {
function registerSettledTrace(
pending: object,
ready: Promise<any>,
state: object,
shallow?: boolean
) {
projectionTraces.set(pending, {
array: Array.isArray(state),
subscribe: async function* () {
await ready;
yield JSON.parse(JSON.stringify(state));
yield cloneProjection(state, shallow);
}
});
}
Expand Down Expand Up @@ -2121,7 +2137,9 @@ export function createProjection<T extends object = {}>(
const usesHybrid = (source: AsyncIterable<unknown>) =>
ssrSource === "hybrid" || !!(source as any)[LIVE_SOURCE];
const patches: PatchOp[] = [];
const draft = useProxy ? createDeepProxy(state as any, patches) : (state as any as T);
const draft = useProxy
? createDeepProxy(state as any, patches, [], options?.shallow)
: (state as any as T);
const takeFirst = (source: AsyncIterable<void | T>) =>
Promise.resolve().then(() => {
const iter = source[Symbol.asyncIterator]();
Expand Down Expand Up @@ -2149,7 +2167,7 @@ export function createProjection<T extends object = {}>(
// declared first paint (#2988 ruling; the client's shadow draft enforces
// the same line, and hydration claims against the plain seed).
const seedLoading = !!options?.seedLoadingValue;
const frozenSeed = seedLoading ? (JSON.parse(JSON.stringify(state)) as T) : undefined;
const frozenSeed = seedLoading ? cloneProjection(state, options?.shallow) : undefined;

const runProjection = () => {
resetOwnerForRerun(owner);
Expand Down Expand Up @@ -2178,7 +2196,7 @@ export function createProjection<T extends object = {}>(
markError,
() => disposed
);
registerSettledTrace(pending, deferred.promise, state);
registerSettledTrace(pending, deferred.promise, state, options?.shallow);
if (ctx?.async && !getContext(NoHydrateContext) && owner.id)
ctx.serialize(owner.id, deferred.promise, options?.deferStream);
return recordSlot(pending);
Expand Down Expand Up @@ -2211,7 +2229,7 @@ export function createProjection<T extends object = {}>(
markError,
() => disposed
);
registerSettledTrace(pending, deferred.promise, state);
registerSettledTrace(pending, deferred.promise, state, options?.shallow);
if (ctx?.async && !getContext(NoHydrateContext) && owner.id)
ctx.serialize(owner.id, deferred.promise, options?.deferStream);
return recordSlot(pending);
Expand Down Expand Up @@ -2257,7 +2275,7 @@ export function createProjection<T extends object = {}>(
// `state` (for draft/patch correctness) but reads go through the frozen
// copy. With seedLoadingValue the lock already sits at commit #0 — the
// seed — so V1 must NOT retarget it (undefined keeps the read target).
markReady(seedLoading ? undefined : (JSON.parse(JSON.stringify(state)) as T));
markReady(seedLoading ? undefined : cloneProjection(state, options?.shallow));
return undefined;
},
markError,
Expand Down Expand Up @@ -2313,7 +2331,7 @@ export function createProjection<T extends object = {}>(
let cursor = log.length;
consumers++;
try {
yield JSON.parse(JSON.stringify(state)) as T;
yield cloneProjection(state, options?.shallow);
while (true) {
if (cursor < log.length) {
yield log[cursor++];
Expand Down Expand Up @@ -2360,7 +2378,7 @@ export function createProjection<T extends object = {}>(
markError,
() => disposed
);
registerSettledTrace(pending, deferred.promise, state);
registerSettledTrace(pending, deferred.promise, state, options?.shallow);
if (ctx?.async && !getContext(NoHydrateContext) && owner.id)
ctx.serialize(owner.id, deferred.promise, options?.deferStream);
return recordSlot(pending);
Expand Down
Loading
Loading