Skip to content

2.0.0-beta.20: starting a second hydration root corrupts an earlier pending root #2917

Description

@brenelz

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

  1. Hydrate root A with an unresolved serialized or streamed <Loading> boundary.
  2. Finish A's synchronous hydration pass, leaving its boundary pending.
  3. Start hydration for an independent root B.
  4. Complete B while A is still unresolved.
  5. Observe that global hydration is marked done before A resolves.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions