Describe the bug
In a sync-only app, every update of a store-producing computed pays a walk over the store's entire materialized-signal set — cost is O(materialized signals) per store update per flush, regardless of what changed.
Mechanism (from @solidjs/signals rc.1 source):
- Every store signal node is created with
firewall = <the store's computed> and prepended to firewall._child — one node per (object, key) any reader ever touched in a tracked scope. A large keyed record projection therefore accumulates a child per leaf its subscribers read.
- After every update of that computed,
clearStatus() calls GlobalQueue._updateChildCompanions(el), which walks the entire _child chain checking child._pendingSignal || child._latestValueComputed — the isPending() / latest() verdict companions.
- In an app that never calls
isPending()/latest(), no companions exist: every check is false and the walk is pure overhead. The hook is installed unconditionally at module load, so the !== null guard never helps a bundled app.
Where we hit it: a flow-graph library with 1,600 nodes / 1,599 edges as keyed record projections, each row read by its own wrapper component (~10^5 materialized leaves across two records). Dragging ONE node — one memo re-run, five DOM attribute writes — cost 17.6 ms per mousemove (prod build, visible tab). The JS Self-Profiling API attributed ~60% of flush samples to updateChildCompanions at every scale we measured, while run counters confirmed our derives did O(changed) work. Restructuring so leaf signals hang off per-row computeds (~30 children each) dropped the same drag to 1.37 ms, confirming the attribution.
Standalone measurement (script in the repo below): one keyed record projection, 1,600 rows, per-row memos so the derive is O(changed). The workload is identical in every scenario — 60 flushes, each writing one leaf of one source row; only the number of leaves readers touch differs:
| readers |
ms/flush (prod) |
ms/flush (dev) |
| none (~0 signals/row) |
0.28 |
0.36 |
| narrow (~4 signals/row) |
0.77 |
0.64 |
| wide + deep (~20 signals/row) |
1.50 |
1.60 |
5.4× for the same data change, growing with reader width; nested reads amplify it because each intermediate object materializes its own node.
Suggested fix: maintain a companion count (or has-companions flag) on the firewall, updated at companion creation/disposal, and skip the walk when zero. Sync-only apps then pay nothing; async apps walk only where companions actually exist.
Your Example Website or App
https://github.com/dsnchz/solid-flow/tree/repro/solid-rc1-issues (script: repro/companion-walk.mjs, writeup: repro/ISSUE-1-companion-walk.md)
Steps to Reproduce the Bug or Issue
git clone https://github.com/dsnchz/solid-flow && cd solid-flow
git checkout repro/solid-rc1-issues
bun install
node --conditions=browser repro/companion-walk.mjs
(The script itself only depends on solid-js@2.0.0-rc.1 — it runs the same from any directory with that package installed.) To see it at app scale: bun run dev, open http://localhost:3000/?example=StressTest&x=40&y=40, drag a node while profiling — updateChildCompanions dominates the flush.
Context pack for automated analysis
AGENTS.md at the root of the repro branch is a self-contained context pack: commands with expected outputs and the source coordinates inside @solidjs/signals dist/dev.js (searchable internal names: updateChildCompanions, clearStatus, signal(v, options, firewall) prepending to firewall._child, GlobalQueue._updateChildCompanions installed unconditionally at module load), plus the app-scale profiling evidence and the sub-store restructuring that confirmed attribution (17.6 ms → 1.37 ms per drag move with identical DOM output).
Expected behavior
Flush cost for a single-leaf write should scale with what changed, not with how many leaves readers have ever touched — or at minimum the companion walk should be skipped when no isPending()/latest() companions exist below the computed.
Screenshots or Videos
No response
Platform
- OS: macOS 15 (Darwin 24.6.0)
- Browser: Chrome (numbers above from node; browser profiling via JS Self-Profiling API)
- Version: solid-js 2.0.0-rc.1 / @solidjs/signals 2.0.0-rc.1
Additional context
Found migrating @dschz/solid-flow (a SolidJS port of React Flow / Svelte Flow) to 2.0. We've since restructured into per-row sub-store projections and reached parity with our Solid 1.x version, so this doesn't block us — filing because the cost shape (per-update, proportional to total materialized signals) will hit any sync-only app with one large store and many readers, and it's invisible without a profiler.
Describe the bug
In a sync-only app, every update of a store-producing computed pays a walk over the store's entire materialized-signal set — cost is O(materialized signals) per store update per flush, regardless of what changed.
Mechanism (from @solidjs/signals rc.1 source):
firewall = <the store's computed>and prepended tofirewall._child— one node per(object, key)any reader ever touched in a tracked scope. A large keyed record projection therefore accumulates a child per leaf its subscribers read.clearStatus()callsGlobalQueue._updateChildCompanions(el), which walks the entire_childchain checkingchild._pendingSignal || child._latestValueComputed— theisPending()/latest()verdict companions.isPending()/latest(), no companions exist: every check is false and the walk is pure overhead. The hook is installed unconditionally at module load, so the!== nullguard never helps a bundled app.Where we hit it: a flow-graph library with 1,600 nodes / 1,599 edges as keyed record projections, each row read by its own wrapper component (~10^5 materialized leaves across two records). Dragging ONE node — one memo re-run, five DOM attribute writes — cost 17.6 ms per mousemove (prod build, visible tab). The JS Self-Profiling API attributed ~60% of flush samples to
updateChildCompanionsat every scale we measured, while run counters confirmed our derives did O(changed) work. Restructuring so leaf signals hang off per-row computeds (~30 children each) dropped the same drag to 1.37 ms, confirming the attribution.Standalone measurement (script in the repo below): one keyed record projection, 1,600 rows, per-row memos so the derive is O(changed). The workload is identical in every scenario — 60 flushes, each writing one leaf of one source row; only the number of leaves readers touch differs:
5.4× for the same data change, growing with reader width; nested reads amplify it because each intermediate object materializes its own node.
Suggested fix: maintain a companion count (or has-companions flag) on the firewall, updated at companion creation/disposal, and skip the walk when zero. Sync-only apps then pay nothing; async apps walk only where companions actually exist.
Your Example Website or App
https://github.com/dsnchz/solid-flow/tree/repro/solid-rc1-issues (script:
repro/companion-walk.mjs, writeup:repro/ISSUE-1-companion-walk.md)Steps to Reproduce the Bug or Issue
(The script itself only depends on
solid-js@2.0.0-rc.1— it runs the same from any directory with that package installed.) To see it at app scale:bun run dev, openhttp://localhost:3000/?example=StressTest&x=40&y=40, drag a node while profiling —updateChildCompanionsdominates the flush.Context pack for automated analysis
AGENTS.mdat the root of the repro branch is a self-contained context pack: commands with expected outputs and the source coordinates inside@solidjs/signalsdist/dev.js(searchable internal names:updateChildCompanions,clearStatus,signal(v, options, firewall)prepending tofirewall._child,GlobalQueue._updateChildCompanionsinstalled unconditionally at module load), plus the app-scale profiling evidence and the sub-store restructuring that confirmed attribution (17.6 ms → 1.37 ms per drag move with identical DOM output).Expected behavior
Flush cost for a single-leaf write should scale with what changed, not with how many leaves readers have ever touched — or at minimum the companion walk should be skipped when no
isPending()/latest()companions exist below the computed.Screenshots or Videos
No response
Platform
Additional context
Found migrating
@dschz/solid-flow(a SolidJS port of React Flow / Svelte Flow) to 2.0. We've since restructured into per-row sub-store projections and reached parity with our Solid 1.x version, so this doesn't block us — filing because the cost shape (per-update, proportional to total materialized signals) will hit any sync-only app with one large store and many readers, and it's invisible without a profiler.