Skip to content

fix(signals): a projection's leaf companions die with the projection; latest() of a dead leaf creates none (spec O5) - #3505

Merged
ryansolid merged 1 commit into
nextfrom
fix/inv4-projection-dispose
Sep 17, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/inv4-projection-dispose

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Closes spec O5 (the INV-4 surfaced by #3495).

Bug

Disposing a store whose async source was mid-refetch left the latest() shadow of a leaf orphaned. The shadow had never derived a value — its compute reads through the projection in flight (NotReady), so the backfilled override stood in — and the settle after disposal dropped the override, leaving it NotReady/uninitialized forever against a leaf whose committed value differed. The __TEST__ quiescence invariant INV-4 fired on the next flush. Externally coherent (latest() fell back to the committed value), but a companion outliving its source is exactly what INV-9's rationale forbids.

Why it was invisible until now: the posture matrix had been leaking parked transactions (the bug #3495 fixed), which kept transitions.size > 0 and silenced every quiescence check for the rest of each run.

Fix — two parts, found in two rounds

  1. Teardown retires leaf companions. The disposal snap covered the disposed computed's own companions only; a projection's leaves hang off the firewall, not the child chain. disposeChildren(self) now snaps the companions of the firewall's _companionChildren (the set markFirewallChildCompanions already maintains, 2.0.0-rc.1 | flush cost scales with total materialized store signals (async-companion walk in sync-only apps) #3038), and snapCompanionsToState disposes a shadow whose owner's firewall is disposed — a disposed shadow reads as absent, so a later read recreates it from the committed view. The isPending signal snaps as the owner's own does. owner.ts gets one line; the logic lives in verdict.ts, outside the core floor.
  2. latest() of a dead leaf creates nothing. With (1) alone the standalone pin passed but the matrix still tripped: tagging shadows by creating cell showed the orphan was a second shadow, created by latest() inside boundary content re-running after the projection's teardown — nothing would ever retire it. latestRead now serves the committed value for a leaf whose firewall is disposed (a read of a disposed node freezes at its last commit, 2.0.0-rc.1 | signal source function revaluates when derived from a component prop - on component cleanup #3024).

Gate

… latest() of a dead leaf creates none (spec O5, INV-4)

Disposing a store whose async source was mid-refetch left the latest()
shadow of a leaf orphaned: never derived (its compute read through the
projection in flight, the backfilled override stood in), its override
dropped at the settle, NotReady/uninitialized forever against a leaf whose
committed value differed — INV-4 at the next quiescence. Externally
coherent (latest() fell back to the committed value), but a companion
outliving its source, which is what INV-9's rationale forbids. The
disposal snap covered the disposed computed's own companions only; a
projection's leaves hang off the firewall, not the child chain.

Two parts:
- disposeChildren(self) snaps the companions of the firewall's
  _companionChildren (the set markFirewallChildCompanions maintains,
  #3038); snapCompanionsToState retires a shadow whose owner's firewall is
  disposed — a disposed shadow reads as absent, a later read recreates it
  from the committed view. The isPending signal snaps as the owner's does.
- latestRead of a leaf whose firewall is disposed serves the committed
  value and creates no shadow: a boundary's content re-running after the
  teardown recreated one that no teardown would ever retire. That
  recreated shadow was the posture matrix's actual trigger.

Surfaced once #3495 stopped leaking parked transactions, which had kept
`transitions.size > 0` and masked every __TEST__ quiescence check in the
matrix. All 621 matrix cells run with zero invariant violations; the two
excluded cells are back. Pin flipped from it.fails and extended with the
post-disposal read. +104 B minified (owner.ts, core floor); five brotli
caps +13…+43 B.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c827758

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
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal 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 35171726315

Coverage remained the same at 71.46%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1018
Covered Lines: 772
Line Coverage: 75.83%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.93 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 175 untouched benchmarks


Comparing fix/inv4-projection-dispose (c827758) with next (191a572)

Open in CodSpeed

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