Skip to content

Commit a731ebb

Browse files
committed
Add skills to make dev on hard code spaces easier and hopefully take less tokens
1 parent 1f29083 commit a731ebb

3 files changed

Lines changed: 545 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)