From 75173bb7c40690383f088ce49537a9fe1f0327b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:13:42 +0000 Subject: [PATCH 1/2] Initial plan From 628c90eb51bba2e958d31b544cc54d8e55185a5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:45:31 +0000 Subject: [PATCH 2/2] Add unit tests for descendant-registry and fix reorder support Co-authored-by: iansan5653 <2294248+iansan5653@users.noreply.github.com> --- .../__tests__/descendant-registry.test.tsx | 190 ++++++++++++++++++ .../react/src/utils/descendant-registry.tsx | 26 ++- 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/utils/__tests__/descendant-registry.test.tsx diff --git a/packages/react/src/utils/__tests__/descendant-registry.test.tsx b/packages/react/src/utils/__tests__/descendant-registry.test.tsx new file mode 100644 index 00000000000..ff2a2d1740f --- /dev/null +++ b/packages/react/src/utils/__tests__/descendant-registry.test.tsx @@ -0,0 +1,190 @@ +import {describe, expect, it} from 'vitest' +import type React from 'react' +import {Fragment, useState} from 'react' +import {act, render} from '@testing-library/react' +import {createDescendantRegistry} from '../descendant-registry' + +/** + * Creates a fresh registry instance with isolated helper components for each test. This ensures + * no state leaks between tests via a shared Context or Provider. + */ +function createTestRegistry() { + const {Provider, useRegistryState, useRegisterDescendant} = createDescendantRegistry() + + /** + * Parent component that exposes the registry values in the DOM for assertions. + * State is held here and passed down to the Provider. + */ + function RegistryParent({children}: {children: React.ReactNode}) { + const [registryState, setRegistry] = useRegistryState() + + return ( + <> +
{Array.from(registryState.values()).join(',')}
+ {children} + + ) + } + + /** A leaf component that registers itself as a descendant. */ + function Item({value}: {value: string}) { + useRegisterDescendant(value) + return null + } + + return {RegistryParent, Item} +} + +describe('createDescendantRegistry', () => { + it('registers descendant items inside of other components', () => { + const {RegistryParent, Item} = createTestRegistry() + + function Wrapper({value}: {value: string}) { + return + } + + const {getByTestId} = render( + + + + + , + ) + + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + }) + + it('registers descendant items inside of React fragments', () => { + const {RegistryParent, Item} = createTestRegistry() + + const {getByTestId} = render( + + + + + + + , + ) + + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + }) + + it('registers items added to the middle of children after initial render', () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [showMiddle, setShowMiddle] = useState(false) + return ( + + + {showMiddle && } + + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,b') + + act(() => { + getByRole('button').click() + }) + + expect(getByTestId('registry-values').textContent).toBe('a,middle,b') + }) + + it('drops items from the registry after they unmount', () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [showLast, setShowLast] = useState(true) + return ( + + + + {showLast && } + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + + act(() => { + getByRole('button').click() + }) + + expect(getByTestId('registry-values').textContent).toBe('a,b') + }) + + it('updates registry order when items are reordered, using key to maintain component mount', () => { + const {RegistryParent, Item} = createTestRegistry() + + function Test() { + const [items, setItems] = useState(['a', 'b', 'c']) + return ( + + {items.map(item => ( + + ))} + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('a,b,c') + + act(() => { + getByRole('button').click() + }) + + expect(getByTestId('registry-values').textContent).toBe('c,a,b') + }) + + it('registers deep descendants added to the beginning of the tree after initial render', () => { + const {RegistryParent, Item} = createTestRegistry() + + function DeepItem({value}: {value: string}) { + return ( +
+
+ +
+
+ ) + } + + function Test() { + const [showFirst, setShowFirst] = useState(false) + return ( + + {showFirst && } + + + + + ) + } + + const {getByTestId, getByRole} = render() + expect(getByTestId('registry-values').textContent).toBe('second,third') + + act(() => { + getByRole('button').click() + }) + + expect(getByTestId('registry-values').textContent).toBe('first,second,third') + }) +}) diff --git a/packages/react/src/utils/descendant-registry.tsx b/packages/react/src/utils/descendant-registry.tsx index 564cc09166c..e69a283b608 100644 --- a/packages/react/src/utils/descendant-registry.tsx +++ b/packages/react/src/utils/descendant-registry.tsx @@ -77,6 +77,13 @@ export function createDescendantRegistry() { /** State value to trigger a re-render and force all descendants to re-register. This ensures everything remains ordered. */ const [key, setKey] = useState(0) + /** + * Tracks the `children` reference from the previous render to detect when the parent + * re-renders with new children (e.g., due to a reorder), as opposed to a Provider re-render + * triggered by our own `setRegistry` call (where children stay the same reference). + */ + const prevChildrenRef = useRef(children) + // Instantiate a new map before all descendants' effects run to populate it useIsomorphicLayoutEffect(function instantiateNewRegistry() { if (workingRegistryRef.current === 'queued') { @@ -112,11 +119,28 @@ export function createDescendantRegistry() { } }, []) - // After all descendants' effects complete, commit the working registry to state + // After all descendants' effects complete, commit the working registry to state. When the + // registry is idle and the children reference changed (indicating the parent re-rendered with + // new children, e.g., due to a reorder), queue a rebuild so all descendants re-register in + // their current render order, keeping the registry accurate. + // + // This effect intentionally omits a dependency array so it runs after every render. Adding + // deps would prevent the registry Map from being committed after rebuild cycles (where `key` + // increments but `children` and `setRegistry` stay the same). + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(function commitWorkingRegistry() { + const childrenChanged = prevChildrenRef.current !== children + prevChildrenRef.current = children + if (workingRegistryRef.current instanceof Map) { setRegistry(workingRegistryRef.current) workingRegistryRef.current = 'idle' + } else if (workingRegistryRef.current === 'idle' && childrenChanged) { + // The children changed (e.g., reordering) without triggering any descendant + // mount/unmount/update. Trigger a rebuild to capture the new render order. + workingRegistryRef.current = 'queued' + + setKey(prev => prev + 1) } })