Skip to content

2.0.0-rc.1 | flush cost scales with total materialized store signals (async-companion walk in sync-only apps) #3038

Description

@thedanchez

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions