@rivetjs/cdk 0.1.0-alpha.0 → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/_adapters/selection.d.ts +13 -2
  2. package/_adapters/selection.js +39 -15
  3. package/button/index.jsx +5 -3
  4. package/collections/components.d.ts +36 -1
  5. package/collections/components.jsx +224 -16
  6. package/collections/nodes.d.ts +9 -1
  7. package/collections/nodes.js +26 -0
  8. package/collections/row-stamps.d.ts +17 -0
  9. package/collections/row-stamps.js +108 -0
  10. package/collections/server-dev.d.ts +1 -0
  11. package/collections/server-dev.js +2 -0
  12. package/collections/server-off.d.ts +1 -0
  13. package/collections/server-off.js +2 -0
  14. package/collections/server-on.d.ts +1 -0
  15. package/collections/server-on.jsx +10 -0
  16. package/drag-drop/directives/drag.d.ts +16 -0
  17. package/drag-drop/directives/drag.js +28 -0
  18. package/drag-drop/drag.jsx +6 -4
  19. package/drag-drop/lazy-refs.js +15 -4
  20. package/grid-list/behaviour.d.ts +6 -0
  21. package/grid-list/behaviour.js +25 -7
  22. package/grid-list/index.d.ts +3 -1
  23. package/grid-list/index.jsx +60 -24
  24. package/interactions/press.jsx +2 -1
  25. package/interactions/utils.d.ts +2 -3
  26. package/interactions/utils.js +8 -7
  27. package/listbox/behaviour.jsx +37 -14
  28. package/listbox/index.jsx +65 -29
  29. package/menu/index.jsx +38 -7
  30. package/package.json +4 -4
  31. package/portal/index.d.ts +8 -0
  32. package/portal/index.jsx +8 -1
  33. package/radio-group/index.jsx +4 -1
  34. package/selection/keyed.js +6 -16
  35. package/selection/selectable-collection.js +42 -17
  36. package/sidebar/index.jsx +5 -4
  37. package/state/menu.d.ts +3 -0
  38. package/state/menu.js +1 -0
  39. package/table/index.jsx +512 -168
  40. package/tree/index.jsx +26 -4
  41. package/utils/data-attrs.d.ts +4 -2
  42. package/utils/data-attrs.jsx +27 -3
  43. package/utils/index.d.ts +1 -15
  44. package/utils/index.js +5 -102
  45. package/utils/link-state.d.ts +6 -0
  46. package/utils/link-state.js +6 -0
  47. package/utils/links.d.ts +1 -6
  48. package/utils/links.jsx +2 -4
  49. package/utils/transitions.d.ts +15 -0
  50. package/utils/transitions.js +104 -0
  51. package/virtualizer/behaviour.d.ts +35 -4
  52. package/virtualizer/behaviour.js +131 -9
  53. package/virtualizer/collection-renderer.d.ts +62 -0
  54. package/virtualizer/collection-renderer.jsx +190 -0
  55. package/virtualizer/engine.d.ts +1 -1
  56. package/virtualizer/index.d.ts +1 -0
  57. package/virtualizer/index.jsx +1 -0
  58. package/virtualizer/list-layout.js +4 -2
  59. package/virtualizer/state.js +12 -2
  60. package/virtualizer/virtualizer.d.ts +8 -6
  61. package/virtualizer/virtualizer.jsx +4 -1
@@ -1,4 +1,4 @@
1
- import { SelectionManager, type FocusStrategy, type Key, type KeyboardDelegate, type MultipleSelectionState, type Orientation } from "./collections.js";
1
+ import { SelectionManager, type Collection, type FocusStrategy, type Key, type KeyboardDelegate, type LayoutDelegate, type MultipleSelectionState, type Orientation } from "./collections.js";
2
2
  import { type PressEvent } from "./interactions.js";
3
3
  export type LinkBehavior = "action" | "selection" | "override" | "none";
4
4
  /** Static (serializable) options of a selectable collection + its items. */
@@ -27,6 +27,17 @@ export interface SelectableCollection extends MultipleSelectionState {
27
27
  readonly onAction?: ((key: Key) => void) | undefined;
28
28
  /** Sections with their own selection (RAC `MenuSection selectionMode`), by section key. */
29
29
  readonly groups?: Readonly<Record<string, MultipleSelectionState>> | undefined;
30
+ /**
31
+ * Rivet: a virtualized collection (RAC `Virtualizer`, only a window of items is rendered): the handlers navigate its
32
+ * built collection (an accessor) instead of the rendered DOM, page keys use the virtualizer's layout delegate, focus
33
+ * waits for the focused item to render and the scroll position is the virtualizer's.
34
+ */
35
+ readonly virtual?: VirtualCollection | undefined;
36
+ }
37
+ /** A virtualized collection's built collection and layout (see `SelectableCollection.virtual`). */
38
+ export interface VirtualCollection {
39
+ readonly collection: () => Collection;
40
+ readonly layoutDelegate?: LayoutDelegate | undefined;
30
41
  }
31
42
  /** The manager selecting `key`: its section's own (GroupSelectionManager) or the collection's. */
32
43
  export declare function groupManager(list: SelectableCollection, root: HTMLElement, manager: SelectionManager, key: Key): SelectionManager;
@@ -40,7 +51,7 @@ export interface ItemPressHooks {
40
51
  /** Called after the item performed its action (menu: close). */
41
52
  onAction?(list: SelectableCollection, key: Key, root: HTMLElement): void;
42
53
  }
43
- /** Selection manager over the rendered collection under `root`. */
54
+ /** Selection manager over the rendered collection under `root` (a virtualized collection: its built collection). */
44
55
  export declare const managerOf: (list: MultipleSelectionState, root: HTMLElement) => SelectionManager;
45
56
  /** Keyboard delegate of `root` (useSelectableList's default `ListKeyboardDelegate`). */
46
57
  export declare function delegateOf(list: SelectableCollection, root: HTMLElement, manager: SelectionManager): KeyboardDelegate;
@@ -21,8 +21,8 @@ export function groupManager(list, root, manager, key) {
21
21
  const group = section && root.contains(section) ? list.groups[section.getAttribute("data-key") ?? ""] : undefined;
22
22
  return group ? new GroupSelectionManager(manager, group) : manager;
23
23
  }
24
- /** Selection manager over the rendered collection under `root`. */
25
- export const managerOf = (list, root) => new SelectionManager(domCollection(root), list);
24
+ /** Selection manager over the rendered collection under `root` (a virtualized collection: its built collection). */
25
+ export const managerOf = (list, root) => new SelectionManager(list.virtual?.collection() ?? domCollection(root), list);
26
26
  /** Keyboard delegate of `root` (useSelectableList's default `ListKeyboardDelegate`). */
27
27
  export function delegateOf(list, root, manager) {
28
28
  return new ListKeyboardDelegate({
@@ -34,6 +34,7 @@ export function delegateOf(list, root, manager) {
34
34
  layout: list.options.layout ?? "stack",
35
35
  orientation: list.options.orientation ?? "vertical",
36
36
  direction: dirOf(root),
37
+ layoutDelegate: list.virtual?.layoutDelegate,
37
38
  });
38
39
  }
39
40
  const selectOnFocusOf = (list) => list.options.selectOnFocus ?? untrack(list.selectionBehavior) === "replace";
@@ -51,23 +52,43 @@ export function syncFocus(list, root, previous, force = false) {
51
52
  const virtual = !!list.options.shouldUseVirtualFocus;
52
53
  if (focused && key != null && (key !== previous || force)) {
53
54
  const element = getItemElement(root, key);
54
- if (element) {
55
- if (virtual)
56
- moveVirtualFocus(element);
57
- else if (document.activeElement !== element)
58
- focusSafely(element);
59
- const modality = getInteractionModality();
60
- if (modality === "keyboard" || force) {
61
- scrollIntoView(root, element);
62
- if (modality !== "virtual")
63
- scrollIntoViewport(element, { containingElement: root });
64
- }
65
- }
55
+ if (element)
56
+ focusItem(virtual, root, element, force);
57
+ // Virtualized: the item renders once the virtualizer persists the new focused key (RA: useSelectableItem's
58
+ // effect after that render) — focus and scroll it in when it appears.
59
+ else if (list.virtual)
60
+ whenRendered(list, root, key, force);
66
61
  }
67
62
  // The focused key went away (item removed): focus the collection itself.
68
63
  if (!virtual && focused && key == null && previous != null && root.isConnected)
69
64
  focusSafely(root);
70
65
  }
66
+ function focusItem(virtual, root, element, force) {
67
+ if (virtual)
68
+ moveVirtualFocus(element);
69
+ else if (document.activeElement !== element)
70
+ focusSafely(element);
71
+ const modality = getInteractionModality();
72
+ if (modality === "keyboard" || force) {
73
+ scrollIntoView(root, element);
74
+ if (modality !== "virtual")
75
+ scrollIntoViewport(element, { containingElement: root });
76
+ }
77
+ }
78
+ /** Virtualized collections: focus the item of `key` after the render that adds it (a microtask, then a few frames). */
79
+ function whenRendered(list, root, key, force) {
80
+ let frames = 0;
81
+ const attempt = () => {
82
+ if (untrack(list.focusedKey) !== key || !untrack(list.isFocused))
83
+ return;
84
+ const element = getItemElement(root, key);
85
+ if (element)
86
+ focusItem(!!list.options.shouldUseVirtualFocus, root, element, force);
87
+ else if (frames++ < 3)
88
+ requestAnimationFrame(attempt);
89
+ };
90
+ queueMicrotask(attempt);
91
+ }
71
92
  // Typeahead (useTypeSelect): `./type-select.ts` (its own module, so a closed Select trigger's typeahead does not
72
93
  // load this collection adapter).
73
94
  export { typeSelectKeyDown, _resetTypeSelect } from "./type-select.js";
@@ -593,6 +614,9 @@ export function collectionPointerOver(list, e, root = e.currentTarget) {
593
614
  const scrollPositions = new WeakMap();
594
615
  /** scroll on the collection root (remembered, restored when focus re-enters). */
595
616
  export function collectionScroll(list, e) {
617
+ // RA: not for a virtualized collection (the virtualizer owns its scroll position).
618
+ if (list.virtual)
619
+ return;
596
620
  scrollPositions.set(list, { top: e.currentTarget.scrollTop, left: e.currentTarget.scrollLeft });
597
621
  }
598
622
  /** focusin on the collection root: item focus → focused key; entering → first / last / selected item. */
@@ -632,7 +656,7 @@ export function collectionFocusIn(list, e, root = e.currentTarget) {
632
656
  // selection-follows-focus collection selects it, as RAC's first focus does.
633
657
  if (selectOnFocus && modality !== "pointer" && manager.isEmpty && !manager.isDisabled(manager.focusedKey))
634
658
  manager.replaceSelection(manager.focusedKey);
635
- const saved = scrollPositions.get(list);
659
+ const saved = list.virtual ? undefined : scrollPositions.get(list);
636
660
  if (saved) {
637
661
  root.scrollTop = saved.top;
638
662
  root.scrollLeft = saved.left;
package/button/index.jsx CHANGED
@@ -149,10 +149,12 @@ export function Button(inProps) {
149
149
  ref = mergeRefValues(ref, responderRef);
150
150
  }
151
151
  // RA useButton → useFocusable: FocusableContext (TooltipTrigger) props / handlers / ref reach the <button>.
152
- // `isDisabled` is read only with a context (it is unused without one): an accessor read here would make the
153
- // whole Button re-render on its first change on a resumed page (Calendar page buttons), dropping focus.
152
+ // `isDisabled` is never read here: an accessor / live one goes to the merge as an accessor (gated at event time and
153
+ // in the attribute binding). A read here would make the Button's enclosing region re-create it on its first change
154
+ // on a resumed page (Calendar page buttons dropped focus; a live `isDisabled` under a TooltipTrigger threw).
154
155
  const focusable = useContext(FocusableContext);
155
- const [focusableMerged, focusableRef, focusAttrs] = mergeFocusableContext(props, focusable, !!focusable && !!read(props.isDisabled));
156
+ const disabledAt = focusable ? liveAt(props, "isDisabled") : undefined;
157
+ const [focusableMerged, focusableRef, focusAttrs] = mergeFocusableContext(props, focusable, typeof disabledAt === "function" ? disabledAt : !!disabledAt);
156
158
  props = focusableMerged;
157
159
  ref = mergeRefValues(ref, focusableRef);
158
160
  // Live props (P5): with a live prop the state / ARIA props are read through a narrow view (getters unread, read in
@@ -1,5 +1,6 @@
1
1
  import { type Context } from "@rivetjs/reactivity";
2
2
  import type { RefProp } from "@rivetjs/runtime/jsx-runtime";
3
+ import { type RowBox } from "./row-stamps.js";
3
4
  import { BaseCollection, type CollectionNodeClass } from "./base.js";
4
5
  import type { Collection as ICollection, Key, LayoutDelegate, Node, SelectionBehavior, SelectionMode } from "./types.js";
5
6
  export type { CollectionNodeClass, CollectionNodeSpec } from "./base.js";
@@ -53,6 +54,14 @@ export declare function isCollectionProbe(): boolean;
53
54
  * popovers). Server: built on first read. Browser: rebuilt when a signal the content reads changes.
54
55
  */
55
56
  export declare function collectionOf<C extends BaseCollection<any> = BaseCollection<any>>(content: unknown, createCollection?: () => C): () => C;
57
+ /**
58
+ * Rivet: the built collection of a virtualized host's `content` (RAC: a collection inside a `Virtualizer` renders
59
+ * from its built collection, only the visible window has parts). One `collectionOf` computed per `instance` (a plain
60
+ * object the host creates in its body: serialized as data, so a resumed page rebuilds the computed from `content` on
61
+ * its first read). Created detached from the reading owner: the first read may be a binding that re-runs (the
62
+ * virtualizer's rows), which would dispose a computed it owns.
63
+ */
64
+ export declare function builtCollectionOf<C extends BaseCollection<any> = BaseCollection<any>>(instance: object, content: unknown, createCollection?: () => C): C;
56
65
  export interface CollectionBuilderProps<C extends BaseCollection<any>> {
57
66
  /** The collection content (plain JSX children are lazy). */
58
67
  content: unknown;
@@ -157,6 +166,11 @@ export interface CollectionRootProps {
157
166
  idScope?: Key | undefined;
158
167
  /** A set of keys for items that should always be persisted in the DOM (virtualizers). */
159
168
  persistedKeys?: Set<Key> | null | undefined;
169
+ /**
170
+ * Rivet: the host's focused key (an accessor, e.g. the selection state's `focusedKey` signal): virtualizers keep it
171
+ * rendered (RAC `usePersistedKeys(focusedKey)`; a signal, not a set, so it resumes without a closure over the host state).
172
+ */
173
+ focusedKey?: (() => Key | null) | undefined;
160
174
  /** The scroll container (virtualizers). */
161
175
  scrollRef?: unknown;
162
176
  renderDropIndicator?: CollectionBranchProps["renderDropIndicator"];
@@ -172,6 +186,11 @@ export interface CollectionRenderer {
172
186
  CollectionRoot: (props: CollectionRootProps) => unknown;
173
187
  /** Renders the child items of a branch. */
174
188
  CollectionBranch: (props: CollectionBranchProps) => unknown;
189
+ /**
190
+ * Rivet: the host forwards its scroll container's `scroll` events here (virtualizers: the engine follows the scroll
191
+ * position; RAC's useScrollView listens on `scrollRef` itself, a resumed page needs the host element's handler).
192
+ */
193
+ onScroll?: ((event: Event) => void) | undefined;
175
194
  }
176
195
  /** A built collection, an accessor of one, or null (live mode). */
177
196
  export type CollectionInput = ICollection<Node<unknown>> | (() => ICollection<Node<unknown>>) | null | undefined;
@@ -189,7 +208,23 @@ export declare function sameRender(a: Node<unknown> | null | undefined, b: Node<
189
208
  * node renders the same (`sameRender`) and re-renders it when its render inputs changed, or when `depends` (read in the
190
209
  * region) changes: a part whose content renders from its parent's value (TreeItemContent).
191
210
  */
192
- export declare function renderNodeAt(collection: () => ICollection<Node<unknown>> | null | undefined, key: () => Key, depends?: () => unknown): unknown;
211
+ export declare function renderNodeAt(collection: () => ICollection<Node<unknown>> | null | undefined, key: () => Key | RowBox, depends?: () => unknown): unknown;
212
+ /**
213
+ * Server baseline of a resumed For over collection keys (ROW-RERENDER): `_regionState` with the stamps of the rows the server
214
+ * renders (`row-stamps.ts`) when the collection is an accessor whose build read signals (a static collection never rebuilds:
215
+ * null), recorded with the For's region; none (no slot) for the children of a section / table row (`parentType`). Called by the server and the browser alike (one `_regionState` slot per accessor
216
+ * collection, so the region's other slots keep their positions); the browser gets the server's value back when it
217
+ * re-creates that region (adopting the rows), null otherwise.
218
+ */
219
+ export declare function serverRows(collection: CollectionInput, keys: () => readonly Key[], parentType?: string | undefined): string | null;
220
+ /**
221
+ * The keys of a resumed For over collection keys (`renderNodeAt` rows), given the server baseline (`serverRows`): an adopted
222
+ * server row keeps its key while its node renders like the server's (stamp in the baseline; then `sameRender` against the
223
+ * last node checked), else its key is boxed (`RowBox`, one box per key for good) — a key the adopted list does not know, so
224
+ * the For re-creates that row (rows read `rowKey(key())`). Rows the server did not render get a box too (created anyway).
225
+ * Without a baseline: `keys` itself.
226
+ */
227
+ export declare function resumedKeys(collection: CollectionInput, base: string | null, keys: () => readonly Key[]): () => readonly (Key | RowBox)[];
193
228
  /** RAC `DefaultCollectionRenderer`: built collections render node by node; live mode renders content in place. */
194
229
  export declare const DefaultCollectionRenderer: CollectionRenderer;
195
230
  /** RAC `CollectionRendererContext` (Virtualizer provides its own renderer). */
@@ -29,11 +29,13 @@
29
29
  // Deviation: `dependencies` is accepted and ignored (signals re-render; no item cache to invalidate).
30
30
  // Deviation: React hook names → Rivet (`useIsHidden` → `isHidden`, `usePersistedKeys` →
31
31
  // `persistedKeys`, `useCachedChildren` → `collectionChildren`).
32
- import { _observer, _reobserve, _scoped, computed, createContext, provide, runWithOwner, untrack, useContext, } from "@rivetjs/reactivity";
32
+ import { _observer, _regionState, _reobserve, _scoped, _trackReads, computed, createContext, provide, runWithOwner, untrack, useContext, } from "@rivetjs/reactivity";
33
33
  import { For, Provider } from "@rivetjs/runtime/dom";
34
34
  import { _rUnrecorded, resumableContext } from "@rivetjs/runtime/resume-format";
35
35
  import { isSlot } from "@rivetjs/runtime/slot";
36
36
  import { HiddenContext } from "./hidden.js";
37
+ import { domRowItem } from "#server";
38
+ import { rowKey, stampSet, stamp } from "./row-stamps.js";
37
39
  import { hasLive } from "../utils/live.jsx";
38
40
  import { mergeProps as mergeLive, splitProps } from "@rivetjs/runtime/props";
39
41
  import { BaseCollection, buildCollection, collectionNodeClass, } from "./base.js";
@@ -93,8 +95,25 @@ export function buildCollectionFrom(content, createCollection) {
93
95
  finally {
94
96
  run.o = null;
95
97
  }
96
- return buildCollection(scope.specs, createCollection);
98
+ const keyed = typeof content === "function" || (typeof content === "object" && content !== null);
99
+ try {
100
+ const built = buildCollection(scope.specs, createCollection);
101
+ if (keyed)
102
+ (lastBuilt ??= new WeakMap()).set(content, built);
103
+ return built;
104
+ }
105
+ catch (error) {
106
+ // A rebuild between two unbatched writes (an item moved from one `items` signal to another: added to the first, not
107
+ // yet removed from the second) sees a key twice; RAC never builds that state (React batches the renders). Keep the
108
+ // content's last collection: the build read every source, so the second write rebuilds it. A first build throws.
109
+ const last = keyed ? lastBuilt?.get(content) : undefined;
110
+ if (last !== undefined && error instanceof Error && error.message.startsWith("Duplicate key "))
111
+ return last;
112
+ throw error;
113
+ }
97
114
  }
115
+ /** The last collection each content built (`buildCollectionFrom`'s duplicate-key tolerance on rebuilds). */
116
+ let lastBuilt;
98
117
  /**
99
118
  * Rivet (server): evaluate `content` in build mode registering nothing, so the collection root in it (ListBox / Menu /
100
119
  * …, `isCollectionProbe`) merges its own `id` into the `SelectableCollectionContext` `MergedId` (`contextProps`) before
@@ -158,6 +177,20 @@ export function isCollectionProbe() {
158
177
  export function collectionOf(content, createCollection) {
159
178
  return computed(() => buildCollectionFrom(content, createCollection));
160
179
  }
180
+ let builtCollections;
181
+ /**
182
+ * Rivet: the built collection of a virtualized host's `content` (RAC: a collection inside a `Virtualizer` renders
183
+ * from its built collection, only the visible window has parts). One `collectionOf` computed per `instance` (a plain
184
+ * object the host creates in its body: serialized as data, so a resumed page rebuilds the computed from `content` on
185
+ * its first read). Created detached from the reading owner: the first read may be a binding that re-runs (the
186
+ * virtualizer's rows), which would dispose a computed it owns.
187
+ */
188
+ export function builtCollectionOf(instance, content, createCollection) {
189
+ let built = (builtCollections ??= new WeakMap()).get(instance);
190
+ if (!built)
191
+ builtCollections.set(instance, (built = runWithOwner(null, () => collectionOf(content, createCollection))));
192
+ return built();
193
+ }
161
194
  /**
162
195
  * Builds a `Collection` from the `content` and passes it to `children` (RAC `CollectionBuilder`).
163
196
  * Inside another builder's build pass it only renders the content (registers into that build).
@@ -173,10 +206,68 @@ export function CollectionBuilder(props) {
173
206
  function evaluate(content) {
174
207
  if (typeof content === "function" && (isSlot(content) || content.length === 0))
175
208
  return evaluate(content());
176
- if (Array.isArray(content))
209
+ if (Array.isArray(content)) {
210
+ if (!isServer && hasThunk(content)) {
211
+ const build = useContext(BuildContext);
212
+ if (build)
213
+ return evaluateInOrder(content, build.specs);
214
+ }
177
215
  return content.map(evaluate);
216
+ }
178
217
  return content;
179
218
  }
219
+ // Build-pass order (browser). A children array is built eagerly: its parts register while JSX builds the array, and a
220
+ // `{() => …}` / `<Show>` / `<For>` child (an accessor) registers only when `evaluate` calls it, after its siblings. In
221
+ // build mode, parts return a per-spec mark (an empty comment: renders nothing; a node, so array copies — the dev
222
+ // runtime maps component output — keep it), so the array says where each eager part's spec belongs; an accessor's
223
+ // specs go where the accessor sits. Server: the build calls everything in document order already.
224
+ let marks;
225
+ /** The value a part returns in build mode: `null` on the server, a mark of its spec in the browser. */
226
+ const buildMark = (spec) => {
227
+ if (isServer)
228
+ return null;
229
+ // `globalThis.document`: the compiler rejects bare browser globals in a server-rendered module (RV2001).
230
+ const mark = globalThis.document.createComment("");
231
+ (marks ??= new WeakMap()).set(mark, spec);
232
+ return mark;
233
+ };
234
+ const isThunk = (value) => typeof value === "function" && (isSlot(value) || value.length === 0);
235
+ function hasThunk(content) {
236
+ for (const value of content)
237
+ if (isThunk(value) || (Array.isArray(value) && hasThunk(value)))
238
+ return true;
239
+ return false;
240
+ }
241
+ function evaluateInOrder(content, list) {
242
+ const listed = new Set(list);
243
+ const order = [];
244
+ const walk = (value) => {
245
+ if (Array.isArray(value))
246
+ for (const item of value)
247
+ walk(item);
248
+ else if (isThunk(value)) {
249
+ const start = list.length;
250
+ evaluate(value);
251
+ for (const spec of list.splice(start))
252
+ order.push(spec);
253
+ }
254
+ else if (value !== null && typeof value === "object") {
255
+ const spec = marks?.get(value);
256
+ if (spec && listed.has(spec))
257
+ order.push(spec);
258
+ }
259
+ };
260
+ walk(content);
261
+ const placed = new Set(order);
262
+ let at = 0;
263
+ for (const spec of list)
264
+ if (!placed.has(spec))
265
+ list[at++] = spec;
266
+ list.length = at;
267
+ for (const spec of order)
268
+ list.push(spec);
269
+ return null;
270
+ }
180
271
  /**
181
272
  * Live (rendered) content of a host / branch / row, placed inside the caller's providers: slots run now; any other
182
273
  * 0-arity function — a `{() => …}` render-function child or a DOM v2 structural accessor (`<For>` / `<Show>` /
@@ -276,7 +367,7 @@ export function createLeafComponent(CollectionNodeClass, render) {
276
367
  const value = all.value ?? defaults?.value;
277
368
  // RAC passes node.props as given: `id` only when the user (or an `items` row) supplied one.
278
369
  const explicitId = resolveId(all, defaults, collectionCtx);
279
- build.specs.push({
370
+ const spec = {
280
371
  type: NodeClass,
281
372
  id: explicitId ?? generatedId(build.parentKey, build.ids),
282
373
  value: value,
@@ -286,8 +377,9 @@ export function createLeafComponent(CollectionNodeClass, render) {
286
377
  props: rest,
287
378
  ownProps: true,
288
379
  render: (node) => render(withNode(all, live && !defaults, node.props, live, explicitId == null ? { value: node.value } : { id: node.key, value: node.value }), p.ref, node),
289
- });
290
- return null;
380
+ };
381
+ build.specs.push(spec);
382
+ return buildMark(spec);
291
383
  }
292
384
  const scope = useContext(CollectionScopeContext);
293
385
  if (!scope) {
@@ -337,7 +429,7 @@ export function createBranchComponent(CollectionNodeClass, render, useChildren =
337
429
  };
338
430
  build.specs.push(spec);
339
431
  provide(BuildContext, { specs: spec.children, parentKey: key, ids: { n: 0 }, run: build.run }, () => provide(CollectionItemContext, null, () => tracked(build, () => evaluate(() => useChildren(childProps(props, all, id))))));
340
- return null;
432
+ return buildMark(spec);
341
433
  }
342
434
  const scope = useContext(CollectionScopeContext);
343
435
  const key = id ?? generatedId(scope?.parentKey, scope?.ids ?? { n: 0 });
@@ -477,23 +569,136 @@ export function renderNodeAt(collection, key, depends) {
477
569
  // of its region (a sort re-created the table header and body: focus lost) instead of keeping it like a client rebuild.
478
570
  if (isServer)
479
571
  return untrack(() => {
480
- const n = collection()?.getItem(key()) ?? null;
572
+ const n = collection()?.getItem(rowKey(key())) ?? null;
573
+ // DOM-DATA servers: the row's data object (the For iterates keys), registered with the row (`domRowItem`).
574
+ if (n !== null)
575
+ domRowItem(n.value);
481
576
  return n?.render ? n.render(n) : null;
482
577
  });
483
- const node = computed(() => collection()?.getItem(key()) ?? null, { equals: sameRender });
578
+ const node = computed(() => collection()?.getItem(rowKey(key())) ?? null, { equals: sameRender });
484
579
  return serverNow(() => {
485
580
  depends?.();
486
581
  node();
487
582
  // The current node (the cut-off computed keeps the first of equal ones; `depends` re-renders with the latest).
488
- const n = untrack(() => collection()?.getItem(key()) ?? null);
583
+ const n = untrack(() => collection()?.getItem(rowKey(key())) ?? null);
489
584
  return n?.render ? untrack(() => n.render(n)) : null;
490
585
  });
491
586
  }
492
- function renderNodes(collection, parent) {
587
+ /**
588
+ * Lazy collection rows (`resume.lazyRows`, LAZY-T6): a loop over container nodes — a section, a table's header / body (nodes
589
+ * whose children have children) — is not the lazy `For`; the rows below it are (a lazy row is one option / table row, not a
590
+ * whole table body). Read by the server's lazy rows only.
591
+ */
592
+ function leafNodes(collection, parent) {
593
+ const c = resolveCollection(collection);
594
+ if (!c?.getChildren)
595
+ return true;
596
+ for (const n of parent ? c.getChildren(parent.key) : c) {
597
+ if (n.type === "content")
598
+ continue;
599
+ if (n.type === "section")
600
+ return false;
601
+ // No children (built nodes keep `firstChildKey`): no iterator per leaf (LAZY-COST-2: 1000 options ≈ 0.5 ms / render).
602
+ if (n.firstChildKey === null)
603
+ continue;
604
+ for (const child of c.getChildren(n.key)) {
605
+ for (const _ of c.getChildren(child.key))
606
+ return false;
607
+ break;
608
+ }
609
+ }
610
+ return true;
611
+ }
612
+ /**
613
+ * The nodes a row of `n` renders (its stamp covers them): `n`, and a section's items / a table row's cells — a nested `For`
614
+ * inside the row (`CollectionBranch`) that keeps no baseline of its own (`serverRows`).
615
+ */
616
+ function rowNodes(c, n) {
617
+ const row = [n];
618
+ if (n.firstChildKey != null && c.getChildren)
619
+ for (const child of c.getChildren(n.key))
620
+ if (n.type === "section" || child.type === "cell")
621
+ row.push(child);
622
+ return row;
623
+ }
624
+ /** The stamp of a row's nodes (`row-stamps.ts`). */
625
+ const rowStamp = (row) => stamp(row[0], row.length > 1 ? row.slice(1) : null);
626
+ /**
627
+ * Server baseline of a resumed For over collection keys (ROW-RERENDER): `_regionState` with the stamps of the rows the server
628
+ * renders (`row-stamps.ts`) when the collection is an accessor whose build read signals (a static collection never rebuilds:
629
+ * null), recorded with the For's region; none (no slot) for the children of a section / table row (`parentType`). Called by the server and the browser alike (one `_regionState` slot per accessor
630
+ * collection, so the region's other slots keep their positions); the browser gets the server's value back when it
631
+ * re-creates that region (adopting the rows), null otherwise.
632
+ */
633
+ export function serverRows(collection, keys, parentType) {
634
+ // The rows of a section / a table row: covered by the stamp of the row holding them (`rowNodes`). Decided by the
635
+ // parent node's type alone (the same on the server and in the browser: the `_regionState` slot is positional).
636
+ if (typeof collection !== "function" || parentType === "section" || parentType === "item")
637
+ return null;
638
+ return _regionState(() => {
639
+ if (!isServer)
640
+ return null;
641
+ return untrack(() => {
642
+ const c = _trackReads(collection);
643
+ if (!c.signals.length || !c.value)
644
+ return null;
645
+ let out = "";
646
+ for (const k of keys()) {
647
+ const n = c.value.getItem(k);
648
+ out += n ? rowStamp(rowNodes(c.value, n)) : stamp(null);
649
+ }
650
+ return out || null;
651
+ });
652
+ });
653
+ }
654
+ /**
655
+ * The keys of a resumed For over collection keys (`renderNodeAt` rows), given the server baseline (`serverRows`): an adopted
656
+ * server row keeps its key while its node renders like the server's (stamp in the baseline; then `sameRender` against the
657
+ * last node checked), else its key is boxed (`RowBox`, one box per key for good) — a key the adopted list does not know, so
658
+ * the For re-creates that row (rows read `rowKey(key())`). Rows the server did not render get a box too (created anyway).
659
+ * Without a baseline: `keys` itself.
660
+ */
661
+ export function resumedKeys(collection, base, keys) {
662
+ if (base === null || isServer)
663
+ return keys;
664
+ let set = null;
665
+ const boxes = new Map(),
666
+ // The last node (`rowNodes`: + its items / cells) found to render like the server's, per key.
667
+ seen = new Map();
668
+ return () => {
669
+ const list = keys(), c = resolveCollection(collection);
670
+ return untrack(() => {
671
+ set ??= stampSet(base);
672
+ let out = null;
673
+ for (let i = 0; i < list.length; i++) {
674
+ const k = list[i];
675
+ let box = boxes.get(k);
676
+ if (box === undefined) {
677
+ const n = c?.getItem(k) ?? null;
678
+ if (n === null)
679
+ continue;
680
+ const row = rowNodes(c, n), last = seen.get(k);
681
+ // Unchanged since the last check (`sameRender`, node by node): no stamp.
682
+ if (last !== undefined && last.length === row.length && row.every((x, j) => sameRender(last[j], x)))
683
+ continue;
684
+ if (set.has(rowStamp(row))) {
685
+ seen.set(k, row);
686
+ continue;
687
+ }
688
+ seen.delete(k);
689
+ boxes.set(k, (box = { k }));
690
+ }
691
+ (out ??= list.slice())[i] = box;
692
+ }
693
+ return out ?? list;
694
+ });
695
+ };
696
+ }
697
+ function renderNodes(collection, parent, parentType) {
493
698
  // Rows are keyed by primitive keys (stable across rebuilds) so an adopted (resumed) row survives a
494
699
  // collection rebuild; a row re-renders only when its node's render inputs changed (`renderNodeAt`) and parts
495
- // read live data by key.
496
- const each = () => {
700
+ // read live data by key. An adopted row whose node changed since the server render is re-created (`resumedKeys`).
701
+ const keys = () => {
497
702
  const c = resolveCollection(collection);
498
703
  const keys = [];
499
704
  for (const n of parent ? c.getChildren(parent.key) : c)
@@ -501,7 +706,8 @@ function renderNodes(collection, parent) {
501
706
  keys.push(n.key);
502
707
  return keys;
503
708
  };
504
- return (<For each={each} by={(key) => key}>
709
+ const each = resumedKeys(collection, serverRows(collection, keys, parentType), keys);
710
+ return (<For each={each} by={(key) => key} lazy={() => leafNodes(collection, parent)}>
505
711
  {(key) => renderNodeAt(() => resolveCollection(collection), key)}
506
712
  </For>);
507
713
  }
@@ -517,8 +723,10 @@ export const DefaultCollectionRenderer = {
517
723
  },
518
724
  CollectionBranch(props) {
519
725
  const { collection, parent } = props;
520
- if (collection && untrack(() => resolveCollection(collection)?.getItem(parent.key)))
521
- return renderNodes(collection, parent);
726
+ // `parent` may be row data (`{ key }`, a resumable capture), not the node: its type comes from the collection.
727
+ const node = collection ? untrack(() => resolveCollection(collection)?.getItem(parent.key)) : null;
728
+ if (node)
729
+ return renderNodes(collection, parent, node.type);
522
730
  return (parent.content?.() ?? null);
523
731
  },
524
732
  };
@@ -1,4 +1,4 @@
1
- import type { Collection, Node } from "./types.js";
1
+ import type { Collection, Key, Node } from "./types.js";
2
2
  export declare function getChildNodes<T>(node: Node<T>, collection: Collection<Node<T>>): Iterable<Node<T>>;
3
3
  export declare function getFirstItem<T>(iterable: Iterable<T>): T | undefined;
4
4
  export declare function getNthItem<T>(iterable: Iterable<T>, index: number): T | undefined;
@@ -7,3 +7,11 @@ export declare function getLastItem<T>(iterable: Iterable<T>): T | undefined;
7
7
  export declare function compareNodeOrder<T>(collection: Collection<Node<T>>, a: Node<T>, b: Node<T>): number;
8
8
  /** Number of `item` nodes, descending into sections (cached per collection). */
9
9
  export declare function getItemCount<T>(collection: Collection<Node<T>>): number;
10
+ /**
11
+ * Rivet: `[aria-posinset, aria-setsize]` of the item `key` in a virtualized collection (only a window of its items is
12
+ * in the DOM, so the browser cannot count them): its 1-based position among the items of its section and that
13
+ * section's item count; top-level items count among the top-level items. Headers, separators and loaders are not
14
+ * counted. RA useOption / useMenuItem use `node.index + 1` (a section's header counted) of `getItemCount` (every item
15
+ * of every section): equal without sections, per section (role `group`) with them. Cached per committed collection.
16
+ */
17
+ export declare function itemPosition<T>(collection: Collection<Node<T>>, key: Key): readonly [number, number] | undefined;
@@ -79,3 +79,29 @@ export function getItemCount(collection) {
79
79
  countCache.set(collection, counter);
80
80
  return counter;
81
81
  }
82
+ const positionCache = new WeakMap();
83
+ /**
84
+ * Rivet: `[aria-posinset, aria-setsize]` of the item `key` in a virtualized collection (only a window of its items is
85
+ * in the DOM, so the browser cannot count them): its 1-based position among the items of its section and that
86
+ * section's item count; top-level items count among the top-level items. Headers, separators and loaders are not
87
+ * counted. RA useOption / useMenuItem use `node.index + 1` (a section's header counted) of `getItemCount` (every item
88
+ * of every section): equal without sections, per section (role `group`) with them. Cached per committed collection.
89
+ */
90
+ export function itemPosition(collection, key) {
91
+ let cache = positionCache.get(collection);
92
+ if (!cache)
93
+ positionCache.set(collection, (cache = new Map()));
94
+ const hit = cache.get(key);
95
+ if (hit)
96
+ return hit;
97
+ const node = collection.getItem(key);
98
+ if (!node || node.type !== "item")
99
+ return undefined;
100
+ const parent = node.parentKey != null ? collection.getItem(node.parentKey) : null;
101
+ const keys = [];
102
+ for (const sibling of parent ? getChildNodes(parent, collection) : collection)
103
+ if (sibling.type === "item")
104
+ keys.push(sibling.key);
105
+ keys.forEach((k, i) => cache.set(k, [i + 1, keys.length]));
106
+ return cache.get(key);
107
+ }
@@ -0,0 +1,17 @@
1
+ import type { Key, Node } from "./types.js";
2
+ /** Characters per row stamp (24 bits). */
3
+ export declare const STAMP = 4;
4
+ /**
5
+ * The row stamp of a built node: a 24-bit hash of what `sameRender` compares (type, key, text, `rendered`, props, value), as
6
+ * 4 characters. A section's stamp covers its child nodes too (`children`): its rows are a For the section part creates in
7
+ * its body (ListBoxSection), which a resumed page never re-runs, so a changed child re-creates the section row.
8
+ */
9
+ export declare function stamp(n: Node<unknown> | null | undefined, children?: Iterable<Node<unknown>> | null): string;
10
+ /** The set of stamps in a baseline string. */
11
+ export declare function stampSet(base: string): Set<string>;
12
+ /** A row key the browser re-created (its node changed since the server render): a fresh For key for the same collection key. */
13
+ export interface RowBox {
14
+ readonly k: Key;
15
+ }
16
+ /** The collection key of a `For` row key (`resumedKeys` boxes the keys of rows it re-creates). */
17
+ export declare const rowKey: (key: Key | RowBox) => Key;