From 24d28da9bac7f6968221c78afc48e23777a074e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 10:36:29 +0200 Subject: [PATCH 01/10] feat(Tree): collect rows rendered inside custom components --- .../Tree/components/TreeCollector.tsx | 101 ++++++++++ ...TreeFolder.spec.ts => TreeFolder.spec.tsx} | 6 +- .../components/Tree/components/TreeFolder.ts | 6 - .../components/Tree/components/TreeFolder.tsx | 20 ++ .../{TreeItem.spec.ts => TreeItem.spec.tsx} | 6 +- .../components/Tree/components/TreeItem.ts | 6 - .../components/Tree/components/TreeItem.tsx | 14 ++ ...eeLoading.spec.ts => TreeLoading.spec.tsx} | 6 +- .../components/Tree/components/TreeLoading.ts | 4 - .../Tree/components/TreeLoading.tsx | 9 + .../components/Tree/components/TreeRoot.tsx | 35 +++- .../Tree/components/TreeWrapper.ct.tsx | 122 ++++++++++++ .../components/testutils/WrapperFixtures.tsx | 187 ++++++++++++++++++ .../Tree/hooks/useTreeController.ts | 3 +- .../components/Tree/utils/parseChildren.ts | 78 +++++--- 15 files changed, 552 insertions(+), 51 deletions(-) create mode 100644 packages/components/src/components/Tree/components/TreeCollector.tsx rename packages/components/src/components/Tree/components/{TreeFolder.spec.ts => TreeFolder.spec.tsx} (56%) delete mode 100644 packages/components/src/components/Tree/components/TreeFolder.ts create mode 100644 packages/components/src/components/Tree/components/TreeFolder.tsx rename packages/components/src/components/Tree/components/{TreeItem.spec.ts => TreeItem.spec.tsx} (70%) delete mode 100644 packages/components/src/components/Tree/components/TreeItem.ts create mode 100644 packages/components/src/components/Tree/components/TreeItem.tsx rename packages/components/src/components/Tree/components/{TreeLoading.spec.ts => TreeLoading.spec.tsx} (59%) delete mode 100644 packages/components/src/components/Tree/components/TreeLoading.ts create mode 100644 packages/components/src/components/Tree/components/TreeLoading.tsx create mode 100644 packages/components/src/components/Tree/components/TreeWrapper.ct.tsx create mode 100644 packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx diff --git a/packages/components/src/components/Tree/components/TreeCollector.tsx b/packages/components/src/components/Tree/components/TreeCollector.tsx new file mode 100644 index 0000000000..dcf1f2d0e8 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeCollector.tsx @@ -0,0 +1,101 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { createContext, useContext, useId, useLayoutEffect, type ReactNode } from 'react'; + +import { ROOT_ID } from '../constants'; +import { type TreeFolderProps, type TreeItemData, type TreeItemProps } from '../types'; +import { type ParsedChildren, toFolderRowData, toItemData } from '../utils/parseChildren'; + +export const COLLECT_ATTR = 'data-tree-collect-key'; + +type CollectedEntry = + | { kind: 'item'; parentId: string; props: TreeItemProps } + | { kind: 'folder'; parentId: string; props: TreeFolderProps } + | { kind: 'loading'; parentId: string }; + +export type CollectStore = { + entries: Map; + requestFlush: () => void; +}; + +const TreeCollectContext = createContext(null); +export const TreeParentContext = createContext(ROOT_ID); + +/** + * Registers a row rendered inside the collect pass. Returns the marker key, or `null` + * outside a collect pass, where Tree parts stay inert markers read by `parseChildren`. + */ +export const useCollectedEntry = ( + build: (parentId: string) => CollectedEntry, +): { key: string; isCollecting: boolean } => { + const store = useContext(TreeCollectContext); + const parentId = useContext(TreeParentContext); + const key = useId(); + useLayoutEffect(() => { + if (!store) { + return; + } + store.entries.set(key, build(parentId)); + store.requestFlush(); + return () => { + store.entries.delete(key); + store.requestFlush(); + }; + }); + return { key, isCollecting: store !== null }; +}; + +export const TreeCollector = ({ + store, + containerRef, + children, +}: { + store: CollectStore; + containerRef: React.RefObject; + children: ReactNode; +}) => ( + +); + +/** Builds the flat item list from the markers' document order. */ +export const buildCollectedItems = (container: HTMLElement, entries: Map): ParsedChildren => { + const loadingParents = new Set(); + for (const entry of entries.values()) { + if (entry.kind === 'loading') { + loadingParents.add(entry.parentId); + } + } + const ordered: CollectedEntry[] = []; + for (const marker of container.querySelectorAll(`[${COLLECT_ATTR}]`)) { + const entry = entries.get(marker.getAttribute(COLLECT_ATTR) ?? ''); + if (entry && entry.kind !== 'loading') { + ordered.push(entry); + } + } + const childIdsByParent = new Map(); + for (const entry of ordered) { + if (entry.kind === 'loading') { + continue; + } + const siblings = childIdsByParent.get(entry.parentId) ?? []; + siblings.push(entry.props.id); + childIdsByParent.set(entry.parentId, siblings); + } + const items: TreeItemData[] = ordered.map((entry) => { + if (entry.kind === 'folder') { + return toFolderRowData( + entry.props, + entry.parentId, + childIdsByParent.get(entry.props.id) ?? [], + loadingParents.has(entry.props.id), + ); + } + if (entry.kind === 'item') { + return toItemData(entry.props, entry.parentId); + } + throw new Error('unreachable'); + }); + return { items, parentIsLoading: loadingParents.has(ROOT_ID), hasForeignRows: true }; +}; diff --git a/packages/components/src/components/Tree/components/TreeFolder.spec.ts b/packages/components/src/components/Tree/components/TreeFolder.spec.tsx similarity index 56% rename from packages/components/src/components/Tree/components/TreeFolder.spec.ts rename to packages/components/src/components/Tree/components/TreeFolder.spec.tsx index 7dfda53e61..b03f17ab25 100644 --- a/packages/components/src/components/Tree/components/TreeFolder.spec.ts +++ b/packages/components/src/components/Tree/components/TreeFolder.spec.tsx @@ -1,12 +1,14 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ +import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { TreeFolder } from './TreeFolder'; describe('TreeFolder', () => { - it('renders null', () => { - expect(TreeFolder({ id: 'f', children: null })).toBe(null); + it('renders nothing outside a Tree collect pass', () => { + const { container } = render({null}); + expect(container).toBeEmptyDOMElement(); }); it('declares displayName="Tree.Folder"', () => { diff --git a/packages/components/src/components/Tree/components/TreeFolder.ts b/packages/components/src/components/Tree/components/TreeFolder.ts deleted file mode 100644 index d7070bd033..0000000000 --- a/packages/components/src/components/Tree/components/TreeFolder.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* (c) Copyright Frontify Ltd., all rights reserved. */ - -import { type TreeFolderProps } from '../types'; - -export const TreeFolder = (_props: TreeFolderProps): null => null; -TreeFolder.displayName = 'Tree.Folder'; diff --git a/packages/components/src/components/Tree/components/TreeFolder.tsx b/packages/components/src/components/Tree/components/TreeFolder.tsx new file mode 100644 index 0000000000..fa1e692b78 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeFolder.tsx @@ -0,0 +1,20 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type TreeFolderProps } from '../types'; +import { getFolderRows } from '../utils/parseChildren'; + +import { COLLECT_ATTR, TreeParentContext, useCollectedEntry } from './TreeCollector'; + +export const TreeFolder = (props: TreeFolderProps) => { + const { key, isCollecting } = useCollectedEntry((parentId) => ({ kind: 'folder', parentId, props })); + if (!isCollecting) { + return null; + } + return ( + <> + + {getFolderRows(props.children)} + + ); +}; +TreeFolder.displayName = 'Tree.Folder'; diff --git a/packages/components/src/components/Tree/components/TreeItem.spec.ts b/packages/components/src/components/Tree/components/TreeItem.spec.tsx similarity index 70% rename from packages/components/src/components/Tree/components/TreeItem.spec.ts rename to packages/components/src/components/Tree/components/TreeItem.spec.tsx index 70d54617ac..58877a49b1 100644 --- a/packages/components/src/components/Tree/components/TreeItem.spec.ts +++ b/packages/components/src/components/Tree/components/TreeItem.spec.tsx @@ -1,5 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ +import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { TreeItem } from './TreeItem'; @@ -11,8 +12,9 @@ import { TreeItem } from './TreeItem'; */ describe('TreeItem', () => { - it('renders null', () => { - expect(TreeItem({ id: 'x', children: 'X' })).toBe(null); + it('renders nothing outside a Tree collect pass', () => { + const { container } = render(X); + expect(container).toBeEmptyDOMElement(); }); it('declares displayName="Tree.Item"', () => { diff --git a/packages/components/src/components/Tree/components/TreeItem.ts b/packages/components/src/components/Tree/components/TreeItem.ts deleted file mode 100644 index dc05863787..0000000000 --- a/packages/components/src/components/Tree/components/TreeItem.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* (c) Copyright Frontify Ltd., all rights reserved. */ - -import { type TreeItemProps } from '../types'; - -export const TreeItem = (_props: TreeItemProps): null => null; -TreeItem.displayName = 'Tree.Item'; diff --git a/packages/components/src/components/Tree/components/TreeItem.tsx b/packages/components/src/components/Tree/components/TreeItem.tsx new file mode 100644 index 0000000000..109906d7e2 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeItem.tsx @@ -0,0 +1,14 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type TreeItemProps } from '../types'; + +import { COLLECT_ATTR, useCollectedEntry } from './TreeCollector'; + +export const TreeItem = (props: TreeItemProps) => { + const { key, isCollecting } = useCollectedEntry((parentId) => ({ kind: 'item', parentId, props })); + if (!isCollecting) { + return null; + } + return ; +}; +TreeItem.displayName = 'Tree.Item'; diff --git a/packages/components/src/components/Tree/components/TreeLoading.spec.ts b/packages/components/src/components/Tree/components/TreeLoading.spec.tsx similarity index 59% rename from packages/components/src/components/Tree/components/TreeLoading.spec.ts rename to packages/components/src/components/Tree/components/TreeLoading.spec.tsx index 3311af1336..f435cf974d 100644 --- a/packages/components/src/components/Tree/components/TreeLoading.spec.ts +++ b/packages/components/src/components/Tree/components/TreeLoading.spec.tsx @@ -1,12 +1,14 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ +import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { TreeLoading } from './TreeLoading'; describe('TreeLoading', () => { - it('renders null', () => { - expect(TreeLoading()).toBe(null); + it('renders nothing outside a Tree collect pass', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); }); it('declares displayName="Tree.Loading"', () => { diff --git a/packages/components/src/components/Tree/components/TreeLoading.ts b/packages/components/src/components/Tree/components/TreeLoading.ts deleted file mode 100644 index 52d8eda338..0000000000 --- a/packages/components/src/components/Tree/components/TreeLoading.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* (c) Copyright Frontify Ltd., all rights reserved. */ - -export const TreeLoading = (): null => null; -TreeLoading.displayName = 'Tree.Loading'; diff --git a/packages/components/src/components/Tree/components/TreeLoading.tsx b/packages/components/src/components/Tree/components/TreeLoading.tsx new file mode 100644 index 0000000000..7348bd0855 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeLoading.tsx @@ -0,0 +1,9 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { useCollectedEntry } from './TreeCollector'; + +export const TreeLoading = (): null => { + useCollectedEntry((parentId) => ({ kind: 'loading', parentId })); + return null; +}; +TreeLoading.displayName = 'Tree.Loading'; diff --git a/packages/components/src/components/Tree/components/TreeRoot.tsx b/packages/components/src/components/Tree/components/TreeRoot.tsx index b77b5c43d9..01faddfc47 100644 --- a/packages/components/src/components/Tree/components/TreeRoot.tsx +++ b/packages/components/src/components/Tree/components/TreeRoot.tsx @@ -1,7 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { AssistiveTreeDescription } from '@headless-tree/react'; -import { Fragment, useId, useMemo, type ReactNode } from 'react'; +import { Fragment, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from '#/hooks/useTranslation'; @@ -13,6 +13,7 @@ import { computeLoadingInsertions } from '../utils/computeLoadingInsertions'; import { isNoopDrop } from '../utils/isNoopDrop'; import { parseChildren } from '../utils/parseChildren'; +import { buildCollectedItems, type CollectStore, TreeCollector } from './TreeCollector'; import { TreeDragLine } from './TreeDragLine'; import { TreeLoadingRow } from './TreeLoadingRow'; import { TreeRow } from './TreeRow'; @@ -64,7 +65,23 @@ export const TreeRoot = ({ }: TreeRootProps) => { const { t } = useTranslation(); const rowHintId = useId(); - const { items, parentIsLoading: rootIsLoading } = useMemo(() => parseChildren(children), [children]); + const parsed = useMemo(() => parseChildren(children), [children]); + // Rows inside custom components are found only by rendering `children` in a hidden + // collect pass; the static parse stays the first-render (and server) value. + const collectRef = useRef(null); + const [flushTick, setFlushTick] = useState(0); + const [collected, setCollected] = useState(null); + const store = useMemo( + () => ({ entries: new Map(), requestFlush: () => setFlushTick((tick) => tick + 1) }), + [], + ); + useLayoutEffect(() => { + if (!parsed.hasForeignRows || !collectRef.current) { + return; + } + setCollected(buildCollectedItems(collectRef.current, store.entries)); + }, [flushTick, parsed.hasForeignRows, store]); + const { items, parentIsLoading: rootIsLoading } = parsed.hasForeignRows && collected ? collected : parsed; const tree = useTreeController({ items, onChange, @@ -94,7 +111,7 @@ export const TreeRoot = ({ .filter(Boolean) .join(' '); - return ( + const treeElement = (
{rowHint && ( @@ -135,5 +152,17 @@ export const TreeRoot = ({ )}
); + + if (!parsed.hasForeignRows) { + return treeElement; + } + return ( + <> + + {children} + + {treeElement} + + ); }; TreeRoot.displayName = 'TreeRoot'; diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx new file mode 100644 index 0000000000..3ab7511736 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -0,0 +1,122 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { expect, test } from '@playwright/experimental-ct-react'; + +import { Tree } from '../Tree'; + +import { + AllWrapperRoot, + ContextWrapperTree, + LazyWrapperTree, + MixedRoot, + MultiSelectWrapperTree, + NestedWrapperTree, + ToggleWrapperTree, +} from './testutils/WrapperFixtures'; + +const rowNames = (component: { getByRole: (role: 'treeitem') => { allInnerTexts: () => Promise } }) => + component.getByRole('treeitem').allInnerTexts(); + +test.describe('Tree rows inside custom components', () => { + test('keeps document order when wrapped rows sit between direct rows', async ({ mount }) => { + const component = await mount(); + await expect(component.getByRole('treeitem')).toHaveCount(4); + expect((await rowNames(component)).map((text) => text.trim())).toEqual(['A', 'B', 'C', 'D']); + }); + + test('shows wrapped rows in the same task as direct rows (no intermediate paint)', async ({ mount, page }) => { + await page.evaluate(() => { + const win = window as unknown as { firstSeen?: number }; + const observer = new MutationObserver(() => { + const count = document.querySelectorAll('[role="treeitem"]').length; + if (count > 0 && win.firstSeen === undefined) { + win.firstSeen = count; + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + await mount(); + expect(await page.evaluate(() => (window as unknown as { firstSeen?: number }).firstSeen)).toBe(4); + }); + + test('makes the first wrapped row the tab stop when every root row is wrapped', async ({ mount, page }) => { + const component = await mount(); + await component.getByRole('button', { name: 'before' }).focus(); + await page.keyboard.press('Tab'); + await expect(component.getByRole('treeitem', { name: 'X' })).toBeFocused(); + }); + + test('loads folder children owned by a component with its own state', async ({ mount }) => { + const component = await mount(); + await component.getByRole('treeitem', { name: /Lazy/ }).click(); + await expect(component.getByText('Loading')).toBeVisible(); + await expect(component.getByRole('treeitem', { name: 'lazy-1' })).toBeVisible(); + await expect(component.getByRole('treeitem', { name: 'lazy-2' })).toHaveAttribute('aria-level', '2'); + await expect(component.getByText('Loading')).toHaveCount(0); + }); + + test('nests folders declared through recursive components', async ({ mount }) => { + const component = await mount(); + await expect(component.getByRole('treeitem', { name: 'deep-leaf' })).toHaveAttribute('aria-level', '4'); + await expect(component.getByRole('treeitem', { name: 'after' })).toHaveAttribute('aria-level', '1'); + expect((await rowNames(component)).map((text) => text.trim())).toEqual([ + 'level-3', + 'level-2', + 'level-1', + 'deep-leaf', + 'after', + ]); + }); + + test('follows a wrapper re-rendering on its own and unmounting', async ({ mount }) => { + const component = await mount(); + await expect(component.getByRole('treeitem', { name: 'g3' })).toBeVisible(); + await component.getByRole('button', { name: 'toggle' }).click(); + await expect(component.getByRole('treeitem')).toHaveCount(1); + await expect(component.getByRole('treeitem', { name: 'static' })).toBeVisible(); + }); + + test('cascades a folder checkbox to wrapped children', async ({ mount }) => { + const component = await mount(); + await component.getByRole('treeitem', { name: /parent/ }).getByRole('checkbox').click(); + await expect(component.getByTestId('selected')).toHaveText('c1,c2'); + await expect(component.getByRole('treeitem', { name: 'c1' })).toHaveAttribute('aria-checked', 'true'); + }); + + test('renders row parts under Tree.Root, outside a wrapper context provider', async ({ mount }) => { + const component = await mount(); + await expect(component.getByRole('treeitem', { name: /ctx-row/ }).getByTestId('ctx')).toHaveText('outside'); + }); +}); + +test.describe('Tree without custom components', () => { + test('renders no collect pass', async ({ mount, page }) => { + await mount( + + + One + + , + ); + await expect(page.locator('[data-tree-collect-key]')).toHaveCount(0); + await expect(page.locator('[aria-hidden="true"][hidden]')).toHaveCount(0); + }); + + test('opens fragments statically', async ({ mount, page }) => { + const component = await mount( + + <> + + One + + + Two + + + , + ); + await expect(component.getByRole('treeitem')).toHaveCount(2); + await expect(page.locator('[data-tree-collect-key]')).toHaveCount(0); + }); +}); diff --git a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx new file mode 100644 index 0000000000..15fa63ee08 --- /dev/null +++ b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx @@ -0,0 +1,187 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { createContext, useContext, useEffect, useState } from 'react'; + +import { Tree } from '../../Tree'; + +const Leaf = ({ + id, + isSelected, + onSelectChange, +}: { + id: string; + isSelected?: boolean; + onSelectChange?: (value: boolean) => void; +}) => ( + + {id} + +); + +const Pair = ({ a, b }: { a: string; b: string }) => ( + <> + + + +); + +export const MixedRoot = () => ( + + + A + + + + D + + +); + +export const AllWrapperRoot = () => ( + <> + + + + + +); + +const FolderContents = ({ folderId }: { folderId: string }) => { + const [ids, setIds] = useState(null); + useEffect(() => { + const timer = setTimeout(() => setIds([`${folderId}-1`, `${folderId}-2`]), 400); + return () => clearTimeout(timer); + }, [folderId]); + if (!ids) { + return ; + } + return ids.map((id) => ); +}; + +export const LazyWrapperTree = () => { + const [isExpanded, setIsExpanded] = useState(false); + return ( + + + + Lazy + + {isExpanded && } + + + ); +}; + +const SubTree = ({ depth }: { depth: number }) => { + if (depth === 0) { + return ; + } + return ( + + + {`level-${depth}`} + + + + ); +}; + +export const NestedWrapperTree = () => ( + + + + +); + +const GrowingWrapper = () => { + const [ids, setIds] = useState(['g1', 'g2']); + useEffect(() => { + const timer = setTimeout(() => setIds((prev) => [...prev, 'g3']), 400); + return () => clearTimeout(timer); + }, []); + return ids.map((id) => ); +}; + +export const ToggleWrapperTree = () => { + const [isShown, setIsShown] = useState(true); + return ( + <> + + + + {isShown && } + + + ); +}; + +const SelectableChildren = ({ + selected, + onToggle, +}: { + selected: Set; + onToggle: (id: string, value: boolean) => void; +}) => + ['c1', 'c2'].map((id) => ( + onToggle(id, value)} /> + )); + +export const MultiSelectWrapperTree = () => { + const [selected, setSelected] = useState>(() => new Set()); + const onToggle = (id: string, value: boolean) => + setSelected((prev) => { + const next = new Set(prev); + if (value) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + return ( + <> + {[...selected].sort().join(',')} + + + + parent + + + + + + ); +}; + +const ProbeContext = createContext('outside'); +const ContextReader = () => {useContext(ProbeContext)}; + +export const ContextWrapperTree = () => ( + + + + ctx-row + + + + + + +); + +const Chunk = ({ start, size }: { start: number; size: number }) => + Array.from({ length: size }, (_, offset) => ); + +export const BigTree = ({ count, wrapped }: { count: number; wrapped: boolean }) => { + const chunkSize = 100; + const chunks = Array.from({ length: count / chunkSize }, (_, index) => index * chunkSize); + return ( + + {wrapped + ? chunks.map((start) => ) + : chunks.flatMap((start) => Chunk({ start, size: chunkSize }))} + + ); +}; diff --git a/packages/components/src/components/Tree/hooks/useTreeController.ts b/packages/components/src/components/Tree/hooks/useTreeController.ts index 05eabc8b5a..d3992ce71b 100644 --- a/packages/components/src/components/Tree/hooks/useTreeController.ts +++ b/packages/components/src/components/Tree/hooks/useTreeController.ts @@ -263,7 +263,8 @@ export const useTreeController = ({ getItem: (itemId) => itemsById.get(itemId) as TreeItemData, getChildren: (itemId) => itemsById.get(itemId)?.children ?? [], }, - state: { ...treeState, renamingItem, renamingValue }, + // `null`, not `undefined`, lets headless-tree fall back to the first row as tab stop. + state: { ...treeState, focusedItem: treeState.focusedItem ?? null, renamingItem, renamingValue }, setExpandedItems, setCheckedItems, setSelectedItems: multiSelect ? undefined : setSelectedItems, diff --git a/packages/components/src/components/Tree/utils/parseChildren.ts b/packages/components/src/components/Tree/utils/parseChildren.ts index 76359271f7..23a43537a6 100644 --- a/packages/components/src/components/Tree/utils/parseChildren.ts +++ b/packages/components/src/components/Tree/utils/parseChildren.ts @@ -1,6 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { Children, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'; import { ROOT_ID } from '../constants'; import { @@ -22,6 +22,11 @@ export type ParsedChildren = { * for `TreeRoot`'s root loading row. */ parentIsLoading: boolean; + /** + * `true` when a row position holds an element that is not a Tree part (a custom + * component or host element). Such rows can only be found by rendering them. + */ + hasForeignRows: boolean; }; const hasDisplayName = @@ -36,7 +41,15 @@ const isTreeActionElement = hasDisplayName('Tree.Action'); const isTreeDecoratorElement = hasDisplayName('Tree.Decorator'); const isTreeIconElement = hasDisplayName('Tree.Icon'); const isTreeLabelElement = hasDisplayName('Tree.Label'); -const isTreeFolderHeaderElement = hasDisplayName('Tree.FolderHeader'); +export const isTreeFolderHeaderElement = hasDisplayName('Tree.FolderHeader'); + +// `Children.toArray` keeps fragments as single elements, so open them here. +export const flattenChildren = (children: ReactNode): ReactNode[] => + Children.toArray(children).flatMap((child) => + isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment + ? flattenChildren(child.props.children) + : [child], + ); type RowParts = { /** Text from ``; empty string when the part is missing. */ @@ -98,7 +111,7 @@ const sharedRowData = (props: TreeItemProps | TreeFolderProps, parentId: string) isDisabled: props.isDisabled, }); -const toItemData = (props: TreeItemProps, parentId: string): TreeItemData => { +export const toItemData = (props: TreeItemProps, parentId: string): TreeItemData => { const { name, icon, decorator, action } = extractRowParts(props.children); return { ...sharedRowData(props, parentId), @@ -115,29 +128,40 @@ type FolderParse = { descendants: TreeItemData[]; }; -const toFolderData = (props: TreeFolderProps, parentId: string): FolderParse => { +export const getFolderRows = (children: ReactNode): ReactNode[] => + flattenChildren(children).filter((child) => !(isValidElement(child) && isTreeFolderHeaderElement(child))); + +export const toFolderRowData = ( + props: TreeFolderProps, + parentId: string, + children: string[], + isLoading: boolean, +): TreeItemData => { // Row parts live in ``; everything else is nested rows. - const headerElement = Children.toArray(props.children).filter(isValidElement).find(isTreeFolderHeaderElement); + const headerElement = flattenChildren(props.children).filter(isValidElement).find(isTreeFolderHeaderElement); const { name, icon, decorator, action } = extractRowParts(headerElement?.props.children); - const rows = Children.toArray(props.children).filter( - (child) => !(isValidElement(child) && isTreeFolderHeaderElement(child)), - ); - const nested = parseChildren(rows, props.id); return { - folder: { - ...sharedRowData(props, parentId), - name, - isFolder: true, - children: nested.items.filter((item) => item.parentId === props.id).map((item) => item.id), - isExpanded: props.isExpanded, - onExpandChange: props.onExpandChange, - icon, - decorator, - actions: action, - isLoading: nested.parentIsLoading, - accepts: props.accepts, - }, + ...sharedRowData(props, parentId), + name, + isFolder: true, + children, + isExpanded: props.isExpanded, + onExpandChange: props.onExpandChange, + icon, + decorator, + actions: action, + isLoading, + accepts: props.accepts, + }; +}; + +const toFolderData = (props: TreeFolderProps, parentId: string): FolderParse & { hasForeignRows: boolean } => { + const nested = parseChildren(getFolderRows(props.children), props.id); + const childIds = nested.items.filter((item) => item.parentId === props.id).map((item) => item.id); + return { + folder: toFolderRowData(props, parentId, childIds, nested.parentIsLoading), descendants: nested.items, + hasForeignRows: nested.hasForeignRows, }; }; @@ -151,8 +175,9 @@ const toFolderData = (props: TreeFolderProps, parentId: string): FolderParse => export const parseChildren = (children: ReactNode, parentId: string = ROOT_ID): ParsedChildren => { const items: TreeItemData[] = []; let parentIsLoading = false; + let hasForeignRows = false; - for (const child of Children.toArray(children)) { + for (const child of flattenChildren(children)) { if (!isValidElement(child)) { continue; } @@ -165,10 +190,13 @@ export const parseChildren = (children: ReactNode, parentId: string = ROOT_ID): continue; } if (isTreeFolderElement(child)) { - const { folder, descendants } = toFolderData(child.props, parentId); + const { folder, descendants, hasForeignRows: nestedForeign } = toFolderData(child.props, parentId); items.push(folder, ...descendants); + hasForeignRows ||= nestedForeign; + continue; } + hasForeignRows = true; } - return { items, parentIsLoading }; + return { items, parentIsLoading, hasForeignRows }; }; From c485bfc3fb0c0ee1f467e3fd59a0abe121175aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 10:41:14 +0200 Subject: [PATCH 02/10] feat(Tree): document rows in custom components, add story and specs --- .changeset/tree-rows-in-custom-components.md | 6 ++ .../src/components/Tree/Tree.metadata.json | 2 +- .../src/components/Tree/Tree.stories.tsx | 60 +++++++++++++++- .../Tree/components/TreeCollector.spec.tsx | 71 +++++++++++++++++++ .../Tree/components/TreeCollector.tsx | 30 ++++---- .../components/Tree/components/TreeRoot.tsx | 6 ++ .../Tree/components/TreeWrapper.ct.tsx | 17 +++-- .../components/testutils/WrapperFixtures.tsx | 16 +---- 8 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 .changeset/tree-rows-in-custom-components.md create mode 100644 packages/components/src/components/Tree/components/TreeCollector.spec.tsx diff --git a/.changeset/tree-rows-in-custom-components.md b/.changeset/tree-rows-in-custom-components.md new file mode 100644 index 0000000000..1a598dc66e --- /dev/null +++ b/.changeset/tree-rows-in-custom-components.md @@ -0,0 +1,6 @@ +--- +"@frontify/fondue-components": minor +"@frontify/fondue": minor +--- + +feat(Tree): allow rows inside custom components and fragments diff --git a/packages/components/src/components/Tree/Tree.metadata.json b/packages/components/src/components/Tree/Tree.metadata.json index 8cdd4a6947..023266d7c8 100644 --- a/packages/components/src/components/Tree/Tree.metadata.json +++ b/packages/components/src/components/Tree/Tree.metadata.json @@ -1,7 +1,7 @@ { "category": "navigation", "description": "A hierarchical list of items and nested folders. Supports keyboard navigation, single-select with a highlighted row or multi-select with cascading folder checkboxes (folders without loaded children are checkable as their own entity), optional drag-and-drop reordering, inline renaming, and async loading rows.", - "instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: (plain text, required) plus optional (rows render no icon otherwise), for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside , and every other folder child is a nested row. Render as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.", + "instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: (plain text, required) plus optional (rows render no icon otherwise), for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside , and every other folder child is a nested row. Rows may also sit inside your own components or fragments, with three limits: rows rendered through a portal inside a custom component are not found; row parts (Icon, Decorator, Action) render under Tree.Root, so they do not see a context provider placed inside the custom component; when server-rendered, custom components add React useLayoutEffect warnings. Render as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.", "name": "Tree", "relatedComponents": ["Accordion", "OrderableList", "Dropdown"], "storyFilePaths": ["src/components/Tree/Tree.stories.tsx"], diff --git a/packages/components/src/components/Tree/Tree.stories.tsx b/packages/components/src/components/Tree/Tree.stories.tsx index 6071cb633e..6974f2ca9b 100644 --- a/packages/components/src/components/Tree/Tree.stories.tsx +++ b/packages/components/src/components/Tree/Tree.stories.tsx @@ -9,7 +9,7 @@ import { IconTrashBin, } from '@frontify/fondue-icons'; import { type Meta, type StoryObj } from '@storybook/react-vite'; -import { useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { action } from 'storybook/actions'; import { Badge, Button, Dropdown } from '#/index'; @@ -1272,6 +1272,64 @@ export const LoadMore: Story = { }, }; +const FolderContents = ({ folderId }: { folderId: string }) => { + const [names, setNames] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => setNames(['q1.pdf', 'q2.pdf', 'q3.pdf']), 600); + return () => clearTimeout(timer); + }, []); + + if (!names) { + return ; + } + return ( + <> + {names.map((name) => ( + + + + + {name} + + ))} + + ); +}; + +export const RowsInCustomComponents: Story = { + parameters: { + docs: { + description: { + story: + 'Rows may sit inside your own components and fragments, so a component can own the data for a folder ' + + 'and render its rows, as `FolderContents` does here with its own fetch state. Limits: rows rendered ' + + 'through a portal inside a custom component are not found. Row parts (`Tree.Icon`, `Tree.Decorator`, ' + + '`Tree.Action`) render under `Tree.Root`, so they do not see a context provider placed inside the ' + + 'custom component. When the tree is server-rendered, custom components add React `useLayoutEffect` ' + + 'warnings.', + }, + }, + }, + render: (args) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + + + + + + Reports + + {isExpanded && } + + + ); + }, +}; + export const DisabledRows: Story = { parameters: { docs: { diff --git a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx new file mode 100644 index 0000000000..fca93e7b75 --- /dev/null +++ b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx @@ -0,0 +1,71 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { render, screen } from '@testing-library/react'; +import { Profiler } from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { Tree } from '../Tree'; + +import { COLLECT_ATTR } from './TreeCollector'; + +const Leaf = ({ id }: { id: string }) => ( + + {id} + +); + +const Pair = () => ( + <> + + + +); + +describe('TreeCollector', () => { + it('renders to a string without throwing when rows sit inside a custom component', () => { + expect(() => + renderToString( + + + , + ), + ).not.toThrow(); + }); + + it('adds no commit on re-render when no custom components are used', () => { + let commits = 0; + // A fresh element each call, so React cannot bail out on referential equality. + const ui = () => ( + { + commits += 1; + }} + > + + + A + + + + ); + const { rerender } = render(ui()); + commits = 0; + rerender(ui()); + expect(commits).toBe(1); + }); + + it('collects rows of custom components after mount', () => { + render( + + + A + + + , + ); + expect(screen.getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual(['A', 'B', 'C']); + expect(document.querySelectorAll(`[${COLLECT_ATTR}]`)).toHaveLength(3); + }); +}); diff --git a/packages/components/src/components/Tree/components/TreeCollector.tsx b/packages/components/src/components/Tree/components/TreeCollector.tsx index dcf1f2d0e8..e126675394 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.tsx @@ -1,6 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { createContext, useContext, useId, useLayoutEffect, type ReactNode } from 'react'; +import { createContext, useContext, useId, useLayoutEffect, type ReactNode, type RefObject } from 'react'; import { ROOT_ID } from '../constants'; import { type TreeFolderProps, type TreeItemData, type TreeItemProps } from '../types'; @@ -13,36 +13,40 @@ type CollectedEntry = | { kind: 'folder'; parentId: string; props: TreeFolderProps } | { kind: 'loading'; parentId: string }; +type RowEntry = Exclude; + export type CollectStore = { entries: Map; requestFlush: () => void; }; const TreeCollectContext = createContext(null); +TreeCollectContext.displayName = 'TreeCollectContext'; export const TreeParentContext = createContext(ROOT_ID); +TreeParentContext.displayName = 'TreeParentContext'; /** - * Registers a row rendered inside the collect pass. Returns the marker key, or `null` - * outside a collect pass, where Tree parts stay inert markers read by `parseChildren`. + * Registers a row rendered inside the collect pass. Returns the marker key and whether a + * collect pass is active; outside one, Tree parts stay inert markers read by `parseChildren`. */ export const useCollectedEntry = ( build: (parentId: string) => CollectedEntry, ): { key: string; isCollecting: boolean } => { const store = useContext(TreeCollectContext); const parentId = useContext(TreeParentContext); - const key = useId(); + const markerId = useId(); useLayoutEffect(() => { if (!store) { return; } - store.entries.set(key, build(parentId)); + store.entries.set(markerId, build(parentId)); store.requestFlush(); return () => { - store.entries.delete(key); + store.entries.delete(markerId); store.requestFlush(); }; }); - return { key, isCollecting: store !== null }; + return { key: markerId, isCollecting: store !== null }; }; export const TreeCollector = ({ @@ -51,7 +55,7 @@ export const TreeCollector = ({ children, }: { store: CollectStore; - containerRef: React.RefObject; + containerRef: RefObject; children: ReactNode; }) => ( ); - if (!parsed.hasForeignRows) { - return treeElement; - } + // One stable shape, so mounting the collect pass never remounts the tree (and its focus). return ( <> - - {children} - + {parsed.hasForeignRows && ( + + {children} + + )} {treeElement} ); diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx index beb198d68e..184b93ae06 100644 --- a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -57,6 +57,19 @@ test.describe('Tree rows inside custom components', () => { await expect(component.getByText('Loading')).toHaveCount(0); }); + test('keeps keyboard focus on the folder when expanding mounts its first custom component', async ({ + mount, + page, + }) => { + const component = await mount(); + const folder = component.getByRole('treeitem', { name: /Lazy/ }); + await folder.focus(); + await page.keyboard.press('ArrowRight'); + await expect(component.getByRole('treeitem', { name: 'lazy-1' })).toBeVisible(); + await expect(folder).toBeFocused(); + await expect(folder).toHaveAttribute('aria-expanded', 'true'); + }); + test('nests folders declared through recursive components', async ({ mount }) => { const component = await mount(); await expect(component.getByRole('treeitem', { name: 'deep-leaf' })).toHaveAttribute('aria-level', '4'); From b4f343de800bbf367e80a56fedbd5319301be24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 10:50:51 +0200 Subject: [PATCH 04/10] fix(Tree): re-collect rows when wrapped rows reorder without re-rendering --- .../Tree/components/TreeCollector.spec.tsx | 30 ++++++++++++- .../Tree/components/TreeCollector.tsx | 23 +++++++--- .../Tree/components/TreeWrapper.ct.tsx | 19 ++++++++- .../components/testutils/WrapperFixtures.tsx | 42 ++++++++++++++++++- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx index fca93e7b75..e66b31e1bb 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx @@ -1,9 +1,9 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { render, screen } from '@testing-library/react'; -import { Profiler } from 'react'; +import { Profiler, type ReactNode } from 'react'; import { renderToString } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Tree } from '../Tree'; @@ -56,6 +56,32 @@ describe('TreeCollector', () => { expect(commits).toBe(1); }); + it('does not reuse rows from an earlier collect pass', () => { + const Wrap = ({ children }: { children: ReactNode }) => <>{children}; + const onRenamingChange = vi.fn(); + const ui = (wrapped: boolean, isRenaming: boolean) => { + const row = ( + {}} isRenaming={isRenaming} onRenamingChange={onRenamingChange}> + x + + ); + if (wrapped) { + return ( + + {row} + + ); + } + return {row}; + }; + const { rerender } = render(ui(true, true)); + rerender(ui(false, false)); + expect(onRenamingChange).toHaveBeenCalledWith(false); + onRenamingChange.mockClear(); + rerender(ui(true, false)); + expect(onRenamingChange).not.toHaveBeenCalled(); + }); + it('collects rows of custom components after mount', () => { render( diff --git a/packages/components/src/components/Tree/components/TreeCollector.tsx b/packages/components/src/components/Tree/components/TreeCollector.tsx index e126675394..a64edca216 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.tsx @@ -57,11 +57,24 @@ export const TreeCollector = ({ store: CollectStore; containerRef: RefObject; children: ReactNode; -}) => ( - -); +}) => { + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + // Rows reordered with unchanged props move their markers without re-rendering. + const observer = new MutationObserver(() => store.requestFlush()); + observer.observe(container, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [containerRef, store]); + + return ( + + ); +}; /** Builds the flat item list from the markers' document order. */ export const buildCollectedItems = (container: HTMLElement, entries: Map): ParsedChildren => { diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx index 184b93ae06..06f8e0ade4 100644 --- a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -7,10 +7,12 @@ import { Tree } from '../Tree'; import { AllWrapperRoot, ContextWrapperTree, + DeferredMixedRoot, LazyWrapperTree, MixedRoot, MultiSelectWrapperTree, NestedWrapperTree, + ReorderWrapperTree, ToggleWrapperTree, } from './testutils/WrapperFixtures'; @@ -26,6 +28,7 @@ test.describe('Tree rows inside custom components', () => { }); test('shows wrapped rows in the same task as direct rows (no intermediate paint)', async ({ mount, page }) => { + const component = await mount(); await page.evaluate(() => { const win = window as unknown as { firstSeen?: number }; const observer = new MutationObserver(() => { @@ -37,8 +40,8 @@ test.describe('Tree rows inside custom components', () => { }); observer.observe(document.body, { childList: true, subtree: true }); }); - await mount(); - expect(await page.evaluate(() => (window as unknown as { firstSeen?: number }).firstSeen)).toBe(4); + await component.getByRole('button', { name: 'show' }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { firstSeen?: number }).firstSeen)).toBe(4); }); test('makes the first wrapped row the tab stop when every root row is wrapped', async ({ mount, page }) => { @@ -86,6 +89,18 @@ test.describe('Tree rows inside custom components', () => { await expect(component.getByRole('treeitem', { name: 'static' })).toBeVisible(); }); + test('follows a reorder of wrapped rows with unchanged props', async ({ mount }) => { + const component = await mount(); + await expect(component.getByRole('treeitem')).toHaveCount(2); + await component.getByRole('button', { name: 'reverse' }).click(); + await expect + .poll(async () => { + const names = await rowNames(component); + return names.map((text) => text.trim()); + }) + .toEqual(['b', 'a']); + }); + test('cascades a folder checkbox to wrapped children', async ({ mount }) => { const component = await mount(); await component diff --git a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx index 575d1690c3..1b41a18745 100644 --- a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx +++ b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx @@ -1,6 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { createContext, useContext, useEffect, useState } from 'react'; +import { Children, type ReactNode, createContext, useContext, useEffect, useState } from 'react'; import { Tree } from '../../Tree'; @@ -37,6 +37,18 @@ export const MixedRoot = () => ( ); +export const DeferredMixedRoot = () => { + const [isShown, setIsShown] = useState(false); + return ( + <> + + {isShown && } + + ); +}; + export const AllWrapperRoot = () => ( <> @@ -155,6 +167,34 @@ export const MultiSelectWrapperTree = () => { ); }; +const Reverse = ({ children, isReversed }: { children: ReactNode; isReversed: boolean }) => { + const items = Children.toArray(children); + return isReversed ? items.reverse() : items; +}; + +const reorderItems = [ + + a + , + + b + , +]; + +export const ReorderWrapperTree = () => { + const [isReversed, setIsReversed] = useState(false); + return ( + <> + + + {reorderItems} + + + ); +}; + const ProbeContext = createContext('outside'); ProbeContext.displayName = 'ProbeContext'; const ContextReader = () => {useContext(ProbeContext)}; From 4969676cd6b2927e0bd12c8518dd79226bc15f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 10:56:05 +0200 Subject: [PATCH 05/10] fix(Tree): flush re-collected rows before paint after a non-event reorder --- .../Tree/components/TreeCollector.tsx | 6 ++-- .../Tree/components/TreeWrapper.ct.tsx | 34 +++++++++++++++++++ .../components/testutils/WrapperFixtures.tsx | 21 +++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/Tree/components/TreeCollector.tsx b/packages/components/src/components/Tree/components/TreeCollector.tsx index a64edca216..f4883d957f 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.tsx @@ -1,6 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { createContext, useContext, useId, useLayoutEffect, type ReactNode, type RefObject } from 'react'; +import { flushSync } from 'react-dom'; import { ROOT_ID } from '../constants'; import { type TreeFolderProps, type TreeItemData, type TreeItemProps } from '../types'; @@ -63,8 +64,9 @@ export const TreeCollector = ({ if (!container) { return; } - // Rows reordered with unchanged props move their markers without re-rendering. - const observer = new MutationObserver(() => store.requestFlush()); + // Rows reordered with unchanged props move their markers without re-rendering; flush before paint. + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + const observer = new MutationObserver(() => flushSync(() => store.requestFlush())); observer.observe(container, { childList: true, subtree: true }); return () => observer.disconnect(); }, [containerRef, store]); diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx index 06f8e0ade4..47757276bc 100644 --- a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -13,6 +13,7 @@ import { MultiSelectWrapperTree, NestedWrapperTree, ReorderWrapperTree, + TimedReorderWrapperTree, ToggleWrapperTree, } from './testutils/WrapperFixtures'; @@ -101,6 +102,39 @@ test.describe('Tree rows inside custom components', () => { .toEqual(['b', 'a']); }); + test('paints a timer-driven reorder of wrapped rows without a stale frame', async ({ mount, page }) => { + const component = await mount(); + await expect(component.getByRole('treeitem')).toHaveCount(2); + await page.evaluate(() => { + const win = window as unknown as { frames_?: string[] }; + const frames: string[] = []; + win.frames_ = frames; + document.querySelector('button')!.click(); + const tick = () => { + const value = [...document.querySelectorAll('[role="treeitem"]')] + .map((el) => el.textContent?.trim()) + .join(','); + if (document.querySelector('[data-order="b,a"]')) { + frames.push(value); + } + if (value !== 'b,a' && frames.length < 200) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + await expect + .poll(() => + page.evaluate(() => { + const frames = (window as unknown as { frames_?: string[] }).frames_ ?? []; + return frames[frames.length - 1]; + }), + ) + .toBe('b,a'); + const frames = await page.evaluate(() => (window as unknown as { frames_?: string[] }).frames_ ?? []); + expect(frames.filter((value) => value === 'a,b')).toHaveLength(0); + }); + test('cascades a folder checkbox to wrapped children', async ({ mount }) => { const component = await mount(); await component diff --git a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx index 1b41a18745..ccf96d5b0b 100644 --- a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx +++ b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx @@ -169,7 +169,12 @@ export const MultiSelectWrapperTree = () => { const Reverse = ({ children, isReversed }: { children: ReactNode; isReversed: boolean }) => { const items = Children.toArray(children); - return isReversed ? items.reverse() : items; + return ( + <> + + {isReversed ? items.reverse() : items} + + ); }; const reorderItems = [ @@ -195,6 +200,20 @@ export const ReorderWrapperTree = () => { ); }; +export const TimedReorderWrapperTree = () => { + const [isReversed, setIsReversed] = useState(false); + return ( + <> + + + {reorderItems} + + + ); +}; + const ProbeContext = createContext('outside'); ProbeContext.displayName = 'ProbeContext'; const ContextReader = () => {useContext(ProbeContext)}; From 61a1a869a53e19f570c384c65cc0fe57f6fe205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 11:03:37 +0200 Subject: [PATCH 06/10] fix(Tree): keep fragment row keys distinct and ignore misplaced row parts --- .../Tree/components/TreeCollector.spec.tsx | 47 +++++++++++++++++++ .../components/Tree/components/TreeFolder.tsx | 3 +- .../Tree/utils/parseChildren.spec.tsx | 18 +++++++ .../components/Tree/utils/parseChildren.ts | 10 ++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx index e66b31e1bb..c4e74dedd2 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx @@ -94,4 +94,51 @@ describe('TreeCollector', () => { expect(screen.getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual(['A', 'B', 'C']); expect(document.querySelectorAll(`[${COLLECT_ATTR}]`)).toHaveLength(3); }); + + it('keeps fragment rows of a folder distinct in the collect pass', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ui = (showX: boolean) => ( + + + + + f + + {showX && ( + + x + + )} + <> + + a + + + a2 + + + <> + + b + + + b2 + + + + + ); + const { rerender } = render(ui(true)); + rerender(ui(false)); + expect(screen.getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual([ + 'z', + 'f', + 'a', + 'a2', + 'b', + 'b2', + ]); + expect(spy.mock.calls.some((call) => String(call[0]).includes('same key'))).toBe(false); + spy.mockRestore(); + }); }); diff --git a/packages/components/src/components/Tree/components/TreeFolder.tsx b/packages/components/src/components/Tree/components/TreeFolder.tsx index fa1e692b78..ea4a69fd7e 100644 --- a/packages/components/src/components/Tree/components/TreeFolder.tsx +++ b/packages/components/src/components/Tree/components/TreeFolder.tsx @@ -1,7 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { type TreeFolderProps } from '../types'; -import { getFolderRows } from '../utils/parseChildren'; import { COLLECT_ATTR, TreeParentContext, useCollectedEntry } from './TreeCollector'; @@ -13,7 +12,7 @@ export const TreeFolder = (props: TreeFolderProps) => { return ( <> - {getFolderRows(props.children)} + {props.children} ); }; diff --git a/packages/components/src/components/Tree/utils/parseChildren.spec.tsx b/packages/components/src/components/Tree/utils/parseChildren.spec.tsx index 33c07252e1..18e714e8db 100644 --- a/packages/components/src/components/Tree/utils/parseChildren.spec.tsx +++ b/packages/components/src/components/Tree/utils/parseChildren.spec.tsx @@ -213,4 +213,22 @@ describe('parseChildren', () => { ]); expect(result.items.map((entry) => entry.id)).toEqual(['1']); }); + + it('ignores misplaced Tree parts without flagging foreign rows', () => { + const result = parseChildren([ + stray, + + h + , + + + f + + i + {item('a', 'a')} + , + ]); + expect(result.hasForeignRows).toBe(false); + expect(result.items.map((entry) => entry.id)).toEqual(['f', 'a']); + }); }); diff --git a/packages/components/src/components/Tree/utils/parseChildren.ts b/packages/components/src/components/Tree/utils/parseChildren.ts index 23a43537a6..6b777789cc 100644 --- a/packages/components/src/components/Tree/utils/parseChildren.ts +++ b/packages/components/src/components/Tree/utils/parseChildren.ts @@ -195,6 +195,16 @@ export const parseChildren = (children: ReactNode, parentId: string = ROOT_ID): hasForeignRows ||= nestedForeign; continue; } + if ( + isTreeLabelElement(child) || + isTreeIconElement(child) || + isTreeDecoratorElement(child) || + isTreeActionElement(child) || + isTreeFolderHeaderElement(child) + ) { + // Misplaced row parts never hold rows; ignore them as before. + continue; + } hasForeignRows = true; } From 99da4717606e85b65386241249cb1f9a446c6981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 11:10:03 +0200 Subject: [PATCH 07/10] perf(Tree): flush the collect pass inside the collector and skip covered mutations --- .../Tree/components/TreeCollector.spec.tsx | 84 ++++++++++++++++++- .../Tree/components/TreeCollector.tsx | 38 +++++++-- .../components/Tree/components/TreeRoot.tsx | 26 ++---- 3 files changed, 118 insertions(+), 30 deletions(-) diff --git a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx index c4e74dedd2..85ea00c6f4 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.spec.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.spec.tsx @@ -1,7 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { render, screen } from '@testing-library/react'; -import { Profiler, type ReactNode } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { Profiler, type ReactNode, useEffect, useState } from 'react'; import { renderToString } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; @@ -15,6 +15,41 @@ const Leaf = ({ id }: { id: string }) => ( ); +let treeRowRenders = 0; +vi.mock(import('./TreeRow'), async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TreeRow: (props: Parameters[0]) => { + treeRowRenders += 1; + return mod.TreeRow(props); + }, + }; +}); + +const Row = ({ id }: { id: string }) => ( + + {id} + +); + +const Rows = ({ ids }: { ids: string[] }) => ( + <> + {ids.map((id) => ( + + ))} + +); + +const GrowingRows = () => { + const [ids, setIds] = useState(['a', 'b', 'c']); + useEffect(() => { + const timer = setTimeout(() => setIds((prev) => [...prev, 'd']), 10); + return () => clearTimeout(timer); + }, []); + return ; +}; + const Pair = () => ( <> @@ -141,4 +176,49 @@ describe('TreeCollector', () => { expect(spy.mock.calls.some((call) => String(call[0]).includes('same key'))).toBe(false); spy.mockRestore(); }); + + describe('collect pass render cost', () => { + it('renders rows twice per consumer commit at most', () => { + const ui = () => ( + + + + ); + const { rerender } = render(ui()); + treeRowRenders = 0; + rerender(ui()); + expect(treeRowRenders / 3).toBeLessThanOrEqual(2); + }); + + it('renders rows no more often than the static path when a row is added', () => { + const staticUi = (ids: string[]) => ( + + {ids.map((id) => ( + + {id} + + ))} + + ); + const staticView = render(staticUi(['a', 'b', 'c'])); + treeRowRenders = 0; + staticView.rerender(staticUi(['a', 'b', 'c', 'd'])); + const staticRenders = treeRowRenders; + staticView.unmount(); + + vi.useFakeTimers(); + render( + + + , + ); + treeRowRenders = 0; + act(() => { + vi.runAllTimers(); + }); + expect(screen.getAllByRole('treeitem')).toHaveLength(4); + expect(treeRowRenders).toBeLessThanOrEqual(staticRenders); + vi.useRealTimers(); + }); + }); }); diff --git a/packages/components/src/components/Tree/components/TreeCollector.tsx b/packages/components/src/components/Tree/components/TreeCollector.tsx index f4883d957f..e739e1d899 100644 --- a/packages/components/src/components/Tree/components/TreeCollector.tsx +++ b/packages/components/src/components/Tree/components/TreeCollector.tsx @@ -1,6 +1,6 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { createContext, useContext, useId, useLayoutEffect, type ReactNode, type RefObject } from 'react'; +import { createContext, useContext, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { flushSync } from 'react-dom'; import { ROOT_ID } from '../constants'; @@ -51,14 +51,34 @@ export const useCollectedEntry = ( }; export const TreeCollector = ({ - store, - containerRef, + onCollect, children, }: { - store: CollectStore; - containerRef: RefObject; + onCollect: (parsed: ParsedChildren) => void; children: ReactNode; }) => { + const containerRef = useRef(null); + const observerRef = useRef(null); + const onCollectRef = useRef(onCollect); + const [flushTick, setFlushTick] = useState(0); + const store = useMemo( + () => ({ entries: new Map(), requestFlush: () => setFlushTick((tick) => tick + 1) }), + [], + ); + + useLayoutEffect(() => { + onCollectRef.current = onCollect; + }); + + useLayoutEffect(() => { + if (!containerRef.current) { + return; + } + onCollectRef.current(buildCollectedItems(containerRef.current, store.entries)); + // Mutations this build already covered must not trigger another flush. + observerRef.current?.takeRecords(); + }, [flushTick, store]); + useLayoutEffect(() => { const container = containerRef.current; if (!container) { @@ -68,8 +88,12 @@ export const TreeCollector = ({ // eslint-disable-next-line @eslint-react/dom-no-flush-sync const observer = new MutationObserver(() => flushSync(() => store.requestFlush())); observer.observe(container, { childList: true, subtree: true }); - return () => observer.disconnect(); - }, [containerRef, store]); + observerRef.current = observer; + return () => { + observer.disconnect(); + observerRef.current = null; + }; + }, [store]); return ( ); - // One stable shape, so mounting the collect pass never remounts the tree (and its focus). + // One stable shape with the tree first: mounting the collect pass never remounts it or shifts sibling selectors. return ( <> - {parsed.hasForeignRows && {children}} {treeElement} + {parsed.hasForeignRows && {children}} ); }; diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx index 47757276bc..f5280df0fe 100644 --- a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -26,6 +26,11 @@ test.describe('Tree rows inside custom components', () => { await expect(component.getByRole('treeitem')).toHaveCount(4); const names = await rowNames(component); expect(names.map((text) => text.trim())).toEqual(['A', 'B', 'C', 'D']); + await expect(component.locator('[role="tree"]')).toHaveCount(1); + const isFirst = await component + .locator('[role="tree"]') + .evaluate((el) => el.parentElement?.firstElementChild === el); + expect(isFirst).toBe(true); }); test('shows wrapped rows in the same task as direct rows (no intermediate paint)', async ({ mount, page }) => { From 7a5f2a9bb7580cb1c2e7863e0a84d7fc5c6ebc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 11:14:52 +0200 Subject: [PATCH 09/10] docs(Tree): state every limit of rows inside custom components --- .changeset/tree-rows-in-custom-components.md | 1 + .../components/src/components/Tree/Tree.metadata.json | 2 +- .../components/src/components/Tree/Tree.stories.tsx | 10 +++++----- .../src/components/Tree/components/TreeRoot.tsx | 10 +++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.changeset/tree-rows-in-custom-components.md b/.changeset/tree-rows-in-custom-components.md index 1a598dc66e..2dbfb08daf 100644 --- a/.changeset/tree-rows-in-custom-components.md +++ b/.changeset/tree-rows-in-custom-components.md @@ -4,3 +4,4 @@ --- feat(Tree): allow rows inside custom components and fragments +fix(Tree): the first row is reachable with Tab when rows arrive after mount diff --git a/packages/components/src/components/Tree/Tree.metadata.json b/packages/components/src/components/Tree/Tree.metadata.json index 023266d7c8..0ed08712cb 100644 --- a/packages/components/src/components/Tree/Tree.metadata.json +++ b/packages/components/src/components/Tree/Tree.metadata.json @@ -1,7 +1,7 @@ { "category": "navigation", "description": "A hierarchical list of items and nested folders. Supports keyboard navigation, single-select with a highlighted row or multi-select with cascading folder checkboxes (folders without loaded children are checkable as their own entity), optional drag-and-drop reordering, inline renaming, and async loading rows.", - "instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: (plain text, required) plus optional (rows render no icon otherwise), for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside , and every other folder child is a nested row. Rows may also sit inside your own components or fragments, with three limits: rows rendered through a portal inside a custom component are not found; row parts (Icon, Decorator, Action) render under Tree.Root, so they do not see a context provider placed inside the custom component; when server-rendered, custom components add React useLayoutEffect warnings. Render as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.", + "instructions": "Compose with Tree.Root wrapping Tree.Item leaves and Tree.Folder nodes. Each row needs a stable `id` and declares its content via anatomy parts: (plain text, required) plus optional (rows render no icon otherwise), for passive badges/status icons hugging the label text (clicks bubble to the row; hidden while renaming), and for trailing controls (overflow menu, edit/delete; clicks do not bubble to the row). For items the parts are direct children; for folders they go inside , and every other folder child is a nested row. Rows may also sit inside your own components or fragments, with four limits: and row parts stay direct children of their folder or item; rows rendered through a portal are not found; row parts (Icon, Decorator, Action) render under Tree.Root, so they do not see a context provider placed around rows; when server-rendered, custom components add React useLayoutEffect warnings. Render as a child of a folder (or of Tree.Root for the top-level slot) to show a translated 'Loading…' row while async content is fetched. Pass `multiSelect` for cascading checkboxes and `reorderable` to enable drag-and-drop; gate drops with an `accepts` predicate on Tree.Root or Tree.Folder, and tag draggable rows via the `tags` prop so the predicate can match by category instead of identity. State is controlled: render from the latest `TreeChangeState` and persist it from `onChange`. `isSelected` is the unified selection prop — with `multiSelect` it checks the row's checkbox, without `multiSelect` it highlights the row, and either mode emits the updated flag via `onChange`. In `multiSelect`, a folder with children derives its checkbox from its contents and ignores its own `isSelected`; a folder with no loaded children (empty, or collapsed while lazy-loading) is instead checkable as its own entity — `isSelected` is honored, `onSelectChange` fires on the folder, and it counts as one unit toward ancestor states. When the children of a checked folder load later, the consumer has to carry the selected state over to the new items by passing `isSelected` to all of them. Inline renaming is controlled too: provide `onRename` and flip `isRenaming` (e.g. from a Tree.Action menu) to swap the label for a text input — Enter or blur commits (firing `onRename` and `onChange` with the new name), Escape cancels, and `onRenamingChange(false)` signals the consumer to clear its flag. Per-item `onSelectChange`, `onExpandChange`, and `onMove` callbacks are fanned out from the global `onChange` when individual rows need to react to their own state changing.", "name": "Tree", "relatedComponents": ["Accordion", "OrderableList", "Dropdown"], "storyFilePaths": ["src/components/Tree/Tree.stories.tsx"], diff --git a/packages/components/src/components/Tree/Tree.stories.tsx b/packages/components/src/components/Tree/Tree.stories.tsx index 6974f2ca9b..67c686a01f 100644 --- a/packages/components/src/components/Tree/Tree.stories.tsx +++ b/packages/components/src/components/Tree/Tree.stories.tsx @@ -1303,11 +1303,11 @@ export const RowsInCustomComponents: Story = { description: { story: 'Rows may sit inside your own components and fragments, so a component can own the data for a folder ' + - 'and render its rows, as `FolderContents` does here with its own fetch state. Limits: rows rendered ' + - 'through a portal inside a custom component are not found. Row parts (`Tree.Icon`, `Tree.Decorator`, ' + - '`Tree.Action`) render under `Tree.Root`, so they do not see a context provider placed inside the ' + - 'custom component. When the tree is server-rendered, custom components add React `useLayoutEffect` ' + - 'warnings.', + 'and render its rows, as `FolderContents` does here with its own fetch state. Limits: `Tree.FolderHeader` ' + + 'and row parts stay direct children of their folder or item. Rows rendered through a portal are not ' + + 'found. Row parts (`Tree.Icon`, `Tree.Decorator`, `Tree.Action`) render under `Tree.Root`, so they do ' + + 'not see a context provider placed around rows. When the tree is server-rendered, custom components ' + + 'add React `useLayoutEffect` warnings.', }, }, }, diff --git a/packages/components/src/components/Tree/components/TreeRoot.tsx b/packages/components/src/components/Tree/components/TreeRoot.tsx index 4128931d90..6cbe77da5a 100644 --- a/packages/components/src/components/Tree/components/TreeRoot.tsx +++ b/packages/components/src/components/Tree/components/TreeRoot.tsx @@ -20,10 +20,10 @@ import { TreeRow } from './TreeRow'; export type TreeRootProps = { /** - * Rows may sit inside custom components and fragments. Limits: rows rendered through a - * portal inside a custom component are not found, row parts (Icon, Decorator, Action) do - * not see a context provider placed inside the custom component, and server rendering - * adds React `useLayoutEffect` warnings for custom components. + * Rows may sit inside custom components and fragments. Limits: `Tree.FolderHeader` and row + * parts stay direct children of their folder or item; rows rendered through a portal are not + * found; row parts (Icon, Decorator, Action) do not see a context provider placed around + * rows; server rendering adds React `useLayoutEffect` warnings for custom components. */ children: ReactNode; /** Fires with the full tree state, at most once per user interaction. */ @@ -147,7 +147,7 @@ export const TreeRoot = ({ ); - // One stable shape with the tree first: mounting the collect pass never remounts it or shifts sibling selectors. + // One stable shape with the tree first: mounting the collect pass never remounts it or shifts `:first-child`. return ( <> {treeElement} From 45fd065a1e1c053fbf21399b284a70dcf2c7e245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Costa?= Date: Fri, 9 Oct 2026 11:21:50 +0200 Subject: [PATCH 10/10] fix(Tree): drop the previous collect snapshot when the collect pass ends --- .../components/Tree/components/TreeRoot.tsx | 4 ++ .../Tree/components/TreeWrapper.ct.tsx | 17 +++++++++ .../components/testutils/WrapperFixtures.tsx | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/packages/components/src/components/Tree/components/TreeRoot.tsx b/packages/components/src/components/Tree/components/TreeRoot.tsx index 6cbe77da5a..8c12123abf 100644 --- a/packages/components/src/components/Tree/components/TreeRoot.tsx +++ b/packages/components/src/components/Tree/components/TreeRoot.tsx @@ -75,6 +75,10 @@ export const TreeRoot = ({ // Rows inside custom components are found only by rendering `children` in a hidden // collect pass; the static parse stays the first-render (and server) value. const [collected, setCollected] = useState(null); + // A snapshot from an earlier collect pass must not seed the next one. + if (!parsed.hasForeignRows && collected !== null) { + setCollected(null); + } const { items, parentIsLoading: rootIsLoading } = parsed.hasForeignRows && collected ? collected : parsed; const tree = useTreeController({ items, diff --git a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx index f5280df0fe..284ea24fea 100644 --- a/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx +++ b/packages/components/src/components/Tree/components/TreeWrapper.ct.tsx @@ -8,6 +8,7 @@ import { AllWrapperRoot, ContextWrapperTree, DeferredMixedRoot, + LazyFoldersTree, LazyWrapperTree, MixedRoot, MultiSelectWrapperTree, @@ -79,6 +80,22 @@ test.describe('Tree rows inside custom components', () => { await expect(folder).toHaveAttribute('aria-expanded', 'true'); }); + test('keeps focus when a new folder expands after an earlier collect pass ended', async ({ mount, page }) => { + const component = await mount(); + await component.getByRole('treeitem', { name: /^a$/ }).click(); + await expect(component.getByRole('treeitem', { name: 'a-1' })).toBeVisible(); + await component.getByRole('treeitem', { name: /^a$/ }).click(); + await expect(component.getByRole('treeitem', { name: 'a-1' })).toHaveCount(0); + await component.getByRole('button', { name: 'add b' }).click(); + const folder = component.getByRole('treeitem', { name: /^b$/ }); + await component.getByRole('treeitem', { name: /^a$/ }).focus(); + await page.keyboard.press('ArrowDown'); + await expect(folder).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(component.getByRole('treeitem', { name: 'b-1' })).toBeVisible(); + await expect(folder).toBeFocused(); + }); + test('nests folders declared through recursive components', async ({ mount }) => { const component = await mount(); await expect(component.getByRole('treeitem', { name: 'deep-leaf' })).toHaveAttribute('aria-level', '4'); diff --git a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx index ccf96d5b0b..1fe1ae384a 100644 --- a/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx +++ b/packages/components/src/components/Tree/components/testutils/WrapperFixtures.tsx @@ -84,6 +84,44 @@ export const LazyWrapperTree = () => { ); }; +export const LazyFoldersTree = () => { + const [folderIds, setFolderIds] = useState(['a']); + const [expanded, setExpanded] = useState>(() => new Set()); + const toggle = (id: string, value: boolean) => { + setExpanded((previous) => { + const next = new Set(previous); + if (value) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }; + return ( + <> + + + {folderIds.map((id) => ( + toggle(id, value)} + > + + {id} + + {expanded.has(id) && } + + ))} + + + ); +}; + const SubTree = ({ depth }: { depth: number }) => { if (depth === 0) { return ;