Skip to content

fix(signals): restore deferred releases after cleanup throws - #4176

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
GabbeV:fix/restore-deferred-releases
Oct 21, 2026
Merged

ryansolid merged 1 commit into
solidjs:nextfrom
GabbeV:fix/restore-deferred-releases

Conversation

@GabbeV

@GabbeV GabbeV commented Oct 20, 2026

Copy link
Copy Markdown

Summary

A throwing reactive onCleanup during an unfinished memo's rerun leaves the module-global deferredReleases collector installed. After the caller catches the error, disposing an unrelated lazy memo's last observer appends it to the abandoned collector instead of releasing it, so its cleanup does not run.

This regresses behavior before #4164: the public-signals repro runs the unrelated cleanup once on its direct parent, a9b9f9e4, but zero times on the tested base, a2743fa2. The cleanup error escapes in both versions.

Restore the previous collector in finally. This restores the earlier release behavior while preserving the original exception, interrupted teardown, and successful-pass deferral/draining behavior. Adds throwing/non-throwing public-API regressions and a patch changeset.

Minimal JSX playground: click Run to see Cleanup count: 0; replacing the throw with return and rebuilding gives 1. The hosted package build is mizulu/solid2@5a4b2e85; the same component separately reproduces against unchanged upstream source at a2743fa2.

How did you test this change?

  • The new throwing case fails before the fix (cleaned is 0 instead of 1); the non-throwing control passes. Both pass after the fix and check that the original cleanup error still escapes. Existing rebuilt-reader/lazy-release regressions also pass.
  • Signals JS build and source declaration check pass.
  • Full signals suite: 327 files, 5,411 tests passed, three expected failures and two skips. The unchanged base passes 326 files / 5,409 tests.
  • Prettier checks on all three files and node scripts/check-release-invariants.mjs pass.
  • Rolldown 1.2.11: +13 minified bytes in each of the four signals scenarios. All comparison gates pass; createStore has a brotli warning within the existing minified allowance. No caps changed.
  • Focused local production timings on Node 24.19.0: five warm-up batches per version and 11 alternating measured rounds for chains, diamonds and pending-frame rebuilds. Patched medians were within 3% of the base, with no apparent slowdown; these noisy microbenchmarks are not an application-performance guarantee.

@changeset-bot

changeset-bot Bot commented Oct 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 035e9b2

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

This PR includes changesets to release 14 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
board-example Patch
server-dbmon-example Patch
todos-server-example 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 Oct 20, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ 4 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

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

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ ⚠️ build + consume 693.9 µs 776.7 µs -10.67%
⚡ memo + sync render effect only (reference) 33.6 ms 28.7 ms +17.19%

Tip

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


Comparing GabbeV:fix/restore-deferred-releases (035e9b2) with next (b0ad1df)

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

@ryansolid
ryansolid merged commit b2d9ed0 into solidjs:next Oct 21, 2026
7 of 8 checks passed
ryansolid pushed a commit that referenced this pull request Oct 21, 2026


Each passed alone; together they put next 1 B past the 20 B minified
allowance (81,159 B against 81,138 recorded). Accepted by the maintainer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
ryansolid added a commit that referenced this pull request Oct 21, 2026
* fix(signals): release lazy nodes collected before a throwing cleanup

#4176 restores the release collector when a cleanup throws while an
unfinished pass's frame is dismantled, but the throw also skips the drain
at the end of the pass, so lazy nodes the frame's children released before
the throw were never released. Drain them before rethrowing.

Also adds the solid-js changeset for #4183's docs, which ship in the package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1

* revert the lazy-release drain; keep only the #4183 changeset

The drain costs about 35 B minified in every signals scenario, including
the simple-app floor, for a leak that only follows a cleanup that throws.
Held for a size decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1

* chore(size): re-record the compiled CSR cap after #4188, #4207 and #4176

Each passed alone; together they put next 1 B past the 20 B minified
allowance (81,159 B against 81,138 recorded). Accepted by the maintainer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 22, 2026
)

* fix: a throwing cleanup no longer aborts the rest of the teardown

Every cleanup in the tree runs even when one throws: the ones registered
before it on the same owner, unvisited siblings, ancestors and effect
cleanups. The first error is rethrown once the teardown is over.

Client (@solidjs/signals): one guarded call (`runCleanup`) covers both the
`_disposal` list and the effect `_cleanup`, collecting the first error.
Outside a flush the walk that began the teardown rethrows it; inside a flush
(a commit retiring a frame, a pass replacing an uncommitted one, an effect
disposing a root) `flush()` rethrows it after the drain completes, so the
commit loop and the effect queues are never left half-applied and the pass
that tears down a frame still drains the lazy releases it collected
(#4176's follow-up case now passes without a dedicated drain).

Server (solid-js): `disposeOwner` guards each child walk and each cleanup
and rethrows the first error after the walk, so renderToStream's final
dispose runs every cleanup and #4206 still reports the error (#4193).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore(size): re-record caps for #4213 from CI

Every scenario the change grows, at CI's measured sizes. Hello world
(app: compiled floor) is 9,928 B brotli, under 10 KB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* perf(signals): keep the cleanup rethrow off disposeChildren's hot path

CodSpeed flagged createOwners at -10%: the end-of-walk rethrow check in
disposeChildren slowed every disposal. Root dispose now rethrows after its
walk outside a flush; every other teardown leaves it to the flush that
runCleanup schedules. Drops the nested parameter too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore(size): re-record #4213's caps from CI after the perf fix

Hello world (app: compiled floor) is 9,935 B brotli, under 10 KB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* fix(signals): collect unobserved-hook throws, drop cleanup errors on halt

Review follow-ups for #4213:
- A throwing `unobserved` hook is collected like a cleanup, so the
  teardown around it carries on and a pass's release collector is
  always removed.
- A halt drops a collected cleanup error, so it never surfaces from a
  later, unrelated dispose().
- Server disposeOwner goes back to the indexed reverse loop.
- Tests for the mid-walk release, the halt, and throwing hooks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore(size): re-record #4213's caps from CI after the review fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

---------

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