From 0209ef8a77bcf423cb31029e050a0722a71ebd03 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Tue, 1 Sep 2026 20:45:00 +0200 Subject: [PATCH] [DevTools] Fix reconciliation of content with fallback Fiber (#37475) ## Summary Alternative to #37280 that keeps the child-set assertion and instead fixes the root cause. The assertion "The children should not have changed if we pass in the same set." fired while DevTools reconciled the hidden content tree of a Suspense boundary that had just switched to its fallback. `updateSuspenseChildrenRecursively` reconciles the content and the fallback in two passes, but the previous-set lockstep pointer of the content pass is not bounded. When a boundary is suspended on both sides of a commit, the pointer advances from the previous content Offscreen onto the previous fallback fragment, and the leftover-children check reports `ShouldResetChildren` even though the fallback is reconciled in the second pass by design. For a boundary that is filtered from the tree, that flag propagates to the parent child list, freezes its lockstep pointer, forces the following sibling to be paired by alternate, and the instance scan (which only matches the paired previous fiber) no longer finds the existing instance, since instances track the current fiber. The subtree below is then walked without its instance, which cascades into spurious unmount and remount work and surfaces at the assertion in the filtered same-child-set branch. We're also avoiding creation of new backend instances in those scenarios. This change bounds the previous set of the content pass by the previous fallback fragment via a new `prevLastChild` parameter, so the flag disappears. Closes #37280 ## How did you test this change? - cherry-picked test from #37280 --------- Co-authored-by: Ruslan Lesiutin Co-authored-by: Cursor Co-authored-by: Claude Code (kimi-k3[1m]) --- .../__tests__/storeComponentFilters-test.js | 72 +++++++++++++++++++ .../src/backend/fiber/renderer.js | 13 +++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js index 1ef17e4c5475..083a9c0393bd 100644 --- a/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js +++ b/packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js @@ -572,6 +572,78 @@ describe('Store component filters', () => { `); }); + // @reactVersion >= 19.0 + it('stays in sync when a filtered boundary suspends during a sibling restructure', async () => { + const neverResolves = new Promise(() => {}); + + function Reader() { + React.use(neverResolves); + return
read
; + } + + function A({collapsed}) { + if (collapsed) { + return ( + a-fb}> + + + ); + } + return ( + a-outer-fb}> + a-inner-fb}> + + + a-sib-fb}> +
a-sibling
+
+
+ ); + } + + function App({collapsed, extra}) { + return ( +
+ {extra ? side : null} + +
+ ); + } + + store.componentFilters = [ + utils.createElementTypeFilter(Types.ElementTypeSuspense), + ]; + + await actAsync(() => render()); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ ▾ +
+
+ `); + + await actAsync(() => render()); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ + ▾ +
+ `); + + await actAsync(() => render()); + expect(store).toMatchInlineSnapshot(` + [root] + ▾ + ▾
+ ▾ +
+ `); + }); + describe('inline errors and warnings', () => { const {render: legacyRender} = getLegacyRenderImplementation(); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b6f64cd48b97..c4f5d61f5a6d 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3933,6 +3933,7 @@ export function attach( nextFirstChild, nextLastChild, prevFirstChild, + null, traceNearestHostComponentUpdate, virtualLevel + 1, ); @@ -3971,6 +3972,7 @@ export function attach( nextFirstChild: Fiber, nextLastChild: null | Fiber, // non-inclusive prevFirstChild: null | Fiber, + prevLastChild: null | Fiber, // non-inclusive traceNearestHostComponentUpdate: boolean, virtualLevel: number, // the nth level of virtual instances ): UpdateFlags { @@ -4247,7 +4249,10 @@ export function attach( } } // If we have no more children, but used to, they don't line up. - if (prevChildAtSameIndex !== null) { + if ( + prevChildAtSameIndex !== null && + prevChildAtSameIndex !== prevLastChild + ) { updateFlags |= ShouldResetChildren | ShouldResetSuspenseChildren; } return updateFlags; @@ -4266,6 +4271,7 @@ export function attach( nextFirstChild, null, prevFirstChild, + null, traceNearestHostComponentUpdate, 0, ); @@ -4284,10 +4290,14 @@ export function attach( const nextFallbackFiber = nextContentFiber.sibling; // First update only the Offscreen boundary. I.e. the main content. + // The previous set is bounded by the previous fallback fragment since the + // fallback is reconciled separately below. Without the bound, a boundary + // that stays suspended would always report its content set as changed. updateFlags |= updateVirtualChildrenRecursively( nextContentFiber, nextFallbackFiber, prevContentFiber, + prevFallbackFiber, traceNearestHostComponentUpdate, 0, ); @@ -4306,6 +4316,7 @@ export function attach( nextFallbackFiber, null, prevFallbackFiber, + null, traceNearestHostComponentUpdate, 0, );