Skip to content

feat(signals): report what each drain showed, and slim the observe hooks - #4725

Open
devagrawal09 wants to merge 11 commits into
solidjs:nextfrom
devagrawal09:tembo/observe-commit
Open

devagrawal09 wants to merge 11 commits into
solidjs:nextfrom
devagrawal09:tembo/observe-commit

Conversation

@devagrawal09

@devagrawal09 devagrawal09 commented Nov 8, 2026 •

Copy link
Copy Markdown

Summary

Today the core reports what ran and what was written to the attribution engine, but never what became visible. So the engine reconstructs visibility itself: it keeps a copy of the scheduler's staging state for fallbacks, snapshots nodes to tell whether async landings committed, and core computes diagnostic-only facts (the store's immutable-update census) on the engine's behalf.

This PR has core report one more fact, where a value was staged (staged, from queuePendingNode and list). The engine compares what each staged node showed before and after each drain. On top of that comparison:

  1. New: FlushEvent.changes lists what each drain changed on screen: one entry per signal, memo or store property whose shown value differs from before the drain. That covers commits, held transitions' landings, and optimistic values appearing or lifting. Each entry carries nodeId, nodeName, key for store properties, and origin. The origin is the action step, effect, interaction or navigation that staged the value, so the drain that lands an action names that action. Nodes and values travel beside the record (FlushLive), and values only for listeners that asked for { bodies: true }. Motivation: exact, action-attributed steps for time travel and state-history devtools (solid-kit).
  2. OPTIMISTIC_REVERTED and the fallback record's display instant are judged from the same comparison.

With that in place, the hooks shrink to what core knows:

Before After
optimisticReverted(el, shown, truth, how) gone (derived from the comparison)
recomputeEnd(el, create, changed, optimistic, transition, held) + derivedChanged(el) recomputeEnd(el, create, changed, phase, held)
asyncStart(el) + asyncEnd(el, prev, value, direct); the engine snapshots _value/_time/change seq; setSignal landings stamped twice asyncLanded(el, value, changed), reported once
storeReplaced(path, isArray, total, unchanged, prevTotal, owner); census computed in store.ts storeWrite(target, old, written, keys); census in the engine, through the StoreTools the store registers at its init (the engine never imports the store)
boundaryFallback(…, transition); engine copy stagedFallbacks, rebased on settle/merge; core schedule()s a drain for the engine boundaryFallback(…, output); displayed when the comparison sees the output change
DEV.hooks.onUpdate, DEV.hooks.onStoreNodeUpdate removed: the flush record covers both (observe/dev, attribution.enable())
— staged(el)

Size

The size gate (scripts/size, CI's check job), head against this PR's base (next @ 701cbb36), minified / brotli bytes:

scenario base head
app: CSR, observe tier 42,468 / 15,453 42,468 / 15,453 (unchanged)
app: CSR, observe tier + attribution engine enabled 88,797 / 30,078 88,576 / 29,953 (−221 / −125)
prod (dist/prod) no code change (comment-only diffs in four files; drops a dead DEV import)
hook combinations¹ 53 33
source code lines core −44 net (store.ts −75, lanes.ts −13, boundaries.ts −10; core.ts +31, attribution-hooks.ts +11 for StoreTools), engine +175

¹ For each hook, the product of the distinct values of every input a consumer branches on (booleans 2, literal unions their size, meaningful optional inputs +1), summed over AttributionHooks + DEV.hooks.

An earlier revision had the engine import store/store.ts for the census, which pulled the store into the engine scenario for an app without stores (+19.5 kB minified) and failed the gate. The store now registers StoreTools at its init and the engine reads through them, so nothing under core/ imports store/.

For review

  • Removing DEV.hooks.onUpdate and onStoreNodeUpdate breaks existing users (devtools). They're independent of the rest and can stay if you'd rather. Store properties nobody reads have no node, so they're not in flush.changes.
  • Semantics: an optimistic guess shown, lifted and re-shown within one drain is not a change. reverted vs superseded is decided by identity against the covered value. A fallback mounted outside any drain is displayed as the mount returns (before: at the drain core scheduled for it).
  • Not in this PR: the engine's navigation/interaction "unheld" settling at flushEnd could follow the same route, but it would change what "settled" means, so I left it for a separate discussion.

How did you test this change?

  • New packages/signals/tests/attribution-flush-changes.test.ts (7 tests): changed signals and memos are listed with their origin; one record per drain; values only with bodies, beside the record; store keys; a held write listed at its landing, naming its action; an optimistic value listed when it appears and when it lifts; effects and first values not listed.
  • Existing suites pass unchanged on the new paths: OPTIMISTIC_REVERTED (8), the fallback and timeline records (attribution-timeline, loading-fallback-in-flush-3540 and the other fallback suites), immutable updates, holds, interactions.
  • Removed the onUpdate/onStoreNodeUpdate tests (signals dev.test.ts, solid dev.spec.ts). The DEV.setSignal store test now asserts the reader re-runs.
  • pnpm turbo run test test-types typecheck across packages: signals 6,749, solid-js 927, diagnostics 37, element 10, html 209, h 72, universal 57, babel-plugin 422; web client 1,747 and hydrate 758 (run directly). Web server suite: one failure, server-functions-adapter-request.spec.tsx › "keeps a rebuild failure's own error…", which fails the same way on next without this change.
  • Review follow-ups: landThroughSetSignal saves and restores its re-entrancy state instead of resetting it; trackStaged walks for an origin only once a node is watched (it runs on every write while the engine is enabled); the docs say flush.changes compares by identity and that a fallback rendering the value already shown is no record.
  • Size gate passes on CI (table above). CodSpeed flags four store benchmarks; they run the prod tier, which this PR leaves comment-identical to next (built both and diffed), so that is instrument noise.
  • pnpm build (17/17 packages); dist-artifacts pass; dist/prod diffed against next (no code change); generate-diagnostics-json --check up to date; prettier clean. Changeset: .changeset/flush-record-changes.md.

devagrawal09 and others added 7 commits November 7, 2026 08:08
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 09462b3

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

This PR includes changesets to release 15 packages
Name Type
@solidjs/signals Patch
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler 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

codspeed Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.71%

1 warning

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 1 improved benchmark
❌ 3 regressed benchmarks
✅ 197 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ projection derive: write one NESTED field (reference) 2.1 ms 2.4 ms -12.32%
❌ dbmon shallow full tick 34.3 ms 36.9 ms -6.93%
❌ fresh stores: create + first write + first commit (reporter shape) 14.7 ms 15.6 ms -5.97%
⚡ arrow in template 12.7 ms 10.9 ms +16.78%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing devagrawal09:tembo/observe-commit (09462b3) with next (701cbb3)

Open in CodSpeed

Footnotes

  1. 5 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. ↩

devagrawal09 and others added 3 commits November 8, 2026 15:01
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
…ched

Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.com>
Co-authored-by: tembo[bot] <208362400+tembo-io[bot]@users.noreply.github.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.

1 participant