Stop the reading effect from owning a component's children - #16
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anAlertinside 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:
ownChildrensplits them. It lives inrenderer.ts/renderer.solid-2.tsbecause separating them is the one thing the two majors spell differently: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.component.tsstays 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. Againstmasterit 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.@pathscale/ui2.11.11,solid-layouts0.2.1). Before: a mistyped username leftContinuepermanentlydisabled/data-state="loading"and froze the rest of the page. After: the error renders, the button returns todefault, the typo can be corrected, a wrong password can be retried, and the login completes — with no reload at any point.Release
Bumps
solid-layoutsto 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-layoutsdepends onsolid-layouts-oxcrather 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
publishjob onubuntu-latestwhile every other job in the repository is on Ubicloud. Nothing about the job wanted a GitHub-hosted runner — it downloads the packed tarball and runsnpm 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 keepsmacos-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.