Skip to content

fix: preserve collection snapshots during hydration - #10656

Open
izorg wants to merge 3 commits into
adobe:mainfrom
izorg:fix/collection-ssr-hydration
Open

izorg wants to merge 3 commits into
adobe:mainfrom
izorg:fix/collection-ssr-hydration

Conversation

@izorg

@izorg izorg commented Sep 27, 2026

Copy link
Copy Markdown

Hello, thank you very much for your effort. I like React Aria vey much.

I first encountered the issue while integrating React Aria into a Next.js project. The Breadcrumbs component appears, disappears, and then reappears.

Summary

Intent

Preserve server-rendered collection items during hydration instead of unnecessarily removing and recreating their DOM nodes. Collection changes made by the first client effects should also be reflected correctly, including when items are removed or replaced.

Root cause

During SSR, Document.collection and Document.nextCollection reference the same collection object. When switching to client rendering, resetAfterSSR() clears the document’s child pointers before React has committed the client portal.

React’s useSyncExternalStore may read the snapshot again during this transition. Previously, that read could call updateCollection() and commit null as the first and last keys into the already-published collection. Its iterator would then return no items even though its key map and size still contained the server-rendered entries.

In Breadcrumbs, this caused React to remove the existing items and recreate them once the client collection was populated. Checking text content alone did not catch the problem because the final HTML could look unchanged.

Implementation

The fix addresses the shared collection lifecycle rather than adding a workaround to Breadcrumbs:

  • Add an isHydrating state to preserve the existing snapshot and defer collection updates and notifications while the client portal is being prepared.
  • Prepare a separate pending collection with the server nodes removed, so items removed by initial client effects do not remain as stale entries.
  • Call finishSSR() from a layout effect in CollectionRoot after the client portal commits, allowing pending changes to be processed.
  • Strengthen the Breadcrumbs SSR test to verify DOM node identity, and add collection-level coverage for initial effect updates and subsequent collection changes.

AI assistance: GitHub Copilot assisted with root-cause investigation, regression-test refinement, and drafting this description.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (SSR regression tests added/updated; no new visual state requiring a Storybook change).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component). No public API or usage changes.
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

  1. Run yarn test:ssr. Without the fix Breadcrumbs.ssr.test.js and Collection.ssr.test.js fail.
  2. Verify that the Breadcrumbs SSR scenario preserves the original listitem DOM nodes after hydration.
  3. Verify that collection updates from the first useEffect and useLayoutEffect correctly handle clearing, replacing, and populating items. Collection size, keys, and iteration order should remain consistent after subsequent restore/reorder and clear actions.

Keep the server snapshot until the client portal commits to avoid removing and recreating server-rendered items. Rebuild the client collection without stale server keys so clearing or replacing items on mount updates its size and contents consistently.
Copilot AI lite review requested due to automatic review settings September 27, 2026 09:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved blocking issues remain, and the reviewed changes include appropriate regression coverage.

Review effort: Lite
Findings: None

What changed in this PR

Fixes SSR hydration for collection-backed components by preserving server-rendered snapshots and applying client updates after portal commitment.

Changes:

  • Defers collection updates during hydration.
  • Finalizes pending changes after client commit.
  • Adds regression tests for collection updates and DOM node reuse.
File Description
packages/​react-aria/​src/​collections/​Document.ts Adds hydration lifecycle and pending collection handling.
packages/​react-aria/​src/​collections/​CollectionBuilder.tsx Finishes SSR handling after portal commit.
packages/​react-aria-components/​test/​Collection.ssr.test.js Tests hydration and subsequent collection updates.
packages/​react-aria-components/​test/​Breadcrumbs.ssr.test.js Verifies server-rendered DOM nodes are reused.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@nwidynski nwidynski left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there, thanks for raising the issue and opening the PR! I believe we can avoid isHydrating if we move the document reset from getSnapshot into CollectionRoot.

Comment on lines +620 to +626

// Preserve the server snapshot until the client portal commits, including an empty collection.
this.isHydrating = true;
this.nextCollection = this.collection.clone();
for (let key of this.nextCollection.getKeys()) {
this.nextCollection.removeNode(key);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Preserve the server snapshot until the client portal commits, including an empty collection.
this.isHydrating = true;
this.nextCollection = this.collection.clone();
for (let key of this.nextCollection.getKeys()) {
this.nextCollection.removeNode(key);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloning shouldn't be necessary. We can just null the pointers so the nodes disconnect.

@izorg izorg Sep 27, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @nwidynski, I applyed your changes as-is and tests are passing! One edge case: when a collection is empty during SSR and stays empty through hydration, nextCollection remains null, so commit() is skipped and the client snapshot stays frozen: false.

Normal updates still work. I haven't found any issues. However, accidental direct mutations such as snapshot.addNode(...) would no longer throw.

Should we ensure empty collections are frozen after hydration, or is this acceptable?

Comment on lines +309 to +313
useLayoutEffect(() => {
if (doc && !isSSR) {
doc.finishSSR();
}
}, [doc, isSSR]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
useLayoutEffect(() => {
if (doc && !isSSR) {
doc.finishSSR();
}
}, [doc, isSSR]);
// After SSR is complete, reset the document to empty so it is ready for React to render the portal into.
// We do this _after_ getting the collection so that the collection still has content in it from SSR
// during the current render, before React has finished the client render.
if (!isSSR && doc?.isSSR) {
doc.resetAfterSSR();
}
// Ensure that React re-renders after switching from SSR to client rendering. If the portal rendered
// any items, appendChild will have already queued one and this is a no-op. If the tree is empty,
// we must still make sure to re-render so queue an update manually.
useLayoutEffect(() => {
if (!isSSR) {
doc?.queueUpdate();
}
}, [doc, isSSR]);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The collection reset remains vulnerable to publishing an empty snapshot when hydration rendering is interrupted or restarted.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Comment on lines +305 to +306
if (!isSSR && doc?.isSSR) {
doc.resetAfterSSR();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@izorg Hm, I suppose this is a valid concern. I think it's a good change nonetheless to remove the reset from getSnapshot, so let's move everything into the effect and bring back the isHydrating flag as you've had it before, only with the pointer changes. That should also address the collection freeze.

useLayoutEffect(() => {
  if (isSSR) {
    doc.resetAfterSSR();
  } else {
    doc.queueUpdateAfterSSR();
  }
}, [doc, isSSR]);
updateCollection(): void {
  if (this.isHydrating) {
    return;
  }

  // ...
}

queueUpdate(): void {
  if (this.isHydrating || this.dirtyNodes.size === 0 || this.queuedRender) {
    return;
  }

  //...

  if (!this.isSSR) {
    this.collection = this.collection.clone();
    this.nextCollection = this.collection;
  }

  //...
}

resetAfterSSR(): void {
  if (this.isSSR) {
    for (let node of this) {
      node.parentNode = null;
    }
    this.firstChild = null;
    this.lastChild = null;
    this.nextCollection = null;
    this.isHydrating = true;
    this.nodeId = 0;
  }
}

queueUpdateAfterSSR(): void {
  if (this.isHydrating) {
    this.isSSR = false;
    this.isHydrating = false;
    this.queuedRender = false;
    this.queueUpdate();
  }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you once again for handling this topic. I've applied your suggestion: the reset and finalization now run in useLayoutEffect, with isHydrating preserving the server snapshot until the client portal commits.

I added SSR coverage for a collection that remains empty, checking that it is frozen after hydration and subsequent updates.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Removing the collection root during the initial layout effects can leave its document permanently hydrating with stale items.

Review effort: Balanced
Findings: 2 High severity

Open (2)

Comment on lines +303 to +309
useLayoutEffect(() => {
if (isSSR) {
doc?.resetAfterSSR();
} else {
doc?.queueUpdateAfterSSR();
}
}, [doc, isSSR]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core maintainers should weigh in here. I'm not sure whether this is a realistic case that is worth the extra re-render in the builder.

@izorg izorg Sep 28, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid AI reported issue we could move useLayoutEffect as is to CollectionBuilder component or its useCollectionDocument hook. In this case document will be present and no conditional logic happens.

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.

3 participants