Skip to content

Stop the reading effect from owning a component's children - #16

Merged
pathscale merged 3 commits into
masterfrom
fix/children-memo-ownership
Sep 2, 2026
Merged

Stop the reading effect from owning a component's children#16
pathscale merged 3 commits into
masterfrom
fix/children-memo-ownership

Conversation

@pathscale

@pathscale pathscale commented Sep 2, 2026

Copy link
Copy Markdown
Owner

The bug

A Layout's children() memo is built on first read rather than at set-up, so a layout's provider wraps the children instead of finding them already constructed. That deferral is load-bearing and stays.

What it did not account for is who the first read belongs to. A memo created while an effect is running is owned by that effect, and the first read comes from the consumer's insert — which then subscribes to the very memo it now owns. So the first time a child changed, the effect woke and disposed the children it was in the middle of re-reading.

The symptom is "children update exactly once, then freeze". Nothing is thrown, nothing is logged, and the last-rendered DOM is left in place, so it reads as an application bug rather than a runtime one.

Under Solid 2 the blast radius is the whole page. These components nest to the root, so one <Show> mounting in the same batch as an unrelated write stops every DOM update in the application. That is how it surfaced: an auth form whose error message is an Alert inside a <Show> dies on the first wrong password — the submit button keeps its spinner forever, and nothing on the page updates again until a full reload.

The fix

Context and lifetime have to come from different owners:

  • context from the read site, because that is where a provider the layout wrapped these children in has been established;
  • lifetime from the component, because the read site is a computation that re-runs.

ownChildren splits them. It lives in renderer.ts / renderer.solid-2.ts because separating them is the one thing the two majors spell differently:

  • 1.9 has createRoot's second argument, which sets the new root's owner without handing the root to it. Disposal is then ours to place, and it rides on the component.
  • 2.0 dropped that argument (it is an options bag there, and the root is parented to whatever is running) but made context a flat snapshot object on the owner. So the scope is created under the component and given the read site's snapshot.

component.ts stays major-agnostic.

Two things that look like fixes and are not, since both are easy to reach for again:

  • runWithOwner(componentOwner, …) ends the freeze but breaks provider context, which is the reason the build is deferred at all. The existing "children are built inside the provider" test catches it.
  • createRoot(build, detachedOwner) alone fixes 1.9 and leaves 2.0 broken, while the suite goes green — see the note on verification below.

Verification

  • bun run test — 150 pass, including the new regression test. It drives the runtime the way a consumer does: an effect that reads the children and re-runs. Against master it records the first two renders and then nothing, which is the freeze.
  • bunx tsc --noEmit, bun run build, and loading the built entry all pass.
  • The suite runs against the installed Solid 1.9.14, so a green suite says nothing about the 2.0 arm. That arm was verified separately by building this branch into a real Solid 2.0.0-rc.4 application (@pathscale/ui 2.11.11, solid-layouts 0.2.1). Before: a mistyped username left Continue permanently disabled / data-state="loading" and froze the rest of the page. After: the error renders, the button returns to default, the typo can be corrected, a wrong password can be retried, and the login completes — with no reload at any point.

Release

Bumps solid-layouts to 0.2.2, so landing this publishes it. Consumers cannot work around 0.2.1 from outside the package, so the fix is only worth anything once it ships.

Only the runtime moves. rsbuild-plugin-solid-layouts depends on solid-layouts-oxc rather than on this package, and neither it nor the compiler changed, so bumping them would publish identical tarballs under new numbers.

Also here: publish from Ubicloud

Both release workflows ran their publish job on ubuntu-latest while every other job in the repository is on Ubicloud. Nothing about the job wanted a GitHub-hosted runner — it downloads the packed tarball and runs npm publish, and the OIDC token Trusted Publishing checks is issued by the Actions service against the workflow and ref rather than by the runner. release-oxc's build matrix keeps macos-14, which is a real exception and already says why.

Worth knowing given it rides along with a release: no publish job has run on Ubicloud yet, and merging this is what exercises it. If the runner turns out not to suit Trusted Publishing, the 0.2.2 publish fails rather than publishing something wrong, and the decider treats npm as the source of truth, so re-running after reverting this commit is safe.

meh added 3 commits September 2, 2026 10:10
`stable.children` builds its `children()` memo on first read rather than at
set-up, so a layout's provider wraps the children rather than finding them
already built. But a memo created while an effect is running belongs to that
effect, and the first read comes from the consumer's `insert` — which then
subscribes to the memo it now owns. The first time a child changed, that
effect woke and disposed the children it was re-reading.

The symptom is "children update exactly once, then freeze", with nothing
thrown and the last-rendered DOM left in place. Under Solid 2 it is worse
than local: these components nest to the root, so one `<Show>` mounting in
the same batch as an unrelated write stopped every DOM update on the page.
An application whose error message is an `Alert` inside a `<Show>` — which
is every auth form — dies on the first wrong password and stays dead until
a reload.

Context and lifetime have to come from different places: context from the
read site, where the provider is, and lifetime from the component. The new
`ownChildren` splits them, and lives in the renderer because separating them
is the one thing the two majors spell differently. 1.9 has `createRoot`'s
detached-owner argument. 2.0 dropped it and made context a flat snapshot on
the owner, so the scope is created under the component and given the read
site's snapshot instead.

The regression test drives it the way the consumer does: an effect that
reads the children and re-runs. Before this it recorded the first two
renders and then nothing.
A version bump is the release, so this is what carries the children-ownership
fix to consumers. 0.2.1 freezes an application's reactivity on the first
change to any Layout's children, which no one can work around from outside
the package.

Only the runtime moves. `rsbuild-plugin-solid-layouts` depends on
`solid-layouts-oxc` rather than on this package, and neither it nor the
compiler changed, so republishing them would ship identical tarballs under
new numbers.
Both release workflows ran their `publish` job on `ubuntu-latest` while every
other job in the repository is on Ubicloud. Nothing about the job wanted a
GitHub-hosted runner: it downloads the packed tarball and runs `npm publish`,
and the OIDC token that Trusted Publishing checks is issued by the Actions
service against the workflow and ref rather than by the runner.

The one genuine exception stays as it is and says so: `release-oxc`'s build
matrix pins macOS to `macos-14`, because Ubicloud has no macOS runners.
@pathscale
pathscale merged commit 68d5668 into master Sep 2, 2026
5 checks passed
@pathscale
pathscale deleted the fix/children-memo-ownership branch September 2, 2026 06:21
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