fix(solid): SSR boundaries resolve children in a scope mirroring the client's flatten computed (#3414) - #3428
Merged
Merged
Conversation
…ient's flatten computed (#3414) A server-rendered `<Errored>` fallback hydrated dead when the boundary's children threw synchronously and the fallback was a zero-arg thunk (`fallback={() => <Fallback />}`) inside an enclosing boundary. Both sides hand the thunk back unresolved and the enclosing boundary unwraps it — the client inside its second computed (`boundaryComputed(() => flatten(read(c)))`, the boundary owner's child `…1`), the server inline under the boundary owner (`ctx.resolve` right after the `fn` owner `…0`) — so the fallback's root element took `…1` on the server and `…10` on the client, failed its claim, and its handlers and effects never attached. Same misalignment for `Loading` as the consumer, fragment children, and `Show`'s fallback thunk; an element hole in between (compiled `scope()` ↔ transparent insert effect) was already aligned. The server `Errored` and `Loading` boundaries now resolve their children's result in a virtual id scope mirroring that second computed — ssrScope's technique: the owner keeps its identity (retry wraps capture it and read the current pull's error handler off it; a real child owner snapshots a stale handler and double-renders the fallback) and only its id counter is rewritten for the resolve, retries continuing where the discovery pass left it. The error record is serialized at the boundary's own id, read once, since `owner.id` is rewritten while the error lands. Parity-harness scenarios cover Errored/Loading/fragment consumers and the Show producer, plus the element-hole control. Closes #3414 Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: f100afa The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
`Show`'s `fallback` prop is typed as an element; the zero-arg thunk is a runtime-accepted shape the scenario exercises on purpose, so cast it (the same treatment as the memo-returning components in #3394). Co-authored-by: Cursor <cursoragent@cursor.com>
Coverage Report for CI Build 34860340383Coverage remained the same at 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
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.
Closes #3414
Problem
A server-rendered
<Errored>fallback hydrates dead: the HTML is right, but the fallback's button never fires and its signals never update the DOM. Reported shape (from the@solidjs/vite-pluginstart entry):DefaultErrorBoundary > Document > DefaultErrorBoundary > App, whereApprenders<Errored fallback={() => <Fallback />}><SyncFailure /></Errored>.It is a hydration id mismatch, not an event or reactivity problem.
Erroredwith a zero-arg fallback hands the thunk back unresolved on both sides, and the consumer of the boundary's output unwraps it. When that consumer is another boundary, the two sides allocate ids for the unwrapped content in different places:createCollectionBoundary— two computeds under the boundary owner:c = computed(fn)(…0) andboundaryComputed(() => flatten(read(c)))(…1);flattencalls the thunk inside…1, so the fallback's<main>gets…10createErrorBoundary.resolve— one owner forfn(…0), thenctx.resolve(...)inline under the boundary owner, so<main>got…1For the reported document: server
<main _hk=00101>, client expected001010→ "Hydration key miss", a detached fallback, dead button. The same misalignment hitsLoadingas the consumer, fragment children, andShow's fallback thunk. An element hole in between (compiledscope()on the server ↔ transparent insert effect on the client) is already aligned, which is why simpler repros pass.Fix
The server
ErroredandLoadingboundaries resolve their children's result in a virtual id scope mirroring the client's second computed —ssrScope's technique: the owner keeps its identity and only itsid/_childCountare rewritten for the duration of the resolve; retry pulls resume the surviving holes in the same scope with the counter continuing where the discovery pass left it.Why virtual and not a real
createOwner(): retry wraps (buildAsyncWrap) capture the owner and read the current pull'sErrorContexthandler off it. A real child owner snapshots the handler of the pull that created it; on the retry pass the stale handler rendered the fallback once, then the current pull rendered it again (double render, error serialized twice, ids off by two) —ssr-stream.spec.tsx's "Loading wrapping Errored" test caught this.The error record is now serialized at the boundary's own id, read once (
boundaryId), sinceowner.idis rewritten while the error lands mid-resolve.Tests
Five parity-harness scenarios (SSR artifact → hydrate, both compilers):
errored-thunk-fallback-under-errored,-under-loading,-in-fragment,-in-element(control),show-thunk-fallback-under-errored. Four failed before the fix (key miss / dead update); all pass with matching owner chains on both sides. Full web dom/server/hydrate and solid suites green.Not verified against the reporter's Vite app (it pins rc.8 from npm); the jsdom repro reproduces its exact markup and ids (
001000record,001010fallback root on both sides).