@multiplatform.one/components 7.10.0 → 7.11.0

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 (65) hide show
  1. package/README.md +98 -0
  2. package/dist/cjs/SheetModal/index.cjs +22 -3
  3. package/dist/cjs/SheetModal/index.native.js +50 -10
  4. package/dist/cjs/SheetModal/index.native.js.map +1 -1
  5. package/dist/cjs/Tabs/index.cjs +33 -12
  6. package/dist/cjs/Tabs/index.native.js +37 -12
  7. package/dist/cjs/Tabs/index.native.js.map +1 -1
  8. package/dist/cjs/layouts/tags.native.js.map +1 -1
  9. package/dist/cjs/views/TreeView.cjs +175 -43
  10. package/dist/cjs/views/TreeView.native.js +181 -46
  11. package/dist/cjs/views/TreeView.native.js.map +1 -1
  12. package/dist/esm/SheetModal/index.mjs +22 -3
  13. package/dist/esm/SheetModal/index.mjs.map +1 -1
  14. package/dist/esm/SheetModal/index.native.js +51 -11
  15. package/dist/esm/SheetModal/index.native.js.map +1 -1
  16. package/dist/esm/Tabs/index.mjs +33 -12
  17. package/dist/esm/Tabs/index.mjs.map +1 -1
  18. package/dist/esm/Tabs/index.native.js +37 -12
  19. package/dist/esm/Tabs/index.native.js.map +1 -1
  20. package/dist/esm/layouts/tags.mjs.map +1 -1
  21. package/dist/esm/layouts/tags.native.js.map +1 -1
  22. package/dist/esm/views/TreeView.mjs +175 -43
  23. package/dist/esm/views/TreeView.mjs.map +1 -1
  24. package/dist/esm/views/TreeView.native.js +181 -46
  25. package/dist/esm/views/TreeView.native.js.map +1 -1
  26. package/dist/jsx/SheetModal/index.mjs +22 -3
  27. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  28. package/dist/jsx/SheetModal/index.native.js +50 -10
  29. package/dist/jsx/SheetModal/index.native.js.map +1 -1
  30. package/dist/jsx/Tabs/index.mjs +33 -12
  31. package/dist/jsx/Tabs/index.mjs.map +1 -1
  32. package/dist/jsx/Tabs/index.native.js +37 -12
  33. package/dist/jsx/Tabs/index.native.js.map +1 -1
  34. package/dist/jsx/layouts/tags.mjs.map +1 -1
  35. package/dist/jsx/layouts/tags.native.js.map +1 -1
  36. package/dist/jsx/views/TreeView.mjs +175 -43
  37. package/dist/jsx/views/TreeView.mjs.map +1 -1
  38. package/dist/jsx/views/TreeView.native.js +181 -46
  39. package/dist/jsx/views/TreeView.native.js.map +1 -1
  40. package/package.json +11 -11
  41. package/src/Accordion/index.tsx +1 -1
  42. package/src/SheetModal/SheetModal.fill.spec.tsx +147 -0
  43. package/src/SheetModal/SheetModal.ownedBody.spec.tsx +200 -0
  44. package/src/SheetModal/SheetModal.stories.spec.tsx +23 -2
  45. package/src/SheetModal/SheetModal.stories.tsx +275 -2
  46. package/src/SheetModal/index.native.tsx +50 -6
  47. package/src/SheetModal/index.tsx +40 -3
  48. package/src/Tabs/Tabs.lifecycle.spec.tsx +123 -0
  49. package/src/Tabs/Tabs.measurement.spec.tsx +186 -0
  50. package/src/Tabs/Tabs.stories.tsx +43 -0
  51. package/src/Tabs/index.tsx +42 -13
  52. package/src/layouts/tags.tsx +2 -1
  53. package/src/views/TreeView.reveal.spec.tsx +7 -0
  54. package/src/views/TreeView.reveal.stories.tsx +134 -0
  55. package/src/views/TreeView.scroll-index.spec.tsx +246 -0
  56. package/src/views/TreeView.scroll.spec.tsx +711 -50
  57. package/src/views/TreeView.tsx +230 -49
  58. package/types/SheetModal/index.d.ts +8 -1
  59. package/types/SheetModal/index.d.ts.map +1 -1
  60. package/types/SheetModal/index.native.d.ts +1 -1
  61. package/types/SheetModal/index.native.d.ts.map +1 -1
  62. package/types/Tabs/index.d.ts.map +1 -1
  63. package/types/layouts/tags.d.ts.map +1 -1
  64. package/types/views/TreeView.d.ts +10 -1
  65. package/types/views/TreeView.d.ts.map +1 -1
@@ -1,7 +1,8 @@
1
1
  import type { FlashListProps, FlashListRef } from "@shopify/flash-list";
2
- import { act, cleanup, screen, waitFor } from "@testing-library/react";
2
+ import { act, cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { renderWithProviders } from "@multiplatform.one/test-utils";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { StrictMode } from "react";
5
6
  import { TreeView, type TreeNode, type TreeViewProps } from "./TreeView";
6
7
 
7
8
  const observed = vi.hoisted(() => ({
@@ -10,7 +11,7 @@ const observed = vi.hoisted(() => ({
10
11
  }));
11
12
 
12
13
  // Keep the real list and rows. Happy DOM cannot measure them; only the public
13
- // geometry/scroll boundary is modeled here. The Storybook fixture needs browser proof.
14
+ // geometry/scroll/load boundary is modeled here. The Storybook fixture needs browser proof.
14
15
  vi.mock("@shopify/flash-list", async (importOriginal) => {
15
16
  const actual = await importOriginal<typeof import("@shopify/flash-list")>();
16
17
  const React = await import("react");
@@ -26,7 +27,11 @@ vi.mock("@shopify/flash-list", async (importOriginal) => {
26
27
  },
27
28
  [ref],
28
29
  );
29
- return React.createElement(actual.FlashList, { ...props, ref: captureRef });
30
+ return React.createElement(actual.FlashList, {
31
+ ...props,
32
+ onLoad: undefined,
33
+ ref: captureRef,
34
+ });
30
35
  }),
31
36
  };
32
37
  });
@@ -55,55 +60,114 @@ async function settle() {
55
60
  });
56
61
  }
57
62
 
58
- function measuredTree(initial: Partial<TreeViewProps> = {}) {
63
+ function measuredTree(
64
+ initial: Partial<TreeViewProps> = {},
65
+ synchronousScroll = true,
66
+ strict = false,
67
+ ) {
59
68
  const props = { nodes, expandByDefault: true, height: 240, ...initial };
60
- const rendered = renderWithProviders(<TreeView {...props} />);
61
- expect(observed.list).not.toBeNull();
62
- const list = observed.list!;
63
- const geometry = { offset: 0, height: 240, width: 320, measured: true, hostVisible: true };
64
- vi.spyOn(screen.getByRole("tree"), "getBoundingClientRect").mockImplementation(() => ({
65
- width: geometry.hostVisible ? geometry.width : 0,
66
- height: geometry.hostVisible ? geometry.height : 0,
67
- x: 0,
68
- y: 0,
69
- top: 0,
70
- left: 0,
71
- bottom: geometry.height,
72
- right: geometry.width,
73
- toJSON: () => ({}),
74
- }));
75
- vi.spyOn(list, "getWindowSize").mockImplementation(() => geometry);
76
- vi.spyOn(list, "getFirstItemOffset").mockReturnValue(0);
77
- vi.spyOn(list, "getAbsoluteLastScrollOffset").mockImplementation(() => geometry.offset);
78
- vi.spyOn(list, "getLayout").mockImplementation((index) => ({
79
- x: 0,
80
- y: index * 400,
81
- width: geometry.width,
82
- height: index % 2 ? 32 : 80,
83
- isHeightMeasured: geometry.measured,
84
- isWidthMeasured: true,
85
- }));
86
- vi.spyOn(list, "computeVisibleIndices").mockImplementation(() => ({
87
- startIndex: Math.floor(geometry.offset / 400),
88
- endIndex: Math.floor((geometry.offset + geometry.height) / 400),
89
- }));
90
- const scroll = vi.spyOn(list, "scrollToOffset").mockImplementation(({ offset }) => {
91
- geometry.offset = offset;
92
- observed.props?.onScroll?.({ nativeEvent: { contentOffset: { y: offset } } } as never);
93
- });
94
- const oldScroll = vi.spyOn(list, "scrollToIndex");
69
+ const view = (next: Partial<TreeViewProps> = {}) =>
70
+ strict ? (
71
+ <StrictMode>
72
+ <TreeView {...props} {...next} />
73
+ </StrictMode>
74
+ ) : (
75
+ <TreeView {...props} {...next} />
76
+ );
77
+ const rendered = renderWithProviders(view());
78
+ const geometry = {
79
+ offset: 0,
80
+ height: 240,
81
+ width: 320,
82
+ contentHeight: 2000,
83
+ firstItemOffset: 0,
84
+ measured: true,
85
+ hostVisible: true,
86
+ };
87
+ const emitScroll = (offset: number) =>
88
+ act(() => {
89
+ geometry.offset = offset;
90
+ observed.props?.onScroll?.({
91
+ nativeEvent: {
92
+ contentOffset: { x: 0, y: offset },
93
+ contentSize: { width: geometry.width, height: geometry.contentHeight },
94
+ layoutMeasurement: { width: geometry.width, height: geometry.height },
95
+ },
96
+ } as never);
97
+ });
98
+ const scroll = vi.fn(({ offset }: { offset: number; animated?: boolean }) => {
99
+ if (synchronousScroll) emitScroll(offset);
100
+ });
101
+ const oldScroll = vi.fn(async () => {});
102
+ let attached: FlashListRef<unknown> | null = null;
103
+ const attach = () => {
104
+ const list = observed.list;
105
+ if (!list || list === attached) return;
106
+ attached = list;
107
+ vi.spyOn(screen.getByRole("tree"), "getBoundingClientRect").mockImplementation(() => ({
108
+ width: geometry.hostVisible ? geometry.width : 0,
109
+ height: geometry.hostVisible ? geometry.height : 0,
110
+ x: 0,
111
+ y: 0,
112
+ top: 0,
113
+ left: 0,
114
+ bottom: geometry.height,
115
+ right: geometry.width,
116
+ toJSON: () => ({}),
117
+ }));
118
+ vi.spyOn(list, "getWindowSize").mockImplementation(() => geometry);
119
+ vi.spyOn(list, "getFirstItemOffset").mockImplementation(() => geometry.firstItemOffset);
120
+ vi.spyOn(list, "getChildContainerDimensions").mockImplementation(() => ({
121
+ width: geometry.width,
122
+ height: geometry.contentHeight - geometry.firstItemOffset,
123
+ }));
124
+ vi.spyOn(list, "getAbsoluteLastScrollOffset").mockImplementation(() => geometry.offset);
125
+ vi.spyOn(list, "getLayout").mockImplementation((index) => ({
126
+ x: 0,
127
+ y: index * 400,
128
+ width: geometry.width,
129
+ height: index % 2 ? 32 : 80,
130
+ isHeightMeasured: geometry.measured,
131
+ isWidthMeasured: true,
132
+ }));
133
+ vi.spyOn(list, "computeVisibleIndices").mockImplementation(() => ({
134
+ startIndex: Math.floor(geometry.offset / 400),
135
+ endIndex: Math.floor((geometry.offset + geometry.height) / 400),
136
+ }));
137
+ vi.spyOn(list, "scrollToOffset").mockImplementation(scroll);
138
+ vi.spyOn(list, "scrollToIndex").mockImplementation(oldScroll);
139
+ };
140
+ attach();
95
141
  const commitLayout = () => act(() => observed.props?.onCommitLayoutEffect?.());
96
- const rerender = (next: Partial<TreeViewProps>) =>
97
- rendered.rerender(<TreeView {...props} {...next} />);
98
- return { ...rendered, rerender, geometry, scroll, oldScroll, commitLayout };
142
+ const load = () => act(() => observed.props?.onLoad?.({ elapsedTimeInMs: 1 }));
143
+ const contentSize = () =>
144
+ act(() => observed.props?.onContentSizeChange?.(geometry.width, geometry.contentHeight));
145
+ const rerender = (next: Partial<TreeViewProps>) => {
146
+ rendered.rerender(view(next));
147
+ attach();
148
+ };
149
+ return {
150
+ ...rendered,
151
+ rerender,
152
+ geometry,
153
+ scroll,
154
+ oldScroll,
155
+ commitLayout,
156
+ load,
157
+ contentSize,
158
+ emitScroll,
159
+ };
99
160
  }
100
161
 
101
162
  describe("TreeView measured reveal scheduling (modeled geometry)", () => {
102
163
  it("scrolls the real mounted target and consumes the request only after measurement", async () => {
103
- const tree = measuredTree();
164
+ const complete = vi.fn();
165
+ const tree = measuredTree({ onRevealComplete: complete });
104
166
  tree.geometry.measured = false;
105
167
  tree.rerender({ revealRequest: { id: "item", requestId: 1 }, selectedId: "item" });
106
168
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledWith({ offset: 640, animated: false }));
169
+ await settle();
170
+ expect(complete).not.toHaveBeenCalled();
107
171
  expect(screen.getByText("Item").closest('[role="treeitem"]')).toHaveAttribute(
108
172
  "aria-selected",
109
173
  "true",
@@ -112,47 +176,63 @@ describe("TreeView measured reveal scheduling (modeled geometry)", () => {
112
176
  tree.geometry.measured = true;
113
177
  tree.commitLayout();
114
178
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(2));
115
- await settle();
179
+ await waitFor(() =>
180
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "item", requestId: 1 }),
181
+ );
116
182
  tree.geometry.offset = 0;
117
183
  tree.commitLayout();
118
184
  await settle();
119
185
  expect(tree.scroll).toHaveBeenCalledTimes(2);
186
+ expect(complete).toHaveBeenCalledTimes(1);
120
187
  expect(tree.oldScroll).not.toHaveBeenCalled();
121
188
  });
122
189
 
123
190
  it("repeats the same target only when requestId changes, without stealing focus", async () => {
124
- const tree = measuredTree();
191
+ const complete = vi.fn();
192
+ const tree = measuredTree({ onRevealComplete: complete });
125
193
  tree.rerender({ revealRequest: { id: "item", requestId: 1 } });
126
194
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
127
- await settle();
195
+ await waitFor(() =>
196
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "item", requestId: 1 }),
197
+ );
128
198
  tree.geometry.offset = 0;
129
199
  tree.rerender({ revealRequest: { id: "item", requestId: 1 } });
130
200
  tree.commitLayout();
131
201
  await settle();
132
202
  expect(tree.scroll).toHaveBeenCalledTimes(1);
203
+ expect(complete).toHaveBeenCalledTimes(1);
133
204
  tree.rerender({ revealRequest: { id: "item", requestId: 2 } });
134
205
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(2));
206
+ await waitFor(() => expect(complete).toHaveBeenNthCalledWith(2, { id: "item", requestId: 2 }));
207
+ expect(complete).toHaveBeenCalledTimes(2);
135
208
  expect(screen.getByRole("tree")).not.toHaveFocus();
136
209
  });
137
210
 
138
211
  it("waits for both usable list dimensions and a visible host", async () => {
139
- const tree = measuredTree();
212
+ const complete = vi.fn();
213
+ const tree = measuredTree({ onRevealComplete: complete });
140
214
  tree.geometry.height = 0;
141
215
  tree.rerender({ revealRequest: { id: "item", requestId: 1 } });
142
216
  await settle();
143
217
  expect(tree.scroll).not.toHaveBeenCalled();
218
+ expect(complete).not.toHaveBeenCalled();
144
219
  tree.geometry.height = 240;
145
220
  tree.geometry.hostVisible = false;
146
221
  tree.commitLayout();
147
222
  await settle();
148
223
  expect(tree.scroll).not.toHaveBeenCalled();
224
+ expect(complete).not.toHaveBeenCalled();
149
225
  tree.geometry.hostVisible = true;
150
226
  tree.commitLayout();
151
227
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
228
+ await waitFor(() =>
229
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "item", requestId: 1 }),
230
+ );
152
231
  });
153
232
 
154
233
  it("cancels queued A when B supersedes it and never starts an uncancellable index scroll", async () => {
155
- const tree = measuredTree();
234
+ const complete = vi.fn();
235
+ const tree = measuredTree({ onRevealComplete: complete });
156
236
  act(() => {
157
237
  tree.rerender({ revealRequest: { id: "item", requestId: 10 } });
158
238
  tree.commitLayout();
@@ -166,10 +246,12 @@ describe("TreeView measured reveal scheduling (modeled geometry)", () => {
166
246
  await settle();
167
247
  expect(tree.scroll).toHaveBeenCalledTimes(1);
168
248
  expect(tree.oldScroll).not.toHaveBeenCalled();
249
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "other-item", requestId: 11 });
169
250
  });
170
251
 
171
252
  it("removing a request cancels its pending scroll", async () => {
172
- const tree = measuredTree();
253
+ const complete = vi.fn();
254
+ const tree = measuredTree({ onRevealComplete: complete });
173
255
  tree.geometry.height = 0;
174
256
  tree.rerender({ revealRequest: { id: "item", requestId: 1 } });
175
257
  await settle();
@@ -178,12 +260,17 @@ describe("TreeView measured reveal scheduling (modeled geometry)", () => {
178
260
  tree.commitLayout();
179
261
  await settle();
180
262
  expect(tree.scroll).not.toHaveBeenCalled();
263
+ expect(complete).not.toHaveBeenCalled();
181
264
  tree.rerender({ revealRequest: { id: "item", requestId: 2 } });
182
265
  await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
266
+ await waitFor(() =>
267
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "item", requestId: 2 }),
268
+ );
183
269
  });
184
270
 
185
271
  it("does not schedule more reveal work while its row refs detach on unmount", async () => {
186
- const tree = measuredTree();
272
+ const complete = vi.fn();
273
+ const tree = measuredTree({ onRevealComplete: complete });
187
274
  tree.geometry.height = 0;
188
275
  tree.rerender({ revealRequest: { id: "item", requestId: 1 } });
189
276
  await settle();
@@ -192,5 +279,579 @@ describe("TreeView measured reveal scheduling (modeled geometry)", () => {
192
279
  expect(frame).not.toHaveBeenCalled();
193
280
  await settle();
194
281
  expect(tree.scroll).not.toHaveBeenCalled();
282
+ expect(complete).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it("waits for controlled expansion before acknowledging a measured target", async () => {
286
+ const complete = vi.fn();
287
+ const change = vi.fn();
288
+ const tree = measuredTree({
289
+ expandedIds: [],
290
+ onExpandedIdsChange: change,
291
+ onRevealComplete: complete,
292
+ });
293
+ const revealRequest = { id: "item", requestId: 1 };
294
+ tree.rerender({ revealRequest });
295
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(["folders", "archive"]));
296
+ expect(complete).not.toHaveBeenCalled();
297
+ expect(tree.scroll).not.toHaveBeenCalled();
298
+ tree.rerender({ revealRequest, expandedIds: ["folders", "archive"] });
299
+ await waitFor(() => expect(complete).toHaveBeenCalledExactlyOnceWith(revealRequest));
300
+ });
301
+
302
+ it("waits for the measured row to enter the visible index range", async () => {
303
+ const complete = vi.fn();
304
+ const tree = measuredTree({ onRevealComplete: complete });
305
+ const visible = vi
306
+ .spyOn(observed.list!, "computeVisibleIndices")
307
+ .mockReturnValue({ startIndex: 0, endIndex: 0 });
308
+ const revealRequest = { id: "item", requestId: 1 };
309
+ tree.rerender({ revealRequest });
310
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
311
+ await settle();
312
+ expect(complete).not.toHaveBeenCalled();
313
+ visible.mockReturnValue({ startIndex: 1, endIndex: 2 });
314
+ tree.commitLayout();
315
+ await waitFor(() => expect(complete).toHaveBeenCalledExactlyOnceWith(revealRequest));
316
+ });
317
+
318
+ it("does not acknowledge a missing target when an already visible request supersedes it", async () => {
319
+ const complete = vi.fn();
320
+ const tree = measuredTree({ onRevealComplete: complete });
321
+ tree.rerender({ revealRequest: { id: "missing", requestId: 1 } });
322
+ await settle();
323
+ expect(complete).not.toHaveBeenCalled();
324
+ tree.rerender({ revealRequest: { id: "folders", requestId: 2 } });
325
+ await waitFor(() =>
326
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "folders", requestId: 2 }),
327
+ );
328
+ expect(tree.scroll).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it("uses the current callback and marks completion before the callback schedules more layout", async () => {
332
+ const oldComplete = vi.fn();
333
+ const tree = measuredTree({ onRevealComplete: oldComplete });
334
+ tree.geometry.measured = false;
335
+ const revealRequest = { id: "item", requestId: 1 };
336
+ tree.rerender({ revealRequest });
337
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
338
+ const complete = vi.fn(() => tree.commitLayout());
339
+ tree.rerender({ revealRequest, onRevealComplete: complete });
340
+ tree.geometry.measured = true;
341
+ tree.commitLayout();
342
+ await waitFor(() => expect(complete).toHaveBeenCalledExactlyOnceWith(revealRequest));
343
+ const laterComplete = vi.fn();
344
+ tree.rerender({ revealRequest, onRevealComplete: laterComplete });
345
+ tree.commitLayout();
346
+ await settle();
347
+ expect(oldComplete).not.toHaveBeenCalled();
348
+ expect(laterComplete).not.toHaveBeenCalled();
349
+ expect(complete).toHaveBeenCalledTimes(1);
350
+ });
351
+ });
352
+
353
+ describe("TreeView retained scroll (actual list, modeled geometry)", () => {
354
+ it("observes ordinary scrolling without reorder/reveal, deduplicates, and uses the current callback", async () => {
355
+ const change = vi.fn();
356
+ const tree = measuredTree({ onScrollOffsetChange: change });
357
+ tree.load();
358
+ await settle();
359
+ change.mockClear();
360
+ tree.emitScroll(128.5);
361
+ tree.emitScroll(128.5);
362
+ tree.emitScroll(256.25);
363
+ expect(change.mock.calls).toEqual([[128.5], [256.25]]);
364
+ const later = vi.fn();
365
+ tree.rerender({ onScrollOffsetChange: later });
366
+ tree.commitLayout();
367
+ await settle();
368
+ expect(later).not.toHaveBeenCalled();
369
+ tree.emitScroll(300.25);
370
+ expect(later).toHaveBeenCalledExactlyOnceWith(300.25);
371
+ expect(tree.scroll).not.toHaveBeenCalled();
372
+ expect(screen.getByRole("tree").parentElement).not.toHaveAttribute("initialScrollOffset");
373
+ });
374
+
375
+ it("restores a saved manual offset after a real remount and never replays callback echoes", async () => {
376
+ let saved = 0;
377
+ const remember = vi.fn((offset: number) => {
378
+ saved = offset;
379
+ });
380
+ const first = measuredTree({ onScrollOffsetChange: remember });
381
+ first.load();
382
+ await settle();
383
+ first.emitScroll(640.5);
384
+ expect(saved).toBe(640.5);
385
+ first.unmount();
386
+ remember.mockClear();
387
+ const tree = measuredTree(
388
+ { initialScrollOffset: saved, onScrollOffsetChange: remember },
389
+ false,
390
+ );
391
+ tree.load();
392
+ await waitFor(() =>
393
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false }),
394
+ );
395
+ expect(remember).not.toHaveBeenCalled();
396
+ tree.commitLayout();
397
+ await settle();
398
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
399
+ tree.emitScroll(640.5);
400
+ await settle();
401
+ expect(remember).toHaveBeenCalledExactlyOnceWith(640.5);
402
+ tree.emitScroll(812.25);
403
+ tree.rerender({ initialScrollOffset: saved });
404
+ tree.commitLayout();
405
+ tree.contentSize();
406
+ await settle();
407
+ expect(saved).toBe(812.25);
408
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
409
+ });
410
+
411
+ it("waits through empty data, load, zero viewport and hidden host without overwriting memory", async () => {
412
+ const change = vi.fn();
413
+ const tree = measuredTree({
414
+ nodes: [],
415
+ initialScrollOffset: 640.5,
416
+ onScrollOffsetChange: change,
417
+ });
418
+ await settle();
419
+ expect(change).not.toHaveBeenCalled();
420
+ tree.rerender({ nodes });
421
+ tree.geometry.height = 0;
422
+ tree.load();
423
+ tree.emitScroll(0);
424
+ await settle();
425
+ expect(tree.scroll).not.toHaveBeenCalled();
426
+ tree.geometry.height = 240;
427
+ tree.geometry.hostVisible = false;
428
+ tree.commitLayout();
429
+ await settle();
430
+ expect(tree.scroll).not.toHaveBeenCalled();
431
+ expect(change).not.toHaveBeenCalled();
432
+ tree.geometry.hostVisible = true;
433
+ tree.commitLayout();
434
+ await waitFor(() =>
435
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false }),
436
+ );
437
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(640.5));
195
438
  });
439
+
440
+ it.each([
441
+ [9999, 1000, 760],
442
+ [-3, 1000, 0],
443
+ [Number.NaN, 1000, 0],
444
+ [Number.POSITIVE_INFINITY, 1000, 0],
445
+ [125.75, 1000, 125.75],
446
+ [0.5, 1000, 0.5],
447
+ [900, 120, 0],
448
+ ])("clamps seed %s using content %s to observed %s", async (seed, contentHeight, expected) => {
449
+ const change = vi.fn();
450
+ const tree = measuredTree({ initialScrollOffset: seed, onScrollOffsetChange: change });
451
+ tree.geometry.contentHeight = contentHeight;
452
+ tree.load();
453
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(expected));
454
+ if (expected === 0) expect(tree.scroll).not.toHaveBeenCalled();
455
+ else expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: expected, animated: false });
456
+ });
457
+
458
+ it("does not clamp a seed against provisional empty content before load", async () => {
459
+ const change = vi.fn();
460
+ const tree = measuredTree({ initialScrollOffset: 900, onScrollOffsetChange: change });
461
+ tree.geometry.contentHeight = 0;
462
+ tree.contentSize();
463
+ tree.commitLayout();
464
+ await settle();
465
+ expect(tree.scroll).not.toHaveBeenCalled();
466
+ expect(change).not.toHaveBeenCalled();
467
+ tree.geometry.contentHeight = 1040;
468
+ tree.geometry.firstItemOffset = 40;
469
+ tree.contentSize();
470
+ await settle();
471
+ expect(tree.scroll).not.toHaveBeenCalled();
472
+ tree.load();
473
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(800));
474
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 800, animated: false });
475
+ });
476
+
477
+ it("freezes the initial seed while pending and ignores subsequent additions or replacements", async () => {
478
+ const tree = measuredTree({ initialScrollOffset: 640.5 });
479
+ tree.rerender({ initialScrollOffset: 900 });
480
+ tree.load();
481
+ await waitFor(() =>
482
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false }),
483
+ );
484
+ tree.emitScroll(700);
485
+ tree.rerender({ initialScrollOffset: 20, nodes: [...nodes], height: 320 });
486
+ tree.geometry.height = 320;
487
+ tree.commitLayout();
488
+ await settle();
489
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
490
+ tree.unmount();
491
+ const absent = measuredTree();
492
+ absent.rerender({ initialScrollOffset: 900 });
493
+ absent.load();
494
+ await settle();
495
+ expect(absent.scroll).not.toHaveBeenCalled();
496
+ });
497
+
498
+ it("gives a pending reveal precedence and never resurrects the seed after cancellation", async () => {
499
+ const complete = vi.fn();
500
+ const change = vi.fn();
501
+ const tree = measuredTree({
502
+ initialScrollOffset: 640.5,
503
+ revealRequest: { id: "missing", requestId: 1 },
504
+ onRevealComplete: complete,
505
+ onScrollOffsetChange: change,
506
+ });
507
+ tree.load();
508
+ await settle();
509
+ expect(tree.scroll).not.toHaveBeenCalled();
510
+ tree.rerender({ revealRequest: undefined });
511
+ tree.commitLayout();
512
+ await settle();
513
+ expect(tree.scroll).not.toHaveBeenCalled();
514
+ tree.rerender({ revealRequest: { id: "other-item", requestId: 2 } });
515
+ await waitFor(() =>
516
+ expect(complete).toHaveBeenCalledExactlyOnceWith({ id: "other-item", requestId: 2 }),
517
+ );
518
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 1440, animated: false });
519
+ expect(change).toHaveBeenLastCalledWith(1440);
520
+ tree.emitScroll(300);
521
+ tree.rerender({ revealRequest: undefined });
522
+ tree.commitLayout();
523
+ await settle();
524
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
525
+ });
526
+
527
+ it("cancels queued restoration when a new reveal arrives", async () => {
528
+ const tree = measuredTree({ initialScrollOffset: 640.5 });
529
+ act(() => {
530
+ tree.load();
531
+ tree.rerender({ revealRequest: { id: "other-item", requestId: 3 } });
532
+ });
533
+ await waitFor(() =>
534
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 1440, animated: false }),
535
+ );
536
+ });
537
+
538
+ it.each(["drag", "movement", "keyboard"])(
539
+ "yields pending restoration to newer %s interaction",
540
+ async (kind) => {
541
+ const tree = measuredTree({ initialScrollOffset: 640.5 });
542
+ act(() => {
543
+ tree.load();
544
+ if (kind === "drag")
545
+ observed.props?.onScrollBeginDrag?.({
546
+ nativeEvent: { contentOffset: { y: 0 } },
547
+ } as never);
548
+ else if (kind === "movement") tree.emitScroll(80);
549
+ else fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
550
+ });
551
+ await settle();
552
+ expect(tree.scroll).not.toHaveBeenCalled();
553
+ if (kind === "keyboard") expect(tree.oldScroll).toHaveBeenCalled();
554
+ },
555
+ );
556
+
557
+ it("recomputes bounds while pending but does not replay after later shrink and growth", async () => {
558
+ const change = vi.fn();
559
+ const tree = measuredTree({ initialScrollOffset: 9999, onScrollOffsetChange: change }, false);
560
+ tree.geometry.contentHeight = 1000;
561
+ tree.load();
562
+ await waitFor(() =>
563
+ expect(tree.scroll).toHaveBeenLastCalledWith({ offset: 760, animated: false }),
564
+ );
565
+ tree.geometry.contentHeight = 500;
566
+ tree.contentSize();
567
+ await waitFor(() =>
568
+ expect(tree.scroll).toHaveBeenLastCalledWith({ offset: 260, animated: false }),
569
+ );
570
+ tree.emitScroll(260);
571
+ await settle();
572
+ expect(change).toHaveBeenCalledExactlyOnceWith(260);
573
+ tree.geometry.contentHeight = 1000;
574
+ tree.contentSize();
575
+ tree.commitLayout();
576
+ await settle();
577
+ expect(tree.scroll).toHaveBeenCalledTimes(2);
578
+ tree.emitScroll(Number.NaN);
579
+ tree.emitScroll(-25);
580
+ tree.emitScroll(10000);
581
+ expect(change.mock.calls).toEqual([[260], [0], [760]]);
582
+ });
583
+
584
+ it("does not emit or schedule restoration during teardown or a callback-triggered unmount", async () => {
585
+ const change = vi.fn();
586
+ const pending = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change });
587
+ pending.load();
588
+ pending.unmount();
589
+ await settle();
590
+ expect(pending.scroll).not.toHaveBeenCalled();
591
+ expect(change).not.toHaveBeenCalled();
592
+ const tree = measuredTree({
593
+ initialScrollOffset: 640.5,
594
+ onScrollOffsetChange: () => tree.unmount(),
595
+ });
596
+ tree.load();
597
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
598
+ await settle();
599
+ expect(screen.queryByRole("tree")).toBeNull();
600
+ });
601
+ });
602
+
603
+ describe("TreeView retained scroll arbitration", () => {
604
+ it("confirms a command by fresh layout readback without a synthetic scroll event", async () => {
605
+ const change = vi.fn();
606
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change }, false);
607
+ tree.load();
608
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
609
+ expect(change).not.toHaveBeenCalled();
610
+ tree.geometry.offset = 640.5;
611
+ tree.commitLayout();
612
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(640.5));
613
+ tree.geometry.offset = 500;
614
+ tree.geometry.contentHeight = 600;
615
+ tree.contentSize();
616
+ await waitFor(() => expect(change).toHaveBeenLastCalledWith(360));
617
+ tree.geometry.contentHeight = 2000;
618
+ tree.contentSize();
619
+ await settle();
620
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
621
+ });
622
+
623
+ it.each(["wheel", "pointer", "drag"])(
624
+ "yields an unacknowledged command to %s without retrying on resize",
625
+ async (kind) => {
626
+ const change = vi.fn();
627
+ const tree = measuredTree({ initialScrollOffset: 9999, onScrollOffsetChange: change }, false);
628
+ tree.load();
629
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
630
+ if (kind === "wheel") fireEvent.wheel(screen.getByRole("tree"), { deltaY: 20 });
631
+ else if (kind === "pointer") fireEvent.pointerDown(screen.getByRole("tree"));
632
+ else act(() => observed.props?.onScrollBeginDrag?.({} as never));
633
+ tree.emitScroll(300.5);
634
+ tree.geometry.contentHeight = 1000;
635
+ tree.contentSize();
636
+ await settle();
637
+ expect(change).toHaveBeenCalledExactlyOnceWith(300.5);
638
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
639
+ },
640
+ );
641
+
642
+ it("does not rearm consumed restoration when the mounted tree loses and regains its data", async () => {
643
+ const change = vi.fn();
644
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change });
645
+ tree.load();
646
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(640.5));
647
+ tree.rerender({ nodes: [] });
648
+ tree.geometry.offset = 0;
649
+ tree.rerender({ nodes });
650
+ tree.load();
651
+ tree.commitLayout();
652
+ await settle();
653
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
654
+ expect(change).toHaveBeenLastCalledWith(0);
655
+ });
656
+
657
+ it("retains one restoration through Strict Mode effect replay and ignores detached events", async () => {
658
+ const change = vi.fn();
659
+ const tree = measuredTree(
660
+ { initialScrollOffset: 640.5, onScrollOffsetChange: change },
661
+ true,
662
+ true,
663
+ );
664
+ tree.load();
665
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(640.5));
666
+ const detached = observed.props;
667
+ tree.unmount();
668
+ act(() => {
669
+ detached?.onLoad?.({ elapsedTimeInMs: 1 });
670
+ detached?.onCommitLayoutEffect?.();
671
+ detached?.onScroll?.({ nativeEvent: { contentOffset: { y: 900 } } } as never);
672
+ });
673
+ await settle();
674
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
675
+ expect(change).toHaveBeenCalledTimes(1);
676
+ });
677
+
678
+ it("reports actual keyboard and drag-list movements without publishing index requests", async () => {
679
+ const change = vi.fn();
680
+ const tree = measuredTree({ onNodeMove: vi.fn(), onScrollOffsetChange: change });
681
+ tree.load();
682
+ await settle();
683
+ change.mockClear();
684
+ fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
685
+ expect(tree.oldScroll).toHaveBeenCalled();
686
+ expect(change).not.toHaveBeenCalled();
687
+ tree.emitScroll(300.25);
688
+ act(() => observed.props?.onScrollBeginDrag?.({} as never));
689
+ tree.emitScroll(400.5);
690
+ expect(change.mock.calls).toEqual([[300.25], [400.5]]);
691
+ });
692
+ });
693
+
694
+ it("retries an unacknowledged target only when real bounds change", async () => {
695
+ const change = vi.fn();
696
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change }, false);
697
+ tree.load();
698
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
699
+ tree.geometry.width = 400;
700
+ tree.commitLayout();
701
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(2));
702
+ tree.commitLayout();
703
+ tree.contentSize();
704
+ await settle();
705
+ expect(tree.scroll).toHaveBeenCalledTimes(2);
706
+ expect(change).not.toHaveBeenCalled();
707
+ tree.emitScroll(641);
708
+ expect(change).toHaveBeenCalledExactlyOnceWith(641);
709
+ tree.geometry.width = 420;
710
+ tree.commitLayout();
711
+ await settle();
712
+ expect(tree.scroll).toHaveBeenCalledTimes(2);
713
+ });
714
+
715
+ it("keeps observing a frozen pending seed when the initial prop is removed", async () => {
716
+ const tree = measuredTree({ initialScrollOffset: 640.5 }, false);
717
+ tree.rerender({ initialScrollOffset: undefined });
718
+ expect(observed.props?.onScroll).toBeTypeOf("function");
719
+ tree.load();
720
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
721
+ tree.emitScroll(640.5);
722
+ tree.geometry.width = 400;
723
+ tree.commitLayout();
724
+ await settle();
725
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
726
+ });
727
+
728
+ it("retries a pending seed for a newly attached list after temporary empty data", async () => {
729
+ const change = vi.fn();
730
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change }, false);
731
+ tree.load();
732
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(1));
733
+ tree.rerender({ nodes: [] });
734
+ tree.rerender({ nodes });
735
+ tree.load();
736
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalledTimes(2));
737
+ expect(change).not.toHaveBeenCalled();
738
+ tree.emitScroll(640.5);
739
+ expect(change).toHaveBeenCalledExactlyOnceWith(640.5);
740
+ });
741
+
742
+ describe("TreeView retained scroll lifecycle", () => {
743
+ it("ignores detached layout, drag and scroll callbacks while a replacement restore is pending", async () => {
744
+ const change = vi.fn();
745
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change }, false);
746
+ const detached = observed.props;
747
+ tree.rerender({ nodes: [] });
748
+ tree.rerender({ nodes });
749
+ act(() => detached?.onScrollBeginDrag?.({} as never));
750
+ tree.load();
751
+ await waitFor(() =>
752
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false }),
753
+ );
754
+ const frame = vi.spyOn(globalThis, "requestAnimationFrame");
755
+ act(() => {
756
+ detached?.onLoad?.({ elapsedTimeInMs: 1 });
757
+ detached?.onCommitLayoutEffect?.();
758
+ detached?.onContentSizeChange?.(320, 120);
759
+ detached?.onScroll?.({ nativeEvent: { contentOffset: { y: 640.5 } } } as never);
760
+ });
761
+ expect(frame).not.toHaveBeenCalled();
762
+ await settle();
763
+ expect(change).not.toHaveBeenCalled();
764
+ expect(tree.scroll).toHaveBeenCalledTimes(1);
765
+ tree.emitScroll(640.5);
766
+ expect(change).toHaveBeenCalledExactlyOnceWith(640.5);
767
+ });
768
+
769
+ it("accepts this list's delayed load across an ordinary callback and data rerender", async () => {
770
+ const previous = vi.fn();
771
+ const current = vi.fn();
772
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: previous });
773
+ const initialProps = observed.props;
774
+ tree.rerender({ nodes: [...nodes], onScrollOffsetChange: current });
775
+ act(() => initialProps?.onLoad?.({ elapsedTimeInMs: 1 }));
776
+ await waitFor(() => expect(current).toHaveBeenCalledExactlyOnceWith(640.5));
777
+ expect(previous).not.toHaveBeenCalled();
778
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false });
779
+ });
780
+
781
+ it("uses the new tree geometry when an observed-offset callback replaces data and reveal", async () => {
782
+ let replaced = false;
783
+ const change = vi.fn(() => {
784
+ if (replaced) return;
785
+ replaced = true;
786
+ tree.rerender({
787
+ nodes: [nodes[1], nodes[0]],
788
+ revealRequest: { id: "other-item", requestId: 91 },
789
+ });
790
+ });
791
+ const tree = measuredTree({ onScrollOffsetChange: change });
792
+ tree.load();
793
+ await waitFor(() => expect(tree.scroll).toHaveBeenCalled());
794
+ await settle();
795
+ expect(tree.scroll.mock.calls.map(([args]) => args.offset)).toEqual([192]);
796
+ });
797
+
798
+ it("does not let a detached list's delayed load ready the replacement list", async () => {
799
+ const change = vi.fn();
800
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change }, false);
801
+ const detached = observed.props;
802
+ tree.rerender({ nodes: [] });
803
+ tree.rerender({ nodes });
804
+ act(() => detached?.onLoad?.({ elapsedTimeInMs: 1 }));
805
+ await settle();
806
+ expect(tree.scroll).not.toHaveBeenCalled();
807
+ expect(change).not.toHaveBeenCalled();
808
+ tree.load();
809
+ await waitFor(() =>
810
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false }),
811
+ );
812
+ });
813
+ });
814
+
815
+ it("positive control: a committed replacement tree reveals using the new index", async () => {
816
+ const tree = measuredTree({ onScrollOffsetChange: vi.fn() });
817
+ tree.load();
818
+ await settle();
819
+ tree.rerender({
820
+ nodes: [nodes[1], nodes[0]],
821
+ revealRequest: { id: "other-item", requestId: 91 },
822
+ });
823
+ await waitFor(() =>
824
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 192, animated: false }),
825
+ );
826
+ });
827
+
828
+ it("retains a seed across a stale load with provisional content, then restores after the current load", async () => {
829
+ const change = vi.fn();
830
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change });
831
+ const detached = observed.props;
832
+ tree.rerender({ nodes: [] });
833
+ tree.rerender({ nodes });
834
+ tree.geometry.contentHeight = 120;
835
+ act(() => detached?.onLoad?.({ elapsedTimeInMs: 1 }));
836
+ await settle();
837
+ tree.geometry.contentHeight = 2000;
838
+ tree.load();
839
+ tree.contentSize();
840
+ await settle();
841
+ expect(change.mock.calls).toEqual([[640.5]]);
842
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ offset: 640.5, animated: false });
843
+ });
844
+
845
+ it("positive control: a replacement list waits for its own load with provisional content", async () => {
846
+ const change = vi.fn();
847
+ const tree = measuredTree({ initialScrollOffset: 640.5, onScrollOffsetChange: change });
848
+ tree.rerender({ nodes: [] });
849
+ tree.rerender({ nodes });
850
+ tree.geometry.contentHeight = 120;
851
+ tree.contentSize();
852
+ await settle();
853
+ expect(change).not.toHaveBeenCalled();
854
+ tree.geometry.contentHeight = 2000;
855
+ tree.load();
856
+ await waitFor(() => expect(change).toHaveBeenCalledExactlyOnceWith(640.5));
196
857
  });