Skip to content

fix(store): shallow store leaves stay raw in drafts, shadows and SSR snapshots (#3498) - #3533

Merged
ryansolid merged 1 commit into
nextfrom
fix/shallow-store-leaves
Sep 18, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/shallow-store-leaves

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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:

  • Draft. wrapDraft wrapped 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.
  • Copies. The loading shadow (seedLoadingValue), its commit copy, the SSR draft/snapshots and the hydration replay shadow were JSON.parse(JSON.stringify(...)) of the whole tree: Date → string, NaNnull, undefined properties dropped, BigInt and cycles throw.
  • SSR draft. createDeepProxy proxied 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 shallow already 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 root slice()/spread instead of a tree clone in loading windows and hydration. Deep stores pay one target.s read per derive run. CodSpeed on #3499 was red on projection derive: delete + set one ROOT key, but cross-CPU (Xeon baseline vs EPYC head); this PR measures on one environment.

Verification

  • 15 tests (his, minus the hybrid describes): 8 signals (shallow-loading), 7 solid (shallow-hydration replay, server/shallow-projection). All 15 fail on unfixed next, all pass here.
  • Full suites: signals 3461, solid 633, web client 833 / server 1005 / hydrate 190. pnpm types clean.
  • Size (brotli): hydrating (no stores) 20,208 → 20,260 B; hydrating + stores 30,303 → 30,469 B — caps ratcheted to 20.3 / 30.5 KB with notes. signals+store 16.65 → 16.72 kB under its 16.75 cap. CSR, CSR-observe and the signal-only floor flat.

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 refresh are 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

…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-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e87d694

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch

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

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35319995646

Coverage increased (+0.06%) to 71.366%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 3 of 3 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1035
Covered Lines: 783
Line Coverage: 75.65%
Relevant Branches: 802
Covered Branches: 528
Branch Coverage: 65.84%
Branches in Coverage %: Yes
Coverage Strength: 15.53 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 18, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/shallow-store-leaves (e87d694) with next (e171fc2)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid
ryansolid merged commit e0e91cc into next Sep 18, 2026
7 checks passed
@ryansolid
ryansolid deleted the fix/shallow-store-leaves branch September 18, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants