Describe the bug
In Solid 2.0.0-beta.20, beginning hydration for a second root while a first root still has an unresolved serialized <Loading> boundary resets the first root's global hydration state.
The second root can mark hydration complete while the first is still pending. It also replaces global registry and gather callbacks used by the first root. When the first root eventually resumes, its boundary count can become negative and its DOM claims can be directed at the second root's registry.
This affects multi-root pages, islands, micro-frontends, and any application that calls hydrate() more than once while one root is still waiting on streamed data or assets.
Your Example Website or App
Focused hydration-state reproduction against solidjs/solid next at ec432b40:
import {
createRoot,
enableHydration,
flush,
Loading,
sharedConfig
} from "solid-js";
enableHydration();
let resolveA!: () => void;
const pendingA = new Promise<void>(resolve => {
resolveA = resolve;
});
// Root A starts with an unresolved serialized Loading boundary.
sharedConfig.hydrating = true;
sharedConfig.has = id => id === "a0";
sharedConfig.load = id => (id === "a0" ? pendingA : undefined);
sharedConfig.gather = () => {};
createRoot(
() => {
Loading({
fallback: "A loading",
children: "A content"
});
},
{ id: "a" }
);
flush();
sharedConfig.hydrating = false;
console.log(sharedConfig.done); // false: A is still pending
// Root B starts and has no pending boundaries.
sharedConfig.hydrating = true;
sharedConfig.has = () => false;
sharedConfig.load = () => undefined;
sharedConfig.hydrating = false;
// Actual: true, even though root A is unresolved.
// Expected: false until every active hydration root has completed.
console.log(sharedConfig.done);
resolveA();
await Promise.resolve();
await Promise.resolve();
flush();
The public hydrate() path drives the same sharedConfig.hydrating transitions and additionally replaces the shared registry/gather closures for each root.
Steps to Reproduce the Bug or Issue
- Hydrate root A with an unresolved serialized or streamed
<Loading> boundary.
- Finish A's synchronous hydration pass, leaving its boundary pending.
- Start hydration for an independent root B.
- Complete B while A is still unresolved.
- Observe that global hydration is marked done before A resolves.
- Resolve A and observe that it resumes against state already reset or replaced by B.
Expected behavior
Each hydration root should own independent pending-boundary, registry, gather, snapshot, and completion state. Completing root B must not reset or complete root A.
Screenshots or Videos
Not applicable.
Platform
- OS: macOS
- Runtime: browser build exercised under Node.js 25
- Version: Solid 2.0.0-beta.20,
next at ec432b40
Additional context
The reset occurs in packages/solid/src/client/hydration.ts:816-835:
if (!was && v) {
_hydrationDone = false;
_doneValue = false;
_pendingBoundaries = 0;
setSnapshotCapture(true);
_snapshotRootOwner = null;
}
Every false-to-true hydration transition sets the single module-global _pendingBoundaries counter to zero. Boundaries increment and later decrement that same counter in initBoundaryResume / resumeBoundaryHydration at hydration.ts:1302-1333.
The DOM hydration runtime also stores registry and gather state globally for each hydrate() invocation, so the problem extends beyond premature done: a late resume from A can gather against B's registry.
Existing tests cover individual hydration roots and server renderId behavior, but not two client roots where the first has a pending boundary.
Describe the bug
In Solid 2.0.0-beta.20, beginning hydration for a second root while a first root still has an unresolved serialized
<Loading>boundary resets the first root's global hydration state.The second root can mark hydration complete while the first is still pending. It also replaces global registry and gather callbacks used by the first root. When the first root eventually resumes, its boundary count can become negative and its DOM claims can be directed at the second root's registry.
This affects multi-root pages, islands, micro-frontends, and any application that calls
hydrate()more than once while one root is still waiting on streamed data or assets.Your Example Website or App
Focused hydration-state reproduction against
solidjs/solidnextatec432b40:The public
hydrate()path drives the samesharedConfig.hydratingtransitions and additionally replaces the shared registry/gather closures for each root.Steps to Reproduce the Bug or Issue
<Loading>boundary.Expected behavior
Each hydration root should own independent pending-boundary, registry, gather, snapshot, and completion state. Completing root B must not reset or complete root A.
Screenshots or Videos
Not applicable.
Platform
nextatec432b40Additional context
The reset occurs in
packages/solid/src/client/hydration.ts:816-835:Every false-to-true hydration transition sets the single module-global
_pendingBoundariescounter to zero. Boundaries increment and later decrement that same counter ininitBoundaryResume/resumeBoundaryHydrationathydration.ts:1302-1333.The DOM hydration runtime also stores registry and gather state globally for each
hydrate()invocation, so the problem extends beyond prematuredone: a late resume from A can gather against B's registry.Existing tests cover individual hydration roots and server
renderIdbehavior, but not two client roots where the first has a pending boundary.