|
| 1 | +--- |
| 2 | +description: Use when answering questions about or modifying the Collections system used by react-aria-components (RAC) and Spectrum 2 (S2) — the two-pass render, the fake DOM / Document, CollectionBuilder, BaseCollection, CollectionNode, useListState/useTreeState, createLeafComponent/createBranchComponent, Section/Item nodes, SSR of collections, or the difference between the new and old (RSP v3) collection builders. |
| 3 | +--- |
| 4 | + |
| 5 | +# Collections (new RAC/S2 system) |
| 6 | + |
| 7 | +Guidance for the collection architecture behind RAC components (`ListBox`, `Menu`, `Table`, `Tree`, |
| 8 | +`GridList`, `TagGroup`, `Tabs`, `Breadcrumbs`) and S2 (which re-exports/wraps RAC). |
| 9 | + |
| 10 | +Source of truth lives in **`packages/react-aria/src/collections/`** (re-exported publicly as |
| 11 | +`@react-aria/collections` / the `react-aria/private/collections/*` aliases). Do not confuse it with |
| 12 | +the *old* builder in `packages/react-stately/src/collections/`. |
| 13 | + |
| 14 | +## The core idea: two-pass render |
| 15 | + |
| 16 | +Collection children use natural JSX (`<ListBox><ListBoxItem/><ListBoxSection/></ListBox>`), but that |
| 17 | +JSX is **not** what ends up in the browser DOM. Rendering happens in two passes: |
| 18 | + |
| 19 | +1. **Pass 1 — build the Collection.** The collection JSX is rendered by React into a *fake DOM* (a |
| 20 | + lightweight in-memory document model, not `document`). This produces an immutable `BaseCollection` |
| 21 | + — a `Map<Key, CollectionNode>` with sibling/parent/child links. Because React does this rendering, |
| 22 | + we keep JSX syntax *and* composition/context, and we learn each item's index, level, parent, |
| 23 | + sibling keys, and the total item count before rendering anything real. |
| 24 | +2. **Pass 2 — render the real DOM.** The `BaseCollection` is fed into state (`useListState` / |
| 25 | + `useTreeState`), and a renderer walks the collection and calls each node's stored `render` function |
| 26 | + to emit the actual DOM (supporting virtualization / rendering a subset). |
| 27 | + |
| 28 | +Rationale is documented inline at `packages/react-aria/src/collections/Document.ts:18-30`. |
| 29 | + |
| 30 | +``` |
| 31 | +<ListBox items={...}>{item => <ListBoxItem/>}</ListBox> |
| 32 | + │ |
| 33 | + ▼ CollectionBuilder renders content into <Hidden> portal |
| 34 | + ┌─────────────────────────── PASS 1 (fake DOM) ───────────────────────────┐ |
| 35 | + │ Collection → CollectionRoot → createPortal(children, Document) │ |
| 36 | + │ each Item/Section is a createLeafComponent/createBranchComponent → │ |
| 37 | + │ renders <ElementNode> host elements → React reconciler mutates fake DOM │ |
| 38 | + │ Document.getCollection() finalizes an immutable BaseCollection │ |
| 39 | + └──────────────────────────────────────────────────────────────────────────┘ |
| 40 | + │ collection (BaseCollection) |
| 41 | + ▼ |
| 42 | + ┌─────────────────────────── PASS 2 (real DOM) ───────────────────────────┐ |
| 43 | + │ useListState(collection) → SelectionManager, keyboard delegates │ |
| 44 | + │ CollectionRoot walks collection, calls node.render(node) → real <div>s │ |
| 45 | + └──────────────────────────────────────────────────────────────────────────┘ |
| 46 | +``` |
| 47 | + |
| 48 | +## The fake DOM / document model |
| 49 | + |
| 50 | +React can render into any host environment given a host-config; here the host is a hand-written mock |
| 51 | +DOM in `packages/react-aria/src/collections/Document.ts`. **No custom reconciler** is written — instead |
| 52 | +`react-dom`'s `createPortal` targets a fake `Document` object that duck-types the DOM API React calls |
| 53 | +(`createElement`, `appendChild`, `insertBefore`, `removeChild`, `style`, `setAttribute`, …). |
| 54 | + |
| 55 | +Key classes (all in `Document.ts`): |
| 56 | + |
| 57 | +| Class | Role | |
| 58 | +|---|---| |
| 59 | +| `BaseNode<T>` (`Document.ts:36`) | Base mutable fake-DOM node: `firstChild`/`lastChild`/`nextSibling`/`parentNode` getters+setters that call `ownerDocument.markDirty`. Implements `appendChild`/`insertBefore`/`removeChild` (`Document.ts:126-220`). | |
| 60 | +| `ElementNode<T>` (`Document.ts:262`) | A mutable fake element. `nodeType = 8` (COMMENT_NODE — deliberately not ELEMENT_NODE so React DevTools doesn't try to measure it, `Document.ts:263`). Owns one immutable `CollectionNode`. Has `setProps` (`:337`), `updateNode` (`:309`), a fake `style` getter for Suspense `display:none` handling (`:379`), and no-op `setAttribute`/`hasAttribute`. | |
| 61 | +| `Document<T,C>` (`Document.ts:428`) | The portal target. `nodeType = 11` (DOCUMENT_FRAGMENT_NODE). Owns the current immutable `collection`, a `nextCollection` (copy-on-write), a `dirtyNodes` set, and the `useSyncExternalStore` subscription plumbing. | |
| 62 | + |
| 63 | +How nodes get created & the collection is built: |
| 64 | + |
| 65 | +- React calls `document.createElement(type)` → `new ElementNode(type, this)` (`Document.ts:452`). |
| 66 | +- React sets children via `appendChild`/`insertBefore`; each setter calls `markDirty` and, when |
| 67 | + connected, `queueUpdate()` (`Document.ts:148-151`, `:180-182`). |
| 68 | +- The `ref` callback on the host element calls `element.setProps(...)`, which lazily constructs (or |
| 69 | + copy-on-write clones) the immutable `CollectionNode`, copying `props`, `rendered`, `render`, `value`, |
| 70 | + `textValue`, `id` (`Document.ts:337-377`). **`id` is immutable** — changing it throws (`:366-368`). |
| 71 | +- `Document.updateCollection()` (`:509`) is the finalize step: removes disconnected/hidden nodes, |
| 72 | + recomputes indices, calls `ElementNode.updateNode()` to recompute `index`/`level`/`parentKey`/ |
| 73 | + `prevKey`/`nextKey`/`firstChildKey`/`lastChildKey`/`colIndex` (`:309-335`), adds surviving nodes to |
| 74 | + `nextCollection`, then `collection.commit(...)` **freezes** it (`:538-548`). |
| 75 | +- `getCollection()` (`:495`) runs the finalize and returns the frozen collection to React via |
| 76 | + `useSyncExternalStore`. `queueUpdate()` clones the collection so React notices a new snapshot and |
| 77 | + schedules the second render (`:551-576`). |
| 78 | + |
| 79 | +**Mutable fake node vs immutable collection node.** Each `ElementNode` (mutable, stable identity that |
| 80 | +React holds onto) owns one `CollectionNode` (immutable, copy-on-write). `getMutableNode()` clones the |
| 81 | +`CollectionNode` on first write per update cycle (`Document.ts:295-307`); unchanged nodes are shared, |
| 82 | +so updates are cheap. |
| 83 | + |
| 84 | +`<Hidden>` (`packages/react-aria/src/collections/Hidden.tsx:66`): during SSR there are no portals, so |
| 85 | +the hidden collection tree is rendered into a `<template>` element (never displayed, not in the a11y |
| 86 | +tree). `HTMLTemplateElement.prototype.firstChild`/`appendChild`/etc. are monkey-patched (`Hidden.tsx:30-62`) |
| 87 | +to proxy into `.content` so React hydration doesn't choke (React issue #19932). |
| 88 | + |
| 89 | +## The collection node shape |
| 90 | + |
| 91 | +Immutable class `CollectionNode<T> implements Node<T>` at |
| 92 | +`packages/react-aria/src/collections/BaseCollection.ts:23`. The public `Node<T>` interface is |
| 93 | +`packages/@react-types/shared/src/collections.d.ts:198`. |
| 94 | + |
| 95 | +| Field | Meaning | |
| 96 | +|---|---| |
| 97 | +| `type` | Node type string — `'item'`, `'section'`, `'header'`, `'loader'`, `'separator'`, `'column'`, `'cell'`, `'content'`, … Set from the `static type` of the node subclass. | |
| 98 | +| `key` | Unique `Key`. From `id` prop, else data `key`/`id`, else auto `react-aria-${++nodeId}` (`Document.ts:347`). | |
| 99 | +| `value` | Original data object the node was created from (dynamic collections). | |
| 100 | +| `level` | Depth in hierarchy; computed from parent chain (`Document.ts:283-289`). | |
| 101 | +| `index` | Position within parent. | |
| 102 | +| `hasChildNodes` | Whether it has children. | |
| 103 | +| `rendered` | The rendered JSX contents (e.g. the label). | |
| 104 | +| `textValue` | Plain-text value for typeahead; derived from `textValue`/string children/`aria-label`. | |
| 105 | +| `parentKey` / `prevKey` / `nextKey` | Links used for navigation and iteration. | |
| 106 | +| `firstChildKey` / `lastChildKey` | Child range for branch nodes. | |
| 107 | +| `props` | Raw props (includes `ref`). | |
| 108 | +| `render` | `(node) => ReactElement` — called in pass 2 to produce the real DOM. | |
| 109 | +| `colSpan` / `colIndex` | Table column spanning. | |
| 110 | +| `childNodes` | Throws on base; use `collection.getChildren(key)` instead (`BaseCollection.ts:49-51`, and `Node.childNodes` is `@deprecated`). | |
| 111 | + |
| 112 | +`CollectionNode` subclasses (`BaseCollection.ts`): `FilterableNode` (`:86`), `HeaderNode` (`:105`, |
| 113 | +type `header`), `LoaderNode` (`:109`, type `loader`), `ItemNode` (`:113`, type `item`), `SectionNode` |
| 114 | +(`:131`, type `section`). Each may override `filter()` to control filtering behavior. |
| 115 | + |
| 116 | +## BaseCollection |
| 117 | + |
| 118 | +`class BaseCollection<T> implements ICollection<Node<T>>` at `BaseCollection.ts:158`. Internally a |
| 119 | +`keyMap: Map<Key, CollectionNode>` plus `firstKey`/`lastKey`/`itemCount`/`frozen`. Implements the |
| 120 | +shared `Collection` interface (`collections.d.ts:163`): `getItem`, `getKeys`, `getChildren`, |
| 121 | +`getKeyBefore`/`getKeyAfter`, `getFirstKey`/`getLastKey`, `[Symbol.iterator]` (flattened traversal), |
| 122 | +`size`, and `filter`. `size` counts only `type === 'item'` nodes (`:279-281`). `commit()` sets |
| 123 | +first/last keys and freezes (`:308-316`); a frozen collection throws on `addNode`/`removeNode`/`commit`. |
| 124 | +`clone()` shallow-copies the keyMap for copy-on-write updates (`:261-272`). `filter()` builds a brand |
| 125 | +new collection by walking children and calling each node's `filter()` (`:318-323`, helper |
| 126 | +`filterChildren` `:326`). |
| 127 | + |
| 128 | +## End-to-end trace: RAC `<ListBox>` |
| 129 | + |
| 130 | +File: `packages/react-aria-components/src/ListBox.tsx`. |
| 131 | + |
| 132 | +1. `<ListBox items>{item => <ListBoxItem/>}</ListBox>`. If no `ListState` is in context (standalone |
| 133 | + case), it renders `<CollectionBuilder content={<Collection {...props} />}>` (`ListBox.tsx:218-222`). |
| 134 | +2. `CollectionBuilder` (`CollectionBuilder.tsx:49`) creates a `Document` via `useCollectionDocument` |
| 135 | + (`:117`, `useSyncExternalStore` over `document.subscribe`/`getCollection`). It renders |
| 136 | + `<Hidden><CollectionDocumentContext.Provider value={document}>{content}</...></Hidden>` **plus** |
| 137 | + `<CollectionInner render={children} collection={collection}/>` (`:69-78`). The hidden tree is pass 1; |
| 138 | + `CollectionInner` is pass 2. |
| 139 | +3. `<Collection>` (`CollectionBuilder.tsx:274`) maps items via `useCachedChildren` |
| 140 | + (`useCachedChildren.ts:33`), which caches rendered elements per data object (WeakMap) and derives |
| 141 | + React keys/ids (`:50-80`). Since a document is in context, it wraps them in `<CollectionRoot>`. |
| 142 | +4. `CollectionRoot` (`CollectionBuilder.tsx:304`) does `createPortal(children, doc)` (client) or an |
| 143 | + `<SSRContext>` (SSR), pushing `ShallowRenderContext = true` so leaf/branch components render as |
| 144 | + collection nodes instead of real DOM (`:304-321`). |
| 145 | +5. Each `<ListBoxItem>` is built by `createLeafComponent(ItemNode, …)` (`ListBox.tsx:538`); each |
| 146 | + `<ListBoxSection>` by `createBranchComponent(SectionNode, …)` (`:494`). In shallow mode these call |
| 147 | + `useSSRCollectionNode` (`CollectionBuilder.tsx:159`), which renders a host |
| 148 | + `<ElementNode>` carrying `setProps`, `rendered`, and a `render` callback (`:227-239`, `:255-257`). |
| 149 | + React commits these into the fake DOM → `Document` builds the `BaseCollection`. |
| 150 | +6. Pass 2: `StandaloneListBox` (`ListBox.tsx:225`) calls `useListState({...props, collection})`. |
| 151 | + `useListState` (`packages/react-stately/src/list/useListState.ts:50`) → `useCollection` |
| 152 | + (`packages/react-stately/src/collections/useCollection.ts:30`): because a prebuilt `collection` is |
| 153 | + passed, it is **returned as-is** (`useCollection.ts:38-39`) — the old `builder.build`/`ListCollection` |
| 154 | + path is skipped. Then a `SelectionManager` is created over it. |
| 155 | +7. `ListBoxInner` (`ListBox.tsx:239`) renders the real `<div>` and a `<CollectionRoot collection={collection}>` |
| 156 | + (`:427-432`). The **default** `CollectionRoot`/`CollectionBranch` (`Collection.tsx:201-208`) call |
| 157 | + `useCollectionRender` → `useCachedChildren` and invoke `node.render!(node)` for each node |
| 158 | + (`Collection.tsx:225`) — producing the real DOM. (Virtualized collections override |
| 159 | + `CollectionRendererContext` with a renderer that only renders visible nodes.) |
| 160 | + |
| 161 | +The ComboBox/Select case: those render two copies. The first passes a `Document` via context (so |
| 162 | +`CollectionBuilder` short-circuits at `CollectionBuilder.tsx:53-61`), the second passes a `ListState` |
| 163 | +via `ListStateContext` so the ListBox reuses state without rebuilding (`ListBox.tsx:207-216`). |
| 164 | + |
| 165 | +## Sections, SSR, id/key resolution |
| 166 | + |
| 167 | +- **Sections** are branch nodes (`createBranchComponent(SectionNode, …)`). `<Header>` inside becomes a |
| 168 | + `HeaderNode`. Section children are built recursively via nested `<Collection>`/`useCachedChildren`. |
| 169 | +- **id/key resolution** (`useCachedChildren.ts:57-69`): explicit `id` prop wins, else `item.key`/ |
| 170 | + `item.id`, else array index as React key (the collection then auto-generates an id). `idScope` |
| 171 | + prefixes ids (`idScope + ':' + id`) to keep nested collections unique. `addIdAndValue` also injects |
| 172 | + `value={item}` so the node captures its data object. |
| 173 | +- **SSR**: portals don't exist server-side, so `Collection` renders through `<SSRContext>` and |
| 174 | + `useSSRCollectionNode` appends nodes to the document *during render* (`CollectionBuilder.tsx:184-197`), |
| 175 | + and `<Hidden>` renders a `<template>`. `Document.isSSR` keeps the collection unfrozen |
| 176 | + (`BaseCollection.ts:315`, `Document.ts:544-547`); after hydration `resetAfterSSR()` (`Document.ts:589`) |
| 177 | + clears the document so the client portal can take over. |
| 178 | +- **Async loading**: rendered as `LoaderNode` (`type 'loader'`), e.g. `ListBoxLoadMoreItem` |
| 179 | + (`ListBox.tsx:742`), plus a `useLoadMoreSentinel` intersection observer. |
| 180 | + |
| 181 | +## Old vs new — how to tell them apart |
| 182 | + |
| 183 | +| | New (RAC/S2) | Old (RSP v3) | |
| 184 | +|---|---|---| |
| 185 | +| Builder | `CollectionBuilder` component (`packages/react-aria/src/collections/CollectionBuilder.tsx`) — renders JSX into a fake DOM | `CollectionBuilder` **class** (`packages/react-stately/src/collections/CollectionBuilder.ts:25`) — `build()` reflects over element `props`/`type` via `getFullNode` | |
| 186 | +| Collection | `BaseCollection` (`react-aria`) | `ListCollection`/`TreeCollection` (`react-stately`) built from `builder.build` | |
| 187 | +| Item/Section | `createLeafComponent`/`createBranchComponent`; `<ListBoxItem>`, `<ListBoxSection>` | `Item`/`Section` from `@react-stately/collections` (`react-stately/src/collections/Item.ts`, `Section.ts`) with a static `getCollectionNode` generator | |
| 188 | +| Entry point | component passes prebuilt `collection` prop → `useCollection` returns it unchanged (`useCollection.ts:38`) | `useCollection` runs `builder.build({children, items})` then `factory` (`useCollection.ts:41-42`) | |
| 189 | + |
| 190 | +Both paths funnel through the same `useListState`/`useTreeState` + shared `Collection`/`Node` types, so |
| 191 | +state, selection, and keyboard code is reused. **New is preferred** for all RAC and S2 work; the old |
| 192 | +reflective builder only remains for legacy RSP v3 components. Quick tell: if a component is defined with |
| 193 | +`createLeafComponent`/`createBranchComponent` and wrapped in a `<CollectionBuilder content=…>`, it's new. |
| 194 | + |
| 195 | +## Common tasks & gotchas |
| 196 | + |
| 197 | +- **Add a new node type**: subclass `CollectionNode` with a `static readonly type` in |
| 198 | + `BaseCollection.ts` (see `LoaderNode`/`HeaderNode`), export it from |
| 199 | + `packages/@react-aria/collections/src/index.ts`, and create the component with |
| 200 | + `createLeafComponent(MyNode, …)` / `createBranchComponent(MyNode, …)`. If it needs custom filtering, |
| 201 | + override `filter()`. |
| 202 | +- **What triggers a rebuild (pass 1)**: any fake-DOM mutation (`appendChild`, `setProps`, `style.display` |
| 203 | + change) marks nodes dirty and calls `queueUpdate()`, which clones the collection so |
| 204 | + `useSyncExternalStore` sees a new snapshot and re-renders. `useCachedChildren` caches by item object |
| 205 | + identity; pass a `dependencies` array to bust the cache when a render closure captures external state |
| 206 | + (`useCachedChildren.ts:48`, `Collection` merges parent+child deps at `CollectionBuilder.tsx:276`). |
| 207 | +- **Copy-on-write / freezing**: committed collections are frozen — never mutate a collection you got |
| 208 | + from state; call `.clone()` or go through the document. `addNode`/`removeNode`/`commit` throw on a |
| 209 | + frozen collection (`BaseCollection.ts:275`, `:297`, `:309`). |
| 210 | +- **Suspense / hidden items**: React sets `display:none`; the fake `style` setter (`Document.ts:379-416`) |
| 211 | + flips `isHidden`, which removes the node from the collection but keeps it in the document. Use |
| 212 | + `useIsHidden`/`createHideableComponent` (`Hidden.tsx:84`) for components that must render nothing while |
| 213 | + in a hidden collection subtree. |
| 214 | +- **Don't render collection item components outside a collection**: leaf components whose render fn |
| 215 | + takes a `node` arg throw "cannot be rendered outside a collection" when not shallow |
| 216 | + (`CollectionBuilder.tsx:220-224`). |
| 217 | +- **`id` is immutable** once set on a node (`Document.ts:366-368`) — changing an item's `id` between |
| 218 | + renders throws. |
| 219 | +- **`node.childNodes` is deprecated** — iterate with `collection.getChildren(key)` |
| 220 | + (`BaseCollection.ts:49`, `collections.d.ts:210-214`). |
0 commit comments