@oxyhq/bloom 0.78.0 → 0.79.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.
- package/README.md +3 -3
- package/lib/commonjs/provider/index.js +11 -7
- package/lib/commonjs/provider/index.js.map +1 -1
- package/lib/commonjs/scroll/context.js +96 -0
- package/lib/commonjs/scroll/context.js.map +1 -0
- package/lib/commonjs/scroll/expo-router/index.js +133 -0
- package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
- package/lib/commonjs/scroll/index.js +85 -21
- package/lib/commonjs/scroll/index.js.map +1 -1
- package/lib/commonjs/scroll/index.web.js +184 -118
- package/lib/commonjs/scroll/index.web.js.map +1 -1
- package/lib/commonjs/scroll/scrollable.native.js +58 -0
- package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
- package/lib/commonjs/scroll/store.js +47 -25
- package/lib/commonjs/scroll/store.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +11 -9
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/module/provider/index.js +10 -6
- package/lib/module/provider/index.js.map +1 -1
- package/lib/module/scroll/context.js +90 -0
- package/lib/module/scroll/context.js.map +1 -0
- package/lib/module/scroll/expo-router/index.js +128 -0
- package/lib/module/scroll/expo-router/index.js.map +1 -0
- package/lib/module/scroll/index.js +80 -21
- package/lib/module/scroll/index.js.map +1 -1
- package/lib/module/scroll/index.web.js +181 -119
- package/lib/module/scroll/index.web.js.map +1 -1
- package/lib/module/scroll/scrollable.native.js +54 -0
- package/lib/module/scroll/scrollable.native.js.map +1 -0
- package/lib/module/scroll/store.js +47 -25
- package/lib/module/scroll/store.js.map +1 -1
- package/lib/module/theme/color-policy.js +11 -9
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/typescript/commonjs/provider/index.d.ts +7 -5
- package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
- package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
- package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
- package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
- package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/provider/index.d.ts +7 -5
- package/lib/typescript/module/provider/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/context.d.ts +14 -0
- package/lib/typescript/module/scroll/context.d.ts.map +1 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/scroll/index.d.ts +14 -23
- package/lib/typescript/module/scroll/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/index.web.d.ts +17 -19
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/module/scroll/store.d.ts +41 -22
- package/lib/typescript/module/scroll/store.d.ts.map +1 -1
- package/lib/typescript/module/scroll/types.d.ts +82 -12
- package/lib/typescript/module/scroll/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/package.json +15 -1
- package/src/__tests__/BloomProvider.web.test.tsx +8 -15
- package/src/__tests__/optional-peer-imports.test.ts +2 -2
- package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
- package/src/__tests__/scroll-native.test.tsx +465 -0
- package/src/__tests__/scroll-store.test.ts +41 -17
- package/src/__tests__/scroll-web.test.tsx +547 -111
- package/src/provider/index.tsx +10 -7
- package/src/scroll/context.tsx +107 -0
- package/src/scroll/expo-router/index.ts +140 -0
- package/src/scroll/index.ts +92 -25
- package/src/scroll/index.web.tsx +219 -162
- package/src/scroll/scrollable.native.ts +64 -0
- package/src/scroll/store.ts +47 -25
- package/src/scroll/types.ts +82 -12
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +27 -27
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +55 -55
- package/src/theme/__tests__/policy-legibility.test.ts +21 -1
- package/src/theme/color-policy.ts +13 -9
- package/lib/commonjs/provider/scroll-provider.js +0 -13
- package/lib/commonjs/provider/scroll-provider.js.map +0 -1
- package/lib/commonjs/provider/scroll-provider.web.js +0 -13
- package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
- package/lib/module/provider/scroll-provider.js +0 -16
- package/lib/module/provider/scroll-provider.js.map +0 -1
- package/lib/module/provider/scroll-provider.web.js +0 -5
- package/lib/module/provider/scroll-provider.web.js.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
- package/src/__tests__/scroll-native.test.ts +0 -25
- package/src/provider/scroll-provider.ts +0 -13
- package/src/provider/scroll-provider.web.ts +0 -2
|
@@ -3,55 +3,65 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
// Exercises the WEB scroll-restoration hook (`scroll/index.web`) against the
|
|
6
|
-
//
|
|
7
|
-
// React Navigation web stack collapses a hidden background screen (forcing its
|
|
8
|
-
// `scrollTop` to 0) on push, and a re-shown virtualized list re-lays out its
|
|
9
|
-
// rows — and thus reaches its full scroll height — over SEVERAL frames after
|
|
10
|
-
// focus.
|
|
6
|
+
// three things it exists to get right:
|
|
11
7
|
//
|
|
12
|
-
//
|
|
13
|
-
// so
|
|
14
|
-
//
|
|
15
|
-
//
|
|
8
|
+
// 1. Restore-or-reset. A document-scrolled app shares ONE scroller across
|
|
9
|
+
// every route, so a key with nothing saved has to be written to 0 — doing
|
|
10
|
+
// nothing leaves the previous screen's offset in place.
|
|
11
|
+
// 2. Identity on two axes. An entry the router RECYCLED to show different
|
|
12
|
+
// content is a different screen; the same content in a new entry is too.
|
|
13
|
+
// 3. The collapsed-screen behaviour of the (expo-router-wrapped) React
|
|
14
|
+
// Navigation web stack, which forces a hidden screen's `scrollTop` to 0
|
|
15
|
+
// and re-lays out a re-shown virtualized list over SEVERAL frames.
|
|
16
|
+
//
|
|
17
|
+
// The router is supplied through the adapter the core now takes, so this suite
|
|
18
|
+
// mocks nothing: it drives identity and focus directly, which is also how a
|
|
19
|
+
// non-expo consumer would use the hook. `requestAnimationFrame` is driven
|
|
20
|
+
// manually so the multi-frame restore is deterministic.
|
|
16
21
|
|
|
17
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createContext,
|
|
24
|
+
createElement,
|
|
25
|
+
useContext,
|
|
26
|
+
useEffect,
|
|
27
|
+
useRef,
|
|
28
|
+
type ReactNode,
|
|
29
|
+
} from 'react';
|
|
18
30
|
import { act } from 'react';
|
|
19
31
|
import { createRoot, type Root } from 'react-dom/client';
|
|
32
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
ScrollRestorationProvider,
|
|
36
|
+
useScrollRestoration,
|
|
37
|
+
} from '../scroll/index.web';
|
|
38
|
+
import type {
|
|
39
|
+
ScreenFocusEffect,
|
|
40
|
+
ScrollRestorationBinding,
|
|
41
|
+
ScrollRouterAdapter,
|
|
42
|
+
} from '../scroll/types';
|
|
20
43
|
|
|
21
44
|
// React 19's `act` requires this flag to be set when driving updates manually
|
|
22
45
|
// outside a testing-library renderer.
|
|
23
46
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
24
47
|
true;
|
|
25
48
|
|
|
26
|
-
// ----
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
let currentRouteKey = 'route-test';
|
|
32
|
-
|
|
33
|
-
// `virtual: true` keeps the test isolated from the real (native-heavy)
|
|
34
|
-
// `expo-router` package: only the two hooks the scroll primitive consumes are
|
|
35
|
-
// stubbed, and jest never has to resolve the full module from disk.
|
|
36
|
-
jest.mock(
|
|
37
|
-
'expo-router',
|
|
38
|
-
() => {
|
|
39
|
-
const react = jest.requireActual<typeof import('react')>('react');
|
|
40
|
-
return {
|
|
41
|
-
useFocusEffect: (effect: () => undefined | (() => void)) => {
|
|
42
|
-
react.useEffect(effect, [effect]);
|
|
43
|
-
},
|
|
44
|
-
useRoute: () => ({ key: currentRouteKey, name: 'Test', params: {} }),
|
|
45
|
-
};
|
|
46
|
-
},
|
|
47
|
-
{ virtual: true },
|
|
48
|
-
);
|
|
49
|
+
// ---- A test router ---------------------------------------------------------
|
|
50
|
+
// `useScreenFocusEffect` mirrors expo-router's contract on the two points this
|
|
51
|
+
// hook depends on: while the screen is focused the effect re-runs whenever its
|
|
52
|
+
// IDENTITY changes (which is how a key change mid-focus is seen at all), and it
|
|
53
|
+
// does not run while the screen is blurred.
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const ContentContext = createContext<string | null>(null);
|
|
56
|
+
const FocusContext = createContext(true);
|
|
57
|
+
|
|
58
|
+
const testAdapter: ScrollRouterAdapter = {
|
|
59
|
+
useScreenContentId: () => useContext(ContentContext),
|
|
60
|
+
useScreenFocusEffect: (effect: ScreenFocusEffect) => {
|
|
61
|
+
const focused = useContext(FocusContext);
|
|
62
|
+
useEffect(() => (focused ? effect() : undefined), [effect, focused]);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
55
65
|
|
|
56
66
|
// ---- requestAnimationFrame harness ---------------------------------------
|
|
57
67
|
|
|
@@ -92,6 +102,7 @@ class FrameScheduler {
|
|
|
92
102
|
// (as a real element is), content starts collapsed and grows on "relayout".
|
|
93
103
|
|
|
94
104
|
const VIEWPORT_HEIGHT = 879;
|
|
105
|
+
const FULL_CONTENT_HEIGHT = 6586;
|
|
95
106
|
|
|
96
107
|
class FakeScrollNode {
|
|
97
108
|
readonly el: HTMLDivElement;
|
|
@@ -144,6 +155,11 @@ class FakeScrollNode {
|
|
|
144
155
|
emitScroll(): void {
|
|
145
156
|
this.el.dispatchEvent(new Event('scroll'));
|
|
146
157
|
}
|
|
158
|
+
|
|
159
|
+
/** The user grabs the scroller mid-restore (wheel, touch, scrollbar, keys). */
|
|
160
|
+
emitUserTakeover(type: string): void {
|
|
161
|
+
this.el.dispatchEvent(new Event(type));
|
|
162
|
+
}
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
// A RNW-style handle exposing `getScrollableNode()` -> the DOM node.
|
|
@@ -155,14 +171,27 @@ function makeHandle(node: FakeScrollNode): { getScrollableNode: () => HTMLElemen
|
|
|
155
171
|
//
|
|
156
172
|
// A single <ScrollRestorationProvider> stays mounted (mirroring the real app
|
|
157
173
|
// root, where the offset store lives for the document's lifetime) while the
|
|
158
|
-
// screen under it
|
|
174
|
+
// screen under it changes identity, focus and mount state.
|
|
159
175
|
|
|
160
|
-
|
|
176
|
+
interface ScreenProps {
|
|
177
|
+
node: FakeScrollNode;
|
|
178
|
+
subKey?: string;
|
|
179
|
+
enabled?: boolean;
|
|
180
|
+
onBinding?: (binding: ScrollRestorationBinding) => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function Screen({ node, subKey, enabled, onBinding }: ScreenProps): ReactNode {
|
|
161
184
|
const ref = useRef(makeHandle(node));
|
|
162
|
-
useScrollRestoration(ref);
|
|
185
|
+
const binding = useScrollRestoration(ref, { key: subKey, enabled });
|
|
186
|
+
onBinding?.(binding);
|
|
163
187
|
return null;
|
|
164
188
|
}
|
|
165
189
|
|
|
190
|
+
interface ShowOptions extends ScreenProps {
|
|
191
|
+
content: string | null;
|
|
192
|
+
focused?: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
166
195
|
class Harness {
|
|
167
196
|
readonly root: Root;
|
|
168
197
|
private readonly container: HTMLElement;
|
|
@@ -173,26 +202,26 @@ class Harness {
|
|
|
173
202
|
this.root = createRoot(this.container);
|
|
174
203
|
}
|
|
175
204
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
205
|
+
/** Render the screen showing `content`, with a focus state and options. */
|
|
206
|
+
show({ content, focused = true, ...screen }: ShowOptions): void {
|
|
178
207
|
act(() => {
|
|
179
208
|
this.root.render(
|
|
180
|
-
createElement(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
209
|
+
createElement(ScrollRestorationProvider, {
|
|
210
|
+
adapter: testAdapter,
|
|
211
|
+
children: createElement(
|
|
212
|
+
FocusContext.Provider,
|
|
213
|
+
{ value: focused },
|
|
214
|
+
createElement(
|
|
215
|
+
ContentContext.Provider,
|
|
216
|
+
{ value: content },
|
|
217
|
+
createElement(Screen, screen),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
}),
|
|
185
221
|
);
|
|
186
222
|
});
|
|
187
223
|
}
|
|
188
224
|
|
|
189
|
-
/** Unmount (blur) the screen while keeping the provider/store alive. */
|
|
190
|
-
blur(): void {
|
|
191
|
-
act(() => {
|
|
192
|
-
this.root.render(createElement(ScrollRestorationProvider, null, null));
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
225
|
teardown(): void {
|
|
197
226
|
act(() => {
|
|
198
227
|
this.root.unmount();
|
|
@@ -201,6 +230,20 @@ class Harness {
|
|
|
201
230
|
}
|
|
202
231
|
}
|
|
203
232
|
|
|
233
|
+
function scrollTo(node: FakeScrollNode, offset: number): void {
|
|
234
|
+
node.scrollTop = offset;
|
|
235
|
+
act(() => {
|
|
236
|
+
node.emitScroll();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let contentSeq = 0;
|
|
241
|
+
/** A content id no earlier test has used, i.e. never seen this session. */
|
|
242
|
+
function unseenContent(): string {
|
|
243
|
+
contentSeq += 1;
|
|
244
|
+
return `route-${contentSeq}?`;
|
|
245
|
+
}
|
|
246
|
+
|
|
204
247
|
describe('web scroll-restoration hook', () => {
|
|
205
248
|
let frames: FrameScheduler;
|
|
206
249
|
let harness: Harness;
|
|
@@ -208,7 +251,6 @@ describe('web scroll-restoration hook', () => {
|
|
|
208
251
|
beforeEach(() => {
|
|
209
252
|
frames = new FrameScheduler();
|
|
210
253
|
frames.install();
|
|
211
|
-
currentRouteKey = `route-${Math.random().toString(36).slice(2)}`;
|
|
212
254
|
harness = new Harness();
|
|
213
255
|
});
|
|
214
256
|
|
|
@@ -216,16 +258,155 @@ describe('web scroll-restoration hook', () => {
|
|
|
216
258
|
harness.teardown();
|
|
217
259
|
});
|
|
218
260
|
|
|
219
|
-
it('
|
|
261
|
+
it('resets an unseen screen to the top instead of leaving it where the last one was', () => {
|
|
262
|
+
// The defect this replaces: a key with nothing saved read 0 and the hook
|
|
263
|
+
// did nothing at all. On a document-scrolled app (one window scroller for
|
|
264
|
+
// every route) that means the new screen opens at the previous screen's
|
|
265
|
+
// offset.
|
|
220
266
|
const node = new FakeScrollNode();
|
|
221
|
-
node.growTo(
|
|
222
|
-
|
|
267
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
268
|
+
node.scrollTop = 3000; // where the previous screen left the shared scroller
|
|
223
269
|
|
|
224
|
-
|
|
225
|
-
|
|
270
|
+
harness.show({ node, content: unseenContent() });
|
|
271
|
+
|
|
272
|
+
expect(node.scrollTop).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('restores a saved offset across frames while the list is still short', () => {
|
|
276
|
+
const node = new FakeScrollNode();
|
|
277
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
278
|
+
const content = unseenContent();
|
|
279
|
+
|
|
280
|
+
harness.show({ node, content });
|
|
281
|
+
scrollTo(node, 3520);
|
|
282
|
+
harness.show({ node, content, focused: false });
|
|
283
|
+
|
|
284
|
+
// Re-show: list starts collapsed (rows not yet rendered) and grows over
|
|
285
|
+
// frames. A single-frame restore would be clamped to 0 and never recover.
|
|
286
|
+
node.collapseLikeNavigator();
|
|
287
|
+
harness.show({ node, content });
|
|
288
|
+
|
|
289
|
+
// Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
|
|
226
290
|
act(() => {
|
|
227
|
-
|
|
291
|
+
frames.flushOneFrame();
|
|
292
|
+
});
|
|
293
|
+
expect(node.scrollTop).toBe(0);
|
|
294
|
+
expect(frames.pending).toBeGreaterThan(0);
|
|
295
|
+
|
|
296
|
+
// A few frames later the rows lay out and the content reaches full height.
|
|
297
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
298
|
+
act(() => {
|
|
299
|
+
frames.flushOneFrame();
|
|
300
|
+
});
|
|
301
|
+
expect(node.scrollTop).toBe(3520);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('saves the outgoing list and restores-or-resets the incoming one when the key changes MID-FOCUS', () => {
|
|
305
|
+
// An in-screen tab or folder swap: the screen never blurs, so nothing in a
|
|
306
|
+
// focus transition can be relied on to notice.
|
|
307
|
+
const node = new FakeScrollNode();
|
|
308
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
309
|
+
const content = unseenContent();
|
|
310
|
+
|
|
311
|
+
harness.show({ node, content, subKey: 'posts' });
|
|
312
|
+
scrollTo(node, 3520);
|
|
313
|
+
|
|
314
|
+
// Swap to a never-seen tab while still focused: reset to the top.
|
|
315
|
+
harness.show({ node, content, subKey: 'media' });
|
|
316
|
+
expect(node.scrollTop).toBe(0);
|
|
317
|
+
|
|
318
|
+
scrollTo(node, 900);
|
|
319
|
+
|
|
320
|
+
// Back to the first tab: its own offset is intact.
|
|
321
|
+
harness.show({ node, content, subKey: 'posts' });
|
|
322
|
+
act(() => {
|
|
323
|
+
frames.flushOneFrame();
|
|
324
|
+
});
|
|
325
|
+
expect(node.scrollTop).toBe(3520);
|
|
326
|
+
|
|
327
|
+
// ...and so is the second tab's.
|
|
328
|
+
harness.show({ node, content, subKey: 'media' });
|
|
329
|
+
act(() => {
|
|
330
|
+
frames.flushOneFrame();
|
|
331
|
+
});
|
|
332
|
+
expect(node.scrollTop).toBe(900);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('resets when the route name is the same but the params differ', () => {
|
|
336
|
+
// The case that makes params part of the content id: expo-router RECYCLES
|
|
337
|
+
// one route object — `route.key` included — when `NAVIGATE` targets the
|
|
338
|
+
// current route name and its dynamic segments match, i.e. when only the
|
|
339
|
+
// query changed. Measured on 56.2.10 and 57.0.9 with `search?q=cats` ->
|
|
340
|
+
// `search?q=dogs`.
|
|
341
|
+
const node = new FakeScrollNode();
|
|
342
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
343
|
+
const cats = 'search?"q":"cats"';
|
|
344
|
+
const dogs = 'search?"q":"dogs"';
|
|
345
|
+
|
|
346
|
+
harness.show({ node, content: cats });
|
|
347
|
+
scrollTo(node, 2000);
|
|
348
|
+
|
|
349
|
+
harness.show({ node, content: dogs });
|
|
350
|
+
expect(node.scrollTop).toBe(0);
|
|
351
|
+
|
|
352
|
+
// ...and cats keeps its own offset.
|
|
353
|
+
harness.show({ node, content: cats });
|
|
354
|
+
act(() => {
|
|
355
|
+
frames.flushOneFrame();
|
|
228
356
|
});
|
|
357
|
+
expect(node.scrollTop).toBe(2000);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('restores content already seen, whatever entry it is reached through', () => {
|
|
361
|
+
// The rule in one test: an offset belongs to WHAT the user was looking at.
|
|
362
|
+
// The same url reached through a second history entry — a tab press, an
|
|
363
|
+
// in-app link, `router.replace` — is the same content and restores.
|
|
364
|
+
const node = new FakeScrollNode();
|
|
365
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
366
|
+
const post = 'p/[id]?"id":"42"';
|
|
367
|
+
const elsewhere = unseenContent();
|
|
368
|
+
|
|
369
|
+
harness.show({ node, content: post });
|
|
370
|
+
scrollTo(node, 1500);
|
|
371
|
+
|
|
372
|
+
harness.show({ node, content: elsewhere });
|
|
373
|
+
expect(node.scrollTop).toBe(0);
|
|
374
|
+
|
|
375
|
+
harness.show({ node, content: post });
|
|
376
|
+
act(() => {
|
|
377
|
+
frames.flushOneFrame();
|
|
378
|
+
});
|
|
379
|
+
expect(node.scrollTop).toBe(1500);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('is completely inert while disabled — no save, no restore, no reset', () => {
|
|
383
|
+
const node = new FakeScrollNode();
|
|
384
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
385
|
+
node.scrollTop = 3000;
|
|
386
|
+
const content = unseenContent();
|
|
387
|
+
|
|
388
|
+
harness.show({ node, content, enabled: false });
|
|
389
|
+
|
|
390
|
+
// No reset, and no restore loop scheduled.
|
|
391
|
+
expect(node.scrollTop).toBe(3000);
|
|
392
|
+
expect(frames.pending).toBe(0);
|
|
393
|
+
|
|
394
|
+
// No save either: scrolling while disabled records nothing, which the
|
|
395
|
+
// enabled render below proves by resetting instead of restoring.
|
|
396
|
+
scrollTo(node, 4200);
|
|
397
|
+
harness.show({ node, content, enabled: true });
|
|
398
|
+
expect(node.scrollTop).toBe(0);
|
|
399
|
+
expect(frames.pending).toBe(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('does not clobber the saved offset when the navigator collapses the screen on blur', () => {
|
|
403
|
+
const node = new FakeScrollNode();
|
|
404
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
405
|
+
const content = unseenContent();
|
|
406
|
+
harness.show({ node, content });
|
|
407
|
+
|
|
408
|
+
// User scrolls to 3520 — the live scroll listener records it.
|
|
409
|
+
scrollTo(node, 3520);
|
|
229
410
|
|
|
230
411
|
// Navigator collapses the hidden screen and forces scrollTop to 0 while the
|
|
231
412
|
// screen is still technically focused (a stray scroll event fires). This
|
|
@@ -236,99 +417,354 @@ describe('web scroll-restoration hook', () => {
|
|
|
236
417
|
});
|
|
237
418
|
|
|
238
419
|
// Blur: cleanup runs and must persist the last GOOD offset, not the 0.
|
|
239
|
-
harness.
|
|
420
|
+
harness.show({ node, content, focused: false });
|
|
240
421
|
|
|
241
422
|
// Re-show with full content height: restore should reach 3520 in one frame.
|
|
242
|
-
node.growTo(
|
|
423
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
243
424
|
node.scrollTop = 0;
|
|
244
|
-
harness.
|
|
425
|
+
harness.show({ node, content });
|
|
245
426
|
act(() => {
|
|
246
427
|
frames.flushOneFrame();
|
|
247
428
|
});
|
|
248
429
|
expect(node.scrollTop).toBe(3520);
|
|
249
430
|
});
|
|
250
431
|
|
|
251
|
-
it('
|
|
432
|
+
it('persists a genuine scroll-to-top over a previously-saved offset', () => {
|
|
252
433
|
const node = new FakeScrollNode();
|
|
253
|
-
node.growTo(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
node.emitScroll();
|
|
258
|
-
});
|
|
259
|
-
harness.blur();
|
|
434
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
435
|
+
const content = unseenContent();
|
|
436
|
+
harness.show({ node, content });
|
|
437
|
+
scrollTo(node, 3520);
|
|
260
438
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
439
|
+
// User scrolls all the way back to the top; container is NOT collapsed, so
|
|
440
|
+
// this 0 is genuine and must overwrite the saved 3520.
|
|
441
|
+
scrollTo(node, 0);
|
|
442
|
+
harness.show({ node, content, focused: false });
|
|
443
|
+
|
|
444
|
+
// Re-show: the saved 0 is written back, so anything that nudged the shared
|
|
445
|
+
// scroller in between is undone.
|
|
446
|
+
node.scrollTop = 500;
|
|
447
|
+
harness.show({ node, content });
|
|
448
|
+
expect(node.scrollTop).toBe(0);
|
|
449
|
+
// A write to 0 always sticks, so it never schedules the re-apply loop.
|
|
450
|
+
expect(frames.pending).toBe(0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('stops retrying after the frame cap even if the content never grows', () => {
|
|
454
|
+
const node = new FakeScrollNode();
|
|
455
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
456
|
+
const content = unseenContent();
|
|
457
|
+
harness.show({ node, content });
|
|
458
|
+
scrollTo(node, 3520);
|
|
459
|
+
harness.show({ node, content, focused: false });
|
|
460
|
+
|
|
461
|
+
// Re-show that never reaches full height.
|
|
263
462
|
node.collapseLikeNavigator();
|
|
264
|
-
harness.
|
|
463
|
+
harness.show({ node, content });
|
|
464
|
+
|
|
465
|
+
// Flush far more than the cap; the loop must terminate.
|
|
466
|
+
for (let i = 0; i < 60; i++) {
|
|
467
|
+
act(() => {
|
|
468
|
+
frames.flushOneFrame();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
expect(frames.pending).toBe(0);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it.each(['wheel', 'touchstart', 'pointerdown', 'keydown'])(
|
|
475
|
+
'abandons the restore as soon as the user takes over (%s)',
|
|
476
|
+
(takeoverEvent) => {
|
|
477
|
+
// HTML §7.4.6.5 makes this a condition on the browser's own restoration:
|
|
478
|
+
// it re-attempts the saved position "until document's has been scrolled by
|
|
479
|
+
// the user becomes true". Without the abort the loop yanks the user back
|
|
480
|
+
// up to once a frame for the whole frame budget.
|
|
481
|
+
const node = new FakeScrollNode();
|
|
482
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
483
|
+
const content = unseenContent();
|
|
484
|
+
harness.show({ node, content });
|
|
485
|
+
scrollTo(node, 3520);
|
|
486
|
+
harness.show({ node, content, focused: false });
|
|
487
|
+
|
|
488
|
+
// Re-show collapsed, so the loop is still retrying rather than done.
|
|
489
|
+
node.collapseLikeNavigator();
|
|
490
|
+
harness.show({ node, content });
|
|
491
|
+
act(() => {
|
|
492
|
+
frames.flushOneFrame();
|
|
493
|
+
});
|
|
494
|
+
expect(frames.pending).toBeGreaterThan(0);
|
|
495
|
+
|
|
496
|
+
// The user grabs it and scrolls somewhere of their own.
|
|
497
|
+
act(() => {
|
|
498
|
+
node.emitUserTakeover(takeoverEvent);
|
|
499
|
+
});
|
|
500
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
501
|
+
scrollTo(node, 120);
|
|
502
|
+
|
|
503
|
+
for (let i = 0; i < 10; i++) {
|
|
504
|
+
act(() => {
|
|
505
|
+
frames.flushOneFrame();
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
expect(frames.pending).toBe(0);
|
|
509
|
+
expect(node.scrollTop).toBe(120);
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
it('does not abandon the restore on the scroll events its own writes produce', () => {
|
|
514
|
+
// The naive form of the abort above — "any scroll event cancels" — cancels
|
|
515
|
+
// on the first frame, because `setOffset` IS a scroll. This is the control
|
|
516
|
+
// that keeps the two apart.
|
|
517
|
+
const node = new FakeScrollNode();
|
|
518
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
519
|
+
const content = unseenContent();
|
|
520
|
+
harness.show({ node, content });
|
|
521
|
+
scrollTo(node, 3520);
|
|
522
|
+
harness.show({ node, content, focused: false });
|
|
523
|
+
|
|
524
|
+
node.collapseLikeNavigator();
|
|
525
|
+
harness.show({ node, content });
|
|
265
526
|
|
|
266
|
-
// Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
|
|
267
527
|
act(() => {
|
|
268
528
|
frames.flushOneFrame();
|
|
529
|
+
node.emitScroll(); // what a real browser emits after our clamped write
|
|
269
530
|
});
|
|
270
|
-
expect(node.scrollTop).toBe(0);
|
|
271
531
|
expect(frames.pending).toBeGreaterThan(0);
|
|
272
532
|
|
|
273
|
-
|
|
274
|
-
node.growTo(6586);
|
|
533
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
275
534
|
act(() => {
|
|
276
535
|
frames.flushOneFrame();
|
|
277
536
|
});
|
|
278
537
|
expect(node.scrollTop).toBe(3520);
|
|
279
538
|
});
|
|
280
539
|
|
|
281
|
-
it('
|
|
540
|
+
it('is inert when the adapter cannot identify the screen', () => {
|
|
282
541
|
const node = new FakeScrollNode();
|
|
283
|
-
node.growTo(
|
|
284
|
-
|
|
285
|
-
|
|
542
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
543
|
+
node.scrollTop = 3000;
|
|
544
|
+
|
|
545
|
+
harness.show({ node, content: null });
|
|
546
|
+
|
|
547
|
+
expect(node.scrollTop).toBe(3000);
|
|
286
548
|
expect(frames.pending).toBe(0);
|
|
287
549
|
});
|
|
288
550
|
|
|
289
|
-
it('
|
|
551
|
+
it('follows the real navigation sequence: home -> post -> TAP HOME -> back -> back', () => {
|
|
552
|
+
// The user's own scenario, and the reason the key is CONTENT rather than
|
|
553
|
+
// the navigation entry. Driving expo-router's own forked router
|
|
554
|
+
// (`build/layouts/StackClient.js`) on 56.2.10 and 57.0.9 shows what tapping
|
|
555
|
+
// Home actually does:
|
|
556
|
+
//
|
|
557
|
+
// [home#A] user scrolls home
|
|
558
|
+
// push /p/1 -> [home#A, post#B]
|
|
559
|
+
// navigate / -> [home#A, post#B, home#C] <- a NEW entry, NEW key
|
|
560
|
+
// back -> post#B (resetRoot to the recorded state, keys intact)
|
|
561
|
+
// back -> home#A
|
|
562
|
+
//
|
|
563
|
+
// Keyed on the entry, home#C misses and opens at the top. Keyed on content,
|
|
564
|
+
// every one of these is `index?` or `p/[id]?"id":"1"` and the user gets
|
|
565
|
+
// back what they were looking at — the Twitter/Instagram convention.
|
|
290
566
|
const node = new FakeScrollNode();
|
|
291
|
-
node.growTo(
|
|
292
|
-
|
|
293
|
-
|
|
567
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
568
|
+
const home = 'index?';
|
|
569
|
+
const post = 'p/[id]?"id":"1"';
|
|
570
|
+
|
|
571
|
+
harness.show({ node, content: home });
|
|
572
|
+
scrollTo(node, 2400);
|
|
573
|
+
|
|
574
|
+
harness.show({ node, content: post });
|
|
575
|
+
expect(node.scrollTop).toBe(0);
|
|
576
|
+
scrollTo(node, 800);
|
|
577
|
+
|
|
578
|
+
// TAP HOME — a new history entry, but the same content.
|
|
579
|
+
harness.show({ node, content: home });
|
|
294
580
|
act(() => {
|
|
295
|
-
|
|
581
|
+
frames.flushOneFrame();
|
|
296
582
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
583
|
+
expect(node.scrollTop).toBe(2400);
|
|
584
|
+
|
|
585
|
+
// Browser Back to the post.
|
|
586
|
+
harness.show({ node, content: post });
|
|
300
587
|
act(() => {
|
|
301
|
-
|
|
588
|
+
frames.flushOneFrame();
|
|
302
589
|
});
|
|
303
|
-
|
|
590
|
+
expect(node.scrollTop).toBe(800);
|
|
591
|
+
|
|
592
|
+
// Browser Back again to home.
|
|
593
|
+
harness.show({ node, content: home });
|
|
594
|
+
act(() => {
|
|
595
|
+
frames.flushOneFrame();
|
|
596
|
+
});
|
|
597
|
+
expect(node.scrollTop).toBe(2400);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('opens content never seen this session at the top', () => {
|
|
601
|
+
// The other half of the rule, and the defect that started the task: with
|
|
602
|
+
// one shared document scroller, "never seen" has to mean "written to 0".
|
|
603
|
+
const node = new FakeScrollNode();
|
|
604
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
304
605
|
|
|
305
|
-
|
|
306
|
-
node
|
|
307
|
-
|
|
606
|
+
harness.show({ node, content: unseenContent() });
|
|
607
|
+
scrollTo(node, 2600);
|
|
608
|
+
|
|
609
|
+
harness.show({ node, content: unseenContent() });
|
|
610
|
+
expect(node.scrollTop).toBe(0);
|
|
308
611
|
expect(frames.pending).toBe(0);
|
|
309
|
-
expect(node.scrollTop).toBe(500);
|
|
310
612
|
});
|
|
311
613
|
|
|
312
|
-
it('
|
|
614
|
+
it('sends content to the top ONCE, not on every re-run for the same key', () => {
|
|
615
|
+
// `enabled` turning on after being off is not a fresh arrival. The hook was
|
|
616
|
+
// inert while it was off, so a scroll during that window was never
|
|
617
|
+
// recorded — resetting on the way back in would throw it away with nothing
|
|
618
|
+
// to restore from. Restoring twice is harmless; resetting twice is loss.
|
|
313
619
|
const node = new FakeScrollNode();
|
|
314
|
-
node.growTo(
|
|
315
|
-
|
|
316
|
-
|
|
620
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
621
|
+
const content = unseenContent();
|
|
622
|
+
|
|
623
|
+
harness.show({ node, content });
|
|
624
|
+
expect(node.scrollTop).toBe(0);
|
|
625
|
+
|
|
626
|
+
harness.show({ node, content, enabled: false });
|
|
627
|
+
node.scrollTop = 1800; // the user scrolls while the hook is inert
|
|
628
|
+
|
|
629
|
+
harness.show({ node, content, enabled: true });
|
|
630
|
+
expect(node.scrollTop).toBe(1800);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('does not persist the browser echo of its own reset write', () => {
|
|
634
|
+
// A real browser dispatches `scroll` for our `setOffset(0)` (jsdom does
|
|
635
|
+
// not, hence the explicit emit). Persisting it records an offset the user
|
|
636
|
+
// never chose — and under content keying a SIBLING screen showing the same
|
|
637
|
+
// content shares the key, so the echo can land after that sibling saved a
|
|
638
|
+
// real offset and clobber it. This is the two-live-entries trade made
|
|
639
|
+
// observable.
|
|
640
|
+
const shared = 'p/[id]?"id":"42"';
|
|
641
|
+
const first = new FakeScrollNode();
|
|
642
|
+
const second = new FakeScrollNode();
|
|
643
|
+
first.growTo(FULL_CONTENT_HEIGHT);
|
|
644
|
+
second.growTo(FULL_CONTENT_HEIGHT);
|
|
645
|
+
|
|
646
|
+
// Two live screens on the same content, both arriving with nothing saved.
|
|
317
647
|
act(() => {
|
|
318
|
-
|
|
648
|
+
harness.root.render(
|
|
649
|
+
createElement(ScrollRestorationProvider, {
|
|
650
|
+
adapter: testAdapter,
|
|
651
|
+
children: createElement(
|
|
652
|
+
ContentContext.Provider,
|
|
653
|
+
{ value: shared },
|
|
654
|
+
createElement(Screen, { node: first }),
|
|
655
|
+
createElement(Screen, { node: second }),
|
|
656
|
+
),
|
|
657
|
+
}),
|
|
658
|
+
);
|
|
319
659
|
});
|
|
320
|
-
harness.blur();
|
|
321
660
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
661
|
+
// The first screen scrolls and saves a real offset under the shared key...
|
|
662
|
+
scrollTo(first, 2400);
|
|
663
|
+
// ...and only then does the second screen's reset echo arrive.
|
|
664
|
+
act(() => {
|
|
665
|
+
second.emitScroll();
|
|
666
|
+
});
|
|
325
667
|
|
|
326
|
-
//
|
|
327
|
-
|
|
668
|
+
// A later visit to that content must still get 2400, not the echoed 0.
|
|
669
|
+
const later = new FakeScrollNode();
|
|
670
|
+
later.growTo(FULL_CONTENT_HEIGHT);
|
|
671
|
+
harness.show({ node: later, content: shared });
|
|
672
|
+
act(() => {
|
|
673
|
+
frames.flushOneFrame();
|
|
674
|
+
});
|
|
675
|
+
expect(later.scrollTop).toBe(2400);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('returns a binding that is safe to wire onto a list on either platform', () => {
|
|
679
|
+
const node = new FakeScrollNode();
|
|
680
|
+
const seen: ScrollRestorationBinding[] = [];
|
|
681
|
+
|
|
682
|
+
harness.show({
|
|
683
|
+
node,
|
|
684
|
+
content: unseenContent(),
|
|
685
|
+
onBinding: (binding) => seen.push(binding),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
expect(typeof seen[0]?.onScroll).toBe('function');
|
|
689
|
+
// Calling it on web must be harmless: this platform records offsets from
|
|
690
|
+
// the DOM node's own scroll event, and a shared call site still passes it.
|
|
691
|
+
expect(() =>
|
|
692
|
+
seen[0]?.onScroll({
|
|
693
|
+
nativeEvent: { contentOffset: { x: 0, y: 1234 } },
|
|
694
|
+
} as NativeSyntheticEvent<NativeScrollEvent>),
|
|
695
|
+
).not.toThrow();
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// ---- history.scrollRestoration ---------------------------------------------
|
|
700
|
+
// jsdom does not implement `history.scrollRestoration` at all ('scrollRestoration'
|
|
701
|
+
// in history === false), so the provider's feature detect makes it a no-op in
|
|
702
|
+
// every other suite here. This block installs the property first, which is what
|
|
703
|
+
// makes these the only tests that exercise the switch.
|
|
704
|
+
|
|
705
|
+
describe('manual browser scroll restoration', () => {
|
|
706
|
+
let installed: HTMLElement | null = null;
|
|
707
|
+
let root: Root | null = null;
|
|
708
|
+
|
|
709
|
+
function mountProvider(): void {
|
|
710
|
+
installed = document.createElement('div');
|
|
711
|
+
document.body.appendChild(installed);
|
|
712
|
+
root = createRoot(installed);
|
|
713
|
+
act(() => {
|
|
714
|
+
root?.render(
|
|
715
|
+
createElement(ScrollRestorationProvider, {
|
|
716
|
+
adapter: testAdapter,
|
|
717
|
+
children: null,
|
|
718
|
+
}),
|
|
719
|
+
);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
beforeEach(() => {
|
|
724
|
+
Object.defineProperty(window.history, 'scrollRestoration', {
|
|
725
|
+
configurable: true,
|
|
726
|
+
writable: true,
|
|
727
|
+
value: 'auto',
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
afterEach(() => {
|
|
732
|
+
if (root && installed) {
|
|
328
733
|
act(() => {
|
|
329
|
-
|
|
734
|
+
root?.unmount();
|
|
330
735
|
});
|
|
736
|
+
installed.remove();
|
|
331
737
|
}
|
|
332
|
-
|
|
738
|
+
root = null;
|
|
739
|
+
installed = null;
|
|
740
|
+
delete (window.history as { scrollRestoration?: string }).scrollRestoration;
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('takes restoration over while the provider is mounted', () => {
|
|
744
|
+
mountProvider();
|
|
745
|
+
expect(history.scrollRestoration).toBe('manual');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('hands restoration back to the browser on pagehide', () => {
|
|
749
|
+
// The offset map is in memory only: a reload starts empty and a bfcache
|
|
750
|
+
// restore resumes a document whose focus effects never re-run. Leaving it
|
|
751
|
+
// 'manual' on the way out means nobody restores at all.
|
|
752
|
+
mountProvider();
|
|
753
|
+
act(() => {
|
|
754
|
+
window.dispatchEvent(new Event('pagehide'));
|
|
755
|
+
});
|
|
756
|
+
expect(history.scrollRestoration).toBe('auto');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('hands restoration back on unmount', () => {
|
|
760
|
+
mountProvider();
|
|
761
|
+
expect(history.scrollRestoration).toBe('manual');
|
|
762
|
+
act(() => {
|
|
763
|
+
root?.unmount();
|
|
764
|
+
});
|
|
765
|
+
installed?.remove();
|
|
766
|
+
root = null;
|
|
767
|
+
installed = null;
|
|
768
|
+
expect(history.scrollRestoration).toBe('auto');
|
|
333
769
|
});
|
|
334
770
|
});
|