fix(store): shallow store leaves stay raw in drafts, shadows and SSR snapshots (#3498) - #3533
Merged
Merged
Conversation
…snapshots (#3498) `{ shallow: true }` promises raw leaf references, but three projection paths treated a shallow store as deep: `wrapDraft` wrapped every nested value read through the derive's draft in another proxy (identity lost; an object-valued non-configurable property on a frozen leaf tripped a Proxy invariant), and the loading shadow, its commit copy, the SSR draft/snapshots and the hydration replay shadow were `JSON.parse(JSON.stringify(...))` copies of the whole tree (`Date` -> string, `NaN` -> null, `undefined` dropped, BigInt and cycles throw). The shallow flag now short-circuits the draft recursion (no indirect call; the nested recursion never happens for a shallow store, so nothing is passed down), `createDeepProxy` skips child proxies when shallow, and the copies go through `cloneState(v, shallow)`: the root container alone for a shallow store — its leaves are raw by contract and stay so — the tree otherwise. The deep path is unchanged in behavior. For shallow stores every touched path does strictly less work: no per-read proxy allocation in the draft, a root `slice()`/spread instead of a tree clone in loading windows and hydration. `cloneState` is mirrored in the solid server runtime rather than exported: it is an implementation detail with an ugly contract, and the server layer is by design a reimplementation that already carries its own copies of this kind of logic. The shallow half of #3499 (Leon Schiffler), reduced: the hybrid hydration resequencing in that PR makes new rules about handoff and stays there for a ruling. Tests are his, minus the hybrid describes; all 15 fail on unfixed `next`. Signals, solid, web (client/server/hydrate) green. Size: hydrating (no stores) 20,208 -> 20,260 B, hydrating + stores 30,303 -> 30,469 B, caps ratcheted with notes; signals+store 16.65 -> 16.72 kB under cap; CSR and the signal-only floor flat. Co-authored-by: Leon Schiffler <leon.schiffler@outlook.com> Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: e87d694 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This was referenced Sep 18, 2026
Coverage Report for CI Build 35319995646Coverage increased (+0.06%) to 71.366%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Comparing Footnotes
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The shallow half of #3498, extracted from #3499 (@Monkeylordz — the fix and the tests are yours, credited as co-author) and reduced to its minimum. The hybrid hydration resequencing in #3499 is deliberately not here; see the note at the end.
What was wrong
{ shallow: true }promises raw leaf references, but three projection paths treated a shallow store as deep:wrapDraftwrapped every nested value read through the derive's draft in another proxy — leaf identity lost, and an object-valued non-configurable property on a frozen leaf trips a Proxy invariant error.seedLoadingValue), its commit copy, the SSR draft/snapshots and the hydration replay shadow wereJSON.parse(JSON.stringify(...))of the whole tree:Date→ string,NaN→null,undefinedproperties dropped, BigInt and cycles throw.createDeepProxyproxied shallow leaves too.Fix (3 source files, ~40 lines net)
wrapDraft(..., shallow): a boolean that short-circuits the recursion. No indirect call; the nested recursion never happens for a shallow store, so nothing is passed down.cloneState(v, shallow): root container alone for a shallow store (slice()/ spread), the tree otherwise. Used at the two client sites and four server sites. Mirrored in the solid server runtime rather than exported — an implementation detail with an ugly contract, and that layer is by design a reimplementation that already carries its own twins of this kind of logic.createDeepProxy(..., shallow)skips child proxies;createShadowDraft(draft, shallow)copies the root.Deep stores are unchanged — this is a strict narrowing to make the projection paths obey what
shallowalready means for plain stores. It doesn't preclude replacing the shadow with staged writes or switching the deep copy to a value-preserving clone later; both would subsume this.Perf
For shallow stores every touched path does strictly less work: no per-read proxy allocation in the draft (previously one
Proxy+ traps object per nested access), and a rootslice()/spread instead of a tree clone in loading windows and hydration. Deep stores pay onetarget.sread per derive run. CodSpeed on #3499 was red onprojection derive: delete + set one ROOT key, but cross-CPU (Xeon baseline vs EPYC head); this PR measures on one environment.Verification
shallow-loading), 7 solid (shallow-hydrationreplay,server/shallow-projection). All 15 fail on unfixednext, all pass here.pnpm typesclean.Not included: hybrid hydration resequencing (#3499's second half)
Waiting for hydration end ∧ first server answer before takeover, committing later runs' first yields, and keeping a rejected server answer visible until
refreshare rules about what a hybrid store shows during handoff. That needs a ruling before it can be reviewed as a bug fix; #3499 stays open for it, and its tests for that path are the right pins if it's ratified. #3498 therefore stays open after this merges, with its shallow section resolved.Co-authored-by: Leon Schiffler
Co-authored-by: Claude via Cursor