Skip to content

fix(signals): preserve transactions reentered during finalization - #3319

Closed
DerpyCrabs wants to merge 1 commit into
solidjs:nextfrom
DerpyCrabs:fix/preserve-reentered-transitions
Closed

fix(signals): preserve transactions reentered during finalization#3319
DerpyCrabs wants to merge 1 commit into
solidjs:nextfrom
DerpyCrabs:fix/preserve-reentered-transitions

Conversation

@DerpyCrabs

Copy link
Copy Markdown

An ambient flush can re-enter a pending transaction while finalizing, then continue committing state and applying effects as though it still owns the original batch.

We hit this in an application when an SSE reconnect triggered parallel query refetches. One refetch updated a settings projection read through snapshot(deep(settings.pins)), while another refetch was still pending. An unrelated signal update triggered a flush; committing the settings store notified its deep reader and re-entered the pending transaction. Once both refetches completed, the network data was current but the pins UI remained stale.

finalizePureQueue() can adopt another transaction through store commit hooks, boundary checks, or recomputations. Two parts of finalization currently assume that has not happened:

  • The remaining commit/optimistic-reversion work can operate on the newly adopted transaction.
  • The caller can proceed to effect application without returning through transition completion and parking logic.

This patch captures the batch identity before the first commitPendingNodes() and skips the remaining commit/reversion work if that identity changes. It also returns through the transition gate before applying effects when finalization leaves an active transition.

The regressions cover both entry points:

  • A boundary check re-enters a suspended action; its writes and effects must remain held until the action completes.
  • A public API reproduction using two async projections and a deep reader; the parked render must be released when the final refetch settles.

Both tests fail on current next and pass with this patch. The full repository tests and type checks, compiler suite, package integration checks, and TanStack Solid Query suite also pass.

@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 790f180

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

ryansolid added a commit that referenced this pull request Sep 9, 2026
…cts follow ownership (#3319)

An ambient flush can enter a held transaction while finalizing (store
commit hook, boundary check, or a stamped recompute) and then kept
committing state and applying effects as though it still owned the
batch, leaving the UI stale once the transaction settled.

finalizePureQueue captures the batch it started with and settles nothing
an entered transaction adopted; a completing transaction whose ambient
batch was separate still settles its own containers. Effects follow the
#3322 owner stamp: in a flush whose finalize entered a transaction,
runEffect leaves runs owned by a still-held transaction queued for the
next gate to park, and applies everything computed mainline — the write
that caused the flush reads and renders together. Lanes are exempt by
construction (they never enter the ordinary queue).

The finalize guard and the two regression tests come from PR #3319 by
DerpyCrabs; the whole-flush park it proposed is replaced with per-effect
ownership, which the two added tests distinguish.

Closes #3319

Co-authored-by: DerpyCrabs <derpycrabs@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Landed on next in 1c9e9e74 with you as co-author — thank you for the precise diagnosis and especially the public-API reproduction (two projections + deep() reader), which is exactly the shape that hits real apps.

What landed vs. the PR:

  • Finalize guard — taken as-is in mechanism, with one refinement: when completingTransition is non-null but the ambient batch was a separate object (the 2.0.0-beta.20: post-action completion race permanently freezes fresh signals #2916 shape), adoption never touched the completing transaction's own containers, so it still settles them (optimistic reversion, affects release) instead of returning early.
  • Flush guard — replaced with per-effect ownership. Parking the whole flush also parks the effects of the write that caused the flush, which was computed mainline and already committed. In your second scenario that means tick() reads 1 while its own render stays at 0 until the unrelated transaction settles — a read/DOM split. The landed version stamps each effect's value with the transaction that computed it (Effect._valueTransition, added for 2.0.0-rc.7 render effect depending on non async values from two concurrent updates commit with the wrong data #3322 in the commit just before) and, in a flush whose finalize entered a transaction, runEffect leaves runs owned by the still-held transaction queued for the next gate to park while applying everything computed mainline. Optimistic lanes are exempt by construction — they apply their own effects ahead of their transaction on purpose and never enter the ordinary queue.

Both of your tests are in finalize-reentry.test.ts unchanged, plus two that distinguish the two approaches (they fail under the whole-flush park). Cost is ~+166 B minified in the core vs. ~+70 B for the coarse guard; we took the precision.

Closing this in favour of the landed commit. It will ship in rc.8.

Claude via Cursor

@ryansolid ryansolid closed this Sep 9, 2026
ryansolid added a commit that referenced this pull request Sep 9, 2026
…core floor)

runEffect reads activeTransition directly — the #3319 flag was set from the
same test at the same point in flush(). The lane exemption moves to where
lanes live: optimistic.ts ORs LANE_RUN into the run type, as does effect()'s
creation-time immediate run. contestEffect (#3322) inlined into its single
call site in recompute. Behavior-identical at every runEffect entry point.

Core floor 21,931 -> 21,890 minified; gzip -38 B core, -48 B isPending/latest.

Co-authored-by: Claude via Cursor <noreply@cursor.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