Skip to content

fix(signals): createOwner's dispose works when called detached (#4444) - #4508

Closed
ryansolid wants to merge 7 commits into
nextfrom
claude/project-thread-x1ore2-4444
Closed

ryansolid wants to merge 7 commits into
nextfrom
claude/project-thread-x1ore2-4444

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Before: const o = createOwner(); queueMicrotask(o.dispose) threw TypeError: Cannot read properties of undefined (reading '_flags'). Effects aren't involved: a bare owner throws the same, and so does const d = o.dispose; d() called synchronously.

After: the owner createOwner returns has a dispose bound to it, so it can be handed around as a plain function, as createRoot's dispose already can (#4097). dispose(false) still works.

Cause: since e16371f ("reduce effect overhead"), every owner's dispose is one shared method, disposeRootSelf(this) in packages/signals/src/core/owner.ts. It finds its owner through this, so a detached call passed undefined to disposeChildren. Before that commit, dispose closed over its owner.

How:

  • A new internal createPublicOwner calls the existing createOwner and binds dispose. The package's public createOwner export is now that function, so the public name is unchanged.
  • Internal owners (mapArray rows, boundaries, createRoot) keep the shared method, so there is no allocation per row.
  • The runtimes' own owners also keep the unbound method: NoHydration, Portal and a frame's fill only ever dispose as o.dispose() or through their parent. They take it from an @internal signals export, createScopeOwner, re-exported through solid-js/internal (no public surface; solid-js strips @internal from its declarations). Without this, every server-components page carried the bind wrapper (+51 to +63 B minified) without ever calling createOwner.
  • No rule covers whether a detached dispose is supported; this restores the behaviour from before e16371f.

Tests: packages/signals/tests/owner-detached-dispose-4444.test.ts has 15 tests, all failing on next. It covers the issue's shape, detached dispose() and dispose(false), and render and plain effects with and without schedule, disposed sync, in a microtask and in a macrotask, three under one owner. A dispose before the first flush means the effects never run. A dispose after it cleans them up after their first run. Signals: 6121 passed / 15 expected fail. Web: 1581 passed / 7 expected fail. solid-js: 917 passed. test-types passes.

Size (client, vs next d8ca095, brotli / minified, measured locally; no ledger edits): only the router pages pay the bind, because @solidjs/router calls the public createOwner. Hello world and every other scenario are +0, except the frames consumer below.

Scenario Brotli Minified
page: base + router +43 +57
page: live + router −18 +73
frames: eager client consumer +7 +5

All three stay under their caps.

Closes #4444


🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

`queueMicrotask(o.dispose)` on an owner from `createOwner()` threw
"Cannot read properties of undefined (reading '_flags')". Root cause:
since e16371f every owner's `dispose` is the shared `disposeRootSelf`,
which reads its owner from `this`; a detached call leaves `this`
undefined and `disposeChildren(undefined)` reads `._flags`. The
`createRenderEffect(..., { schedule: true })` in the report is
incidental: a bare owner throws the same way, with or without effects,
synchronously or from any task. Called as a method (`o.dispose()`),
disposal before, during and after a scheduled first run already worked.

The exported `createOwner` now binds `dispose` to its owner (as
`createRoot`'s dispose is, #4097). Internal owners (mapArray rows,
boundaries, reveal) keep the shared method and pay no allocation; no
per-pass or flush code is touched.

No F-/D- rule governs the call shape of a disposer; the disposal it
performs is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 691f109

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
@solidjs/web Patch
test-integration 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

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.42 KB 0 B 0 B 0 B 7.43 KB ✅
signals: + createStore 14.98 KB 0 B 0 B 0 B 14.99 KB ✅
signals: + isPending/latest 11.35 KB 0 B 0 B 0 B 11.35 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B +2 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B 0 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.73 KB 0 B 0 B 0 B 18.74 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.48 KB 0 B 0 B 0 B 32.48 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.45 KB 0 B 0 B +9 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.19 KB 0 B 0 B +9 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.78 KB 0 B 0 B +9 B 29.78 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B +2 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.35 KB 0 B 0 B 0 B 26.36 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.48 KB 0 B 0 B 0 B 32.54 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.76 KB +7 B (+0.1%) +5 B +5 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.72 KB 0 B 0 B 0 B 33.77 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.17 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.05 KB 0 B 0 B 0 B 39.10 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.19 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.71 KB 0 B 0 B 0 B 35.72 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.14 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.01 KB 0 B 0 B 0 B 43.02 KB ✅ eager (counted): web.js 22.99 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.15 KB +43 B (+0.1%) +57 B 0 B 41.25 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 9.11 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.65 KB +32 B (+0.1%) +73 B 0 B 48.67 KB ✅ eager (counted): client.js 28.16 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.10 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.85 KB 0 B 0 B 0 B 22.87 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

Copy link
Copy Markdown
Member Author

Size gate (check) on ea230ce: four of the failing rows aren't this PR's. createStore, isPending/latest and compiled CSR are at 0 B vs base. Live + router is mostly the batch too. next @ b5077ea is over those caps after the batch, and #4507 re-records them.

This PR's own growth is on the pages that import the public createOwner: +51–63 B minified, as listed in the body. Under the batch process it gets re-recorded in the ledger PR after this batch. I'll merge next in once #4507 lands so the check re-runs against the new ledger.


Generated by Claude Code

@coveralls

coveralls commented Oct 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41140873806

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 78.1%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: 1 of 1 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1362
Covered Lines: 1125
Line Coverage: 82.6%
Relevant Branches: 1090
Covered Branches: 790
Branch Coverage: 72.48%
Branches in Coverage %: Yes
Coverage Strength: 29.82 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 11.03%

2 warnings

⚠️ 1 benchmark is 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 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 31.9 ms 28.8 ms +11.03%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-x1ore2-4444 (691f109) with next (71a6a75)2

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

  2. No successful run was found on next (e6c2f3b) during the generation of this report, so 71a6a75 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

…ound dispose (#4444)

NoHydration, Portal and a frame's fill called the exported createOwner,
so every page carried the bind wrapper (+51 to +63 B minified) for a
dispose they only ever call as o.dispose() or through the parent. They
now take the unbound owner from an @internal signals export
(createScopeOwner), re-exported through solid-js/internal. Apps that
never call createOwner are back to 0 B; the router pages still pay it,
since @solidjs/router calls the public createOwner.

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

Copy link
Copy Markdown
Member Author

CodSpeed flags arrow in template at −5.7%. That bench is server-side renderToString, which this PR doesn't touch: the change is the client createOwner export plus three client call sites. It is one of this repo's known noisy benches, and on the previous run of this PR it showed +6.0%. Every other check is green, and I'm treating this one as noise.


Generated by Claude Code

…ts, scope the changeset

Review nits on #4508, comment-only: the public `createOwner` shows a
user-facing doc comment instead of the maintainer note, the
`solid-js/internal` header lists `createScopeOwner`, and the changeset
no longer says "again" (a detached `getOwner().dispose` on other owner
kinds still throws; out of #4444's scope).

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

Copy link
Copy Markdown
Member Author

The check job's size gate fails on two rows at a4ab913. The commit is comment-only and changes no bytes.

This PR stays red until that cap moves. I'm keeping it watched.


Generated by Claude Code

ryansolid pushed a commit that referenced this pull request Oct 30, 2026
#4508 (#4444) binds `dispose` on the public `createOwner`, which
@solidjs/router calls, so the router pages pay the bind. The maintainer
accepted the growth on 2026-10-30, with a router follow-up to win it
back. Recorded from #4508's CI head a4ab913 (matches local):

- page: live + router: 48.65 -> 48.67 KB, minified 143,454 -> 143,527
- page: base + router: recorded minified 125,539 -> 125,596 (cap
  unchanged at 41.25 KB)

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

Copy link
Copy Markdown
Member Author

The CodSpeed run on 0753ed9 flags two store-setter benches, including createStore setter: delete + set one root key (#3044 overlay). CodSpeed reports that four of the benches with a significant change ran on different hardware for base and head.

This PR doesn't touch the store setter path. Its runtime change is limited to createOwner: the public export binds dispose, and NoHydration, Portal and frames use the unbound owner. The store benches never create owners through either. I'm reading this as runner noise, not a regression from this PR.


Generated by Claude Code

ryansolid added a commit that referenced this pull request Oct 30, 2026
…4512, #4506, #4509, #4508) (#4514)

* chore(size): re-record the ledger after the third 2026-10-30 batch (#4512, #4506, #4509)

next @ 71a6a75 fails the size gate on server: renderToString after
#4512 (low-priority modulepreload hints): 22,854 B brotli against the
frozen 22.80 KB cap, +266 B minified over its record. Re-records,
measured over next @ 71a6a75 (linux):

- server: renderToString (frozen floor): 22.80 -> 22.87 KB,
  minified 77,473 -> 77,739
- app: render + one signal (frozen floor): 9.78 -> 9.80 KB,
  minified 26,739 -> 26,754 (it measured 9,781 B, 1 B over its cap,
  with 5 B of minified allowance left)
- page: compiled live server components: 43.01 -> 43.02 KB,
  minified 126,181 -> 126,196

and the recorded minified only (caps unchanged) where #4509 moved it
+15 B: signals core floor (frozen, 19,908 -> 19,923), isPending/latest
(31,245 -> 31,260), live server components (frozen, 118,033 ->
118,048), live + router (143,439 -> 143,454).

Hello world is 9,971 B (cap 9.98 KB). The build leaves the pinned
mangle-props.json unchanged.

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

* chore(size): record #4508's router growth in the batch ledger

#4508 (#4444) binds `dispose` on the public `createOwner`, which
@solidjs/router calls, so the router pages pay the bind. The maintainer
accepted the growth on 2026-10-30, with a router follow-up to win it
back. Recorded from #4508's CI head a4ab913 (matches local):

- page: live + router: 48.65 -> 48.67 KB, minified 143,454 -> 143,527
- page: base + router: recorded minified 125,539 -> 125,596 (cap
  unchanged at 41.25 KB)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Member Author

#4515 supersedes this PR. The maintainer decided createOwner isn't application API, since 1.x had none, so it becomes typed only through solid-js/internal instead of getting a bound dispose. #4515 costs 0 B on every size scenario. Closing.


Generated by Claude Code

@ryansolid ryansolid closed this Oct 30, 2026
ryansolid added a commit that referenced this pull request Oct 30, 2026
Ryan's call on #4444 (2026-10-30): `createOwner` isn't intended as
application API, and 1.x had none. Like the boundary primitives in
#3709, it stays a runtime export of both solid-js entries but is
`@internal` (stripped from the declarations) and typed for renderers in
`solid-js/internal`. Apps host a scope with `createRoot()` and re-enter
it with `runWithOwner()`.

@solidjs/web keeps importing it from solid-js at runtime, with a
`@ts-expect-error` on the two typed imports (`Portal`, the frames
client), so the bundle holds one binding: 0 B on every size scenario.
Importing it through solid-js/internal instead cost +6 to +29 B
minified. The web specs that create owners import it from
solid-js/internal.

Supersedes #4508.


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.

3 participants