Skip to content

fix(store): reconcile() notifies symbol-keyed properties (closes #2851) - #2852

Closed
yumemi-thomas wants to merge 2 commits into
solidjs:nextfrom
yumemi-thomas:fix/reconcile-symbol-keys
Closed

fix(store): reconcile() notifies symbol-keyed properties (closes #2851)#2852
yumemi-thomas wants to merge 2 commits into
solidjs:nextfrom
yumemi-thomas:fix/reconcile-symbol-keys

Conversation

@yumemi-thomas

Copy link
Copy Markdown

Closes #2851

Summary

reconcile() never notifies subscribers of symbol-keyed store properties. An effect or binding tracking state[SYM] doesn't re-run when reconcile() changes that property, and because the stale node then shadows the value, even a plain state[SYM] read keeps returning the old value — the reconciled value is unreachable. String-keyed properties on the same store update fine through the identical path, so the only difference is the key type.

https://stackblitz.com/edit/solidjs-templates-krnhumcg?file=src%2FApp.tsx

This is the remaining variant of #2769: that fix (bc92d002) covered symbol keys in the set trap, storeSetter, storePath, and merge/omit via the ownEnumerableKeys helper — but reconcile()'s own diff loops still enumerated with Object.keys, which drops symbols.

Root cause

Every key enumeration in reconcile's diff dropped symbols:

  • getAllKeys (the $TRACK-tracked path) built from getKeys (string-only) + Object.keys(next).
  • The untracked value loops and the STORE_HAS (in-dependency) loops iterated Object.keys(nodes).

So setSignal was never called for symbol-keyed nodes, while applyState* had already swapped STORE_VALUE to the new value — leaving the stale node shadowing it for direct reads too.

Fix

Enumerate symbol keys everywhere reconcile enumerates string keys, while keeping the common symbol-free path on the exact Object.keys fast path:

  • getAllKeys builds string keys via Object.keys (unchanged) and appends enumerable symbol keys via getOwnPropertySymbols — symbol-free objects pay only an empty call, no per-key predicate.
  • nodeKeys (new) enumerates a node record's keys: Object.keys always, plus symbol nodes only for records flagged in a symbolKeyedRecords WeakSet. getNode marks a record when it creates a user (non-$TRACK) symbol node, and its unobserved cleanup drops the mark once the last such node goes — so the flag means exactly "this record currently holds a user symbol node", and the reconcile hot path stays on Object.keys gated by a free WeakSet.has.
  • $TRACK (the internal self-node, the only internal symbol a node record can hold — the get/has traps early-return the others) is filtered out of symbol enumeration; callers handle it separately.

Semantics are identical to the old code for string keys, and match ownEnumerableKeys (enumerable strings + enumerable symbols) for the extended set; key order is irrelevant since each key is diffed independently.

Tests

Seven tests in tests/store/reconcile.test.ts (reconcile with symbol-keyed properties), the symbol cases confirmed failing red-first on the unfixed source:

  • effect tracking a symbol-keyed property is notified; the value is reachable, not shadowed
  • string control updates through the identical path
  • string and symbol keys on one store both update
  • a symbol key removed by reconcile notifies undefined
  • a symbol in check updates when reconcile adds the key
  • a nested symbol-keyed value reconciles
  • perf invariant: the symbolKeyedRecords mark is set while tracked and cleared once unobserved (guards the fast-path optimization against a monotonic leak)

Full solid-signals suite: the diff versus pristine is exactly the intended flips, nothing else; solid and @solidjs/web rebuilt against the fixed package and re-run at their baselines; source and test files pass the tests-inclusive typecheck.

Solid 1.x note

Exists in 1.x too, in a worse form: 1.x reconcile doesn't even merge symbol-keyed values (the value stays stale; no notification question arises). 2.0 half-fixed this — the value merges — but subscribers of the symbol-keyed node are still never notified. Not a regression, but 2.0 already handles symbols in its other store paths (set trap, getAllKeys, merge/omit after #2769), so finishing the job in reconcile's diff loops is consistent.

Full disclosure: I wrote this with the help of an AI assistant (Claude Fable 5) and reviewed every line before pushing. All new tests were written first and confirmed failing on the unfixed code

@changeset-bot

changeset-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 023a932

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

This PR includes changesets to release 9 packages
Name Type
@solidjs/signals Patch
test-integration Patch
solid-js Patch
babel-preset-solid Patch
@solidjs/web Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/universal Patch
@solidjs/element 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

@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 7.61%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 119 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
reconcile: deep tree, single deep() effect 80.1 ms 74.4 ms +7.61%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing yumemi-thomas:fix/reconcile-symbol-keys (023a932) with next (070d853)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member

Right diagnosis and the right performance-conscious shape — the symbolKeyedRecords WeakSet mark maintained at node creation/cleanup is exactly how to keep the symbol-free hot path on Object.keys without a per-key predicate, and filtering $TRACK (the only internal symbol a node record can hold) at the enumeration site is correct.

Your branch predates the #2756 getAllKeys rewrite (the identical-key-sets fast path landed there), so the diff no longer applied — we re-implemented it against the current tree preserving your design: symbol-free diffs stay byte-for-byte on the old path including the fast path, the symbol-aware branch re-applies override $DELETED after base symbols so deletes win, and we added one guard you couldn't have known to need — store-in-store values enumerate their symbols untracked, mirroring getKeys. All seven of your tests are included verbatim (symbol cases re-verified red-first), including the perf-invariant pin on the WeakSet mark lifecycle. Landed as b9f27372 with co-author credit. Ships in the next beta — thanks!

@ryansolid ryansolid closed this Jul 8, 2026
ryansolid added a commit that referenced this pull request Jul 8, 2026
…2852)

The remaining #2769 variant: reconcile's diff loops enumerated with
Object.keys, dropping symbol keys — setSignal never ran for symbol-keyed
nodes while applyState* had already swapped STORE_VALUE, so the stale node
shadowed the new value even for untracked reads. Symbol keys now diff like
string keys: getAllKeys appends enumerable symbols (override $DELETED
re-applied after base symbols so deletes win), and the node-record loops
enumerate symbols only for records holding a user symbol node, tracked via
a symbolKeyedRecords WeakSet marked in getNode and dropped in unobserved.
Symbol-free diffs stay byte-for-byte on the Object.keys fast path,
including the #2756 identical-key-sets shortcut.

Re-implemented from PR #2852 against the current tree (the diff predates
the #2756 getAllKeys fast path); added the store-in-store untracked symbol
enumeration guard mirroring getKeys. All 7 PR tests included, symbol cases
verified red-first on the unfixed source.

Co-authored-by: yumemi-thomas <yumemi-thomas@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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