Repository navigation
Conversation
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.
There was a problem hiding this comment.
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.
|
|
||
| // 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); | ||
| } |
There was a problem hiding this comment.
| // 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); | |
| } |
There was a problem hiding this comment.
Cloning shouldn't be necessary. We can just null the pointers so the nodes disconnect.
There was a problem hiding this comment.
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?
| useLayoutEffect(() => { | ||
| if (doc && !isSSR) { | ||
| doc.finishSSR(); | ||
| } | ||
| }, [doc, isSSR]); |
There was a problem hiding this comment.
| 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]); |
| if (!isSSR && doc?.isSSR) { | ||
| doc.resetAfterSSR(); |
There was a problem hiding this comment.
@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();
}
}There was a problem hiding this comment.
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.
| useLayoutEffect(() => { | ||
| if (isSSR) { | ||
| doc?.resetAfterSSR(); | ||
| } else { | ||
| doc?.queueUpdateAfterSSR(); | ||
| } | ||
| }, [doc, isSSR]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.

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.collectionandDocument.nextCollectionreference 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
useSyncExternalStoremay read the snapshot again during this transition. Previously, that read could callupdateCollection()and commitnullas 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:isHydratingstate to preserve the existing snapshot and defer collection updates and notifications while the client portal is being prepared.finishSSR()from a layout effect inCollectionRootafter the client portal commits, allowing pending changes to be processed.BreadcrumbsSSR 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:
📝 Test Instructions:
yarn test:ssr. Without the fixBreadcrumbs.ssr.test.jsandCollection.ssr.test.jsfail.BreadcrumbsSSR scenario preserves the originallistitemDOM nodes after hydration.useEffectanduseLayoutEffectcorrectly handle clearing, replacing, and populating items. Collection size, keys, and iteration order should remain consistent after subsequent restore/reorder and clear actions.