@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.
- package/README.md +98 -0
- package/dist/cjs/SheetModal/index.cjs +22 -3
- package/dist/cjs/SheetModal/index.native.js +50 -10
- package/dist/cjs/SheetModal/index.native.js.map +1 -1
- package/dist/cjs/Tabs/index.cjs +33 -12
- package/dist/cjs/Tabs/index.native.js +37 -12
- package/dist/cjs/Tabs/index.native.js.map +1 -1
- package/dist/cjs/layouts/tags.native.js.map +1 -1
- package/dist/cjs/views/TreeView.cjs +175 -43
- package/dist/cjs/views/TreeView.native.js +181 -46
- package/dist/cjs/views/TreeView.native.js.map +1 -1
- package/dist/esm/SheetModal/index.mjs +22 -3
- package/dist/esm/SheetModal/index.mjs.map +1 -1
- package/dist/esm/SheetModal/index.native.js +51 -11
- package/dist/esm/SheetModal/index.native.js.map +1 -1
- package/dist/esm/Tabs/index.mjs +33 -12
- package/dist/esm/Tabs/index.mjs.map +1 -1
- package/dist/esm/Tabs/index.native.js +37 -12
- package/dist/esm/Tabs/index.native.js.map +1 -1
- package/dist/esm/layouts/tags.mjs.map +1 -1
- package/dist/esm/layouts/tags.native.js.map +1 -1
- package/dist/esm/views/TreeView.mjs +175 -43
- package/dist/esm/views/TreeView.mjs.map +1 -1
- package/dist/esm/views/TreeView.native.js +181 -46
- package/dist/esm/views/TreeView.native.js.map +1 -1
- package/dist/jsx/SheetModal/index.mjs +22 -3
- package/dist/jsx/SheetModal/index.mjs.map +1 -1
- package/dist/jsx/SheetModal/index.native.js +50 -10
- package/dist/jsx/SheetModal/index.native.js.map +1 -1
- package/dist/jsx/Tabs/index.mjs +33 -12
- package/dist/jsx/Tabs/index.mjs.map +1 -1
- package/dist/jsx/Tabs/index.native.js +37 -12
- package/dist/jsx/Tabs/index.native.js.map +1 -1
- package/dist/jsx/layouts/tags.mjs.map +1 -1
- package/dist/jsx/layouts/tags.native.js.map +1 -1
- package/dist/jsx/views/TreeView.mjs +175 -43
- package/dist/jsx/views/TreeView.mjs.map +1 -1
- package/dist/jsx/views/TreeView.native.js +181 -46
- package/dist/jsx/views/TreeView.native.js.map +1 -1
- package/package.json +11 -11
- package/src/Accordion/index.tsx +1 -1
- package/src/SheetModal/SheetModal.fill.spec.tsx +147 -0
- package/src/SheetModal/SheetModal.ownedBody.spec.tsx +200 -0
- package/src/SheetModal/SheetModal.stories.spec.tsx +23 -2
- package/src/SheetModal/SheetModal.stories.tsx +275 -2
- package/src/SheetModal/index.native.tsx +50 -6
- package/src/SheetModal/index.tsx +40 -3
- package/src/Tabs/Tabs.lifecycle.spec.tsx +123 -0
- package/src/Tabs/Tabs.measurement.spec.tsx +186 -0
- package/src/Tabs/Tabs.stories.tsx +43 -0
- package/src/Tabs/index.tsx +42 -13
- package/src/layouts/tags.tsx +2 -1
- package/src/views/TreeView.reveal.spec.tsx +7 -0
- package/src/views/TreeView.reveal.stories.tsx +134 -0
- package/src/views/TreeView.scroll-index.spec.tsx +246 -0
- package/src/views/TreeView.scroll.spec.tsx +711 -50
- package/src/views/TreeView.tsx +230 -49
- package/types/SheetModal/index.d.ts +8 -1
- package/types/SheetModal/index.d.ts.map +1 -1
- package/types/SheetModal/index.native.d.ts +1 -1
- package/types/SheetModal/index.native.d.ts.map +1 -1
- package/types/Tabs/index.d.ts.map +1 -1
- package/types/layouts/tags.d.ts.map +1 -1
- package/types/views/TreeView.d.ts +10 -1
- 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, {
|
|
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(
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|