@oxyhq/bloom 0.79.0 → 0.80.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 +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 +221 -121
- 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/scrollable.web.js +9 -3
- package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
- package/lib/commonjs/scroll/store.js +47 -25
- package/lib/commonjs/scroll/store.js.map +1 -1
- package/lib/commonjs/tabs/Tabs.js +263 -63
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
- package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/commonjs/tabs/expo-router/index.js +13 -0
- package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
- package/lib/commonjs/theme/color-policy.js +11 -10
- 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 +218 -122
- 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/scrollable.web.js +9 -3
- package/lib/module/scroll/scrollable.web.js.map +1 -1
- package/lib/module/scroll/store.js +47 -25
- package/lib/module/scroll/store.js.map +1 -1
- package/lib/module/tabs/Tabs.js +271 -65
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
- package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/module/tabs/expo-router/index.js +10 -0
- package/lib/module/tabs/expo-router/index.js.map +1 -0
- package/lib/module/theme/color-policy.js +11 -10
- 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/scrollable.web.d.ts +18 -5
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
- 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/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
- package/lib/typescript/commonjs/tabs/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/scrollable.web.d.ts +18 -5
- package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
- 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/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/tabs/types.d.ts +48 -4
- package/lib/typescript/module/tabs/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/package.json +29 -1
- package/src/__tests__/BloomProvider.web.test.tsx +8 -15
- package/src/__tests__/Tabs.test.tsx +187 -0
- package/src/__tests__/optional-peer-imports.test.ts +7 -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 +797 -108
- 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 +255 -162
- package/src/scroll/scrollable.native.ts +64 -0
- package/src/scroll/scrollable.web.ts +30 -8
- package/src/scroll/store.ts +47 -25
- package/src/scroll/types.ts +82 -12
- package/src/tabs/Tabs.tsx +352 -77
- package/src/tabs/expo-router/RouterTabs.tsx +268 -0
- package/src/tabs/expo-router/index.ts +8 -0
- package/src/tabs/types.ts +48 -4
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +5 -5
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +11 -11
- package/src/theme/__tests__/policy-legibility.test.ts +10 -7
- package/src/theme/color-policy.ts +11 -10
- 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,38 @@ 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.
|
|
175
|
+
|
|
176
|
+
interface ScreenProps {
|
|
177
|
+
node: FakeScrollNode;
|
|
178
|
+
subKey?: string;
|
|
179
|
+
enabled?: boolean;
|
|
180
|
+
/** Drive the `'window'` sentinel instead of the node ref. */
|
|
181
|
+
windowTarget?: boolean;
|
|
182
|
+
onBinding?: (binding: ScrollRestorationBinding) => void;
|
|
183
|
+
}
|
|
159
184
|
|
|
160
|
-
function Screen({
|
|
185
|
+
function Screen({
|
|
186
|
+
node,
|
|
187
|
+
subKey,
|
|
188
|
+
enabled,
|
|
189
|
+
windowTarget,
|
|
190
|
+
onBinding,
|
|
191
|
+
}: ScreenProps): ReactNode {
|
|
161
192
|
const ref = useRef(makeHandle(node));
|
|
162
|
-
useScrollRestoration(ref
|
|
193
|
+
const binding = useScrollRestoration(windowTarget ? 'window' : ref, {
|
|
194
|
+
key: subKey,
|
|
195
|
+
enabled,
|
|
196
|
+
});
|
|
197
|
+
onBinding?.(binding);
|
|
163
198
|
return null;
|
|
164
199
|
}
|
|
165
200
|
|
|
201
|
+
interface ShowOptions extends ScreenProps {
|
|
202
|
+
content: string | null;
|
|
203
|
+
focused?: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
166
206
|
class Harness {
|
|
167
207
|
readonly root: Root;
|
|
168
208
|
private readonly container: HTMLElement;
|
|
@@ -173,26 +213,26 @@ class Harness {
|
|
|
173
213
|
this.root = createRoot(this.container);
|
|
174
214
|
}
|
|
175
215
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
216
|
+
/** Render the screen showing `content`, with a focus state and options. */
|
|
217
|
+
show({ content, focused = true, ...screen }: ShowOptions): void {
|
|
178
218
|
act(() => {
|
|
179
219
|
this.root.render(
|
|
180
|
-
createElement(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
createElement(ScrollRestorationProvider, {
|
|
221
|
+
adapter: testAdapter,
|
|
222
|
+
children: createElement(
|
|
223
|
+
FocusContext.Provider,
|
|
224
|
+
{ value: focused },
|
|
225
|
+
createElement(
|
|
226
|
+
ContentContext.Provider,
|
|
227
|
+
{ value: content },
|
|
228
|
+
createElement(Screen, screen),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
}),
|
|
185
232
|
);
|
|
186
233
|
});
|
|
187
234
|
}
|
|
188
235
|
|
|
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
236
|
teardown(): void {
|
|
197
237
|
act(() => {
|
|
198
238
|
this.root.unmount();
|
|
@@ -201,6 +241,20 @@ class Harness {
|
|
|
201
241
|
}
|
|
202
242
|
}
|
|
203
243
|
|
|
244
|
+
function scrollTo(node: FakeScrollNode, offset: number): void {
|
|
245
|
+
node.scrollTop = offset;
|
|
246
|
+
act(() => {
|
|
247
|
+
node.emitScroll();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let contentSeq = 0;
|
|
252
|
+
/** A content id no earlier test has used, i.e. never seen this session. */
|
|
253
|
+
function unseenContent(): string {
|
|
254
|
+
contentSeq += 1;
|
|
255
|
+
return `route-${contentSeq}?`;
|
|
256
|
+
}
|
|
257
|
+
|
|
204
258
|
describe('web scroll-restoration hook', () => {
|
|
205
259
|
let frames: FrameScheduler;
|
|
206
260
|
let harness: Harness;
|
|
@@ -208,7 +262,6 @@ describe('web scroll-restoration hook', () => {
|
|
|
208
262
|
beforeEach(() => {
|
|
209
263
|
frames = new FrameScheduler();
|
|
210
264
|
frames.install();
|
|
211
|
-
currentRouteKey = `route-${Math.random().toString(36).slice(2)}`;
|
|
212
265
|
harness = new Harness();
|
|
213
266
|
});
|
|
214
267
|
|
|
@@ -216,16 +269,155 @@ describe('web scroll-restoration hook', () => {
|
|
|
216
269
|
harness.teardown();
|
|
217
270
|
});
|
|
218
271
|
|
|
219
|
-
it('
|
|
272
|
+
it('resets an unseen screen to the top instead of leaving it where the last one was', () => {
|
|
273
|
+
// The defect this replaces: a key with nothing saved read 0 and the hook
|
|
274
|
+
// did nothing at all. On a document-scrolled app (one window scroller for
|
|
275
|
+
// every route) that means the new screen opens at the previous screen's
|
|
276
|
+
// offset.
|
|
220
277
|
const node = new FakeScrollNode();
|
|
221
|
-
node.growTo(
|
|
222
|
-
|
|
278
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
279
|
+
node.scrollTop = 3000; // where the previous screen left the shared scroller
|
|
223
280
|
|
|
224
|
-
|
|
225
|
-
|
|
281
|
+
harness.show({ node, content: unseenContent() });
|
|
282
|
+
|
|
283
|
+
expect(node.scrollTop).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('restores a saved offset across frames while the list is still short', () => {
|
|
287
|
+
const node = new FakeScrollNode();
|
|
288
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
289
|
+
const content = unseenContent();
|
|
290
|
+
|
|
291
|
+
harness.show({ node, content });
|
|
292
|
+
scrollTo(node, 3520);
|
|
293
|
+
harness.show({ node, content, focused: false });
|
|
294
|
+
|
|
295
|
+
// Re-show: list starts collapsed (rows not yet rendered) and grows over
|
|
296
|
+
// frames. A single-frame restore would be clamped to 0 and never recover.
|
|
297
|
+
node.collapseLikeNavigator();
|
|
298
|
+
harness.show({ node, content });
|
|
299
|
+
|
|
300
|
+
// Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
|
|
226
301
|
act(() => {
|
|
227
|
-
|
|
302
|
+
frames.flushOneFrame();
|
|
303
|
+
});
|
|
304
|
+
expect(node.scrollTop).toBe(0);
|
|
305
|
+
expect(frames.pending).toBeGreaterThan(0);
|
|
306
|
+
|
|
307
|
+
// A few frames later the rows lay out and the content reaches full height.
|
|
308
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
309
|
+
act(() => {
|
|
310
|
+
frames.flushOneFrame();
|
|
311
|
+
});
|
|
312
|
+
expect(node.scrollTop).toBe(3520);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('saves the outgoing list and restores-or-resets the incoming one when the key changes MID-FOCUS', () => {
|
|
316
|
+
// An in-screen tab or folder swap: the screen never blurs, so nothing in a
|
|
317
|
+
// focus transition can be relied on to notice.
|
|
318
|
+
const node = new FakeScrollNode();
|
|
319
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
320
|
+
const content = unseenContent();
|
|
321
|
+
|
|
322
|
+
harness.show({ node, content, subKey: 'posts' });
|
|
323
|
+
scrollTo(node, 3520);
|
|
324
|
+
|
|
325
|
+
// Swap to a never-seen tab while still focused: reset to the top.
|
|
326
|
+
harness.show({ node, content, subKey: 'media' });
|
|
327
|
+
expect(node.scrollTop).toBe(0);
|
|
328
|
+
|
|
329
|
+
scrollTo(node, 900);
|
|
330
|
+
|
|
331
|
+
// Back to the first tab: its own offset is intact.
|
|
332
|
+
harness.show({ node, content, subKey: 'posts' });
|
|
333
|
+
act(() => {
|
|
334
|
+
frames.flushOneFrame();
|
|
228
335
|
});
|
|
336
|
+
expect(node.scrollTop).toBe(3520);
|
|
337
|
+
|
|
338
|
+
// ...and so is the second tab's.
|
|
339
|
+
harness.show({ node, content, subKey: 'media' });
|
|
340
|
+
act(() => {
|
|
341
|
+
frames.flushOneFrame();
|
|
342
|
+
});
|
|
343
|
+
expect(node.scrollTop).toBe(900);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('resets when the route name is the same but the params differ', () => {
|
|
347
|
+
// The case that makes params part of the content id: expo-router RECYCLES
|
|
348
|
+
// one route object — `route.key` included — when `NAVIGATE` targets the
|
|
349
|
+
// current route name and its dynamic segments match, i.e. when only the
|
|
350
|
+
// query changed. Measured on 56.2.10 and 57.0.9 with `search?q=cats` ->
|
|
351
|
+
// `search?q=dogs`.
|
|
352
|
+
const node = new FakeScrollNode();
|
|
353
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
354
|
+
const cats = 'search?"q":"cats"';
|
|
355
|
+
const dogs = 'search?"q":"dogs"';
|
|
356
|
+
|
|
357
|
+
harness.show({ node, content: cats });
|
|
358
|
+
scrollTo(node, 2000);
|
|
359
|
+
|
|
360
|
+
harness.show({ node, content: dogs });
|
|
361
|
+
expect(node.scrollTop).toBe(0);
|
|
362
|
+
|
|
363
|
+
// ...and cats keeps its own offset.
|
|
364
|
+
harness.show({ node, content: cats });
|
|
365
|
+
act(() => {
|
|
366
|
+
frames.flushOneFrame();
|
|
367
|
+
});
|
|
368
|
+
expect(node.scrollTop).toBe(2000);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('restores content already seen, whatever entry it is reached through', () => {
|
|
372
|
+
// The rule in one test: an offset belongs to WHAT the user was looking at.
|
|
373
|
+
// The same url reached through a second history entry — a tab press, an
|
|
374
|
+
// in-app link, `router.replace` — is the same content and restores.
|
|
375
|
+
const node = new FakeScrollNode();
|
|
376
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
377
|
+
const post = 'p/[id]?"id":"42"';
|
|
378
|
+
const elsewhere = unseenContent();
|
|
379
|
+
|
|
380
|
+
harness.show({ node, content: post });
|
|
381
|
+
scrollTo(node, 1500);
|
|
382
|
+
|
|
383
|
+
harness.show({ node, content: elsewhere });
|
|
384
|
+
expect(node.scrollTop).toBe(0);
|
|
385
|
+
|
|
386
|
+
harness.show({ node, content: post });
|
|
387
|
+
act(() => {
|
|
388
|
+
frames.flushOneFrame();
|
|
389
|
+
});
|
|
390
|
+
expect(node.scrollTop).toBe(1500);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('is completely inert while disabled — no save, no restore, no reset', () => {
|
|
394
|
+
const node = new FakeScrollNode();
|
|
395
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
396
|
+
node.scrollTop = 3000;
|
|
397
|
+
const content = unseenContent();
|
|
398
|
+
|
|
399
|
+
harness.show({ node, content, enabled: false });
|
|
400
|
+
|
|
401
|
+
// No reset, and no restore loop scheduled.
|
|
402
|
+
expect(node.scrollTop).toBe(3000);
|
|
403
|
+
expect(frames.pending).toBe(0);
|
|
404
|
+
|
|
405
|
+
// No save either: scrolling while disabled records nothing, which the
|
|
406
|
+
// enabled render below proves by resetting instead of restoring.
|
|
407
|
+
scrollTo(node, 4200);
|
|
408
|
+
harness.show({ node, content, enabled: true });
|
|
409
|
+
expect(node.scrollTop).toBe(0);
|
|
410
|
+
expect(frames.pending).toBe(0);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('does not clobber the saved offset when the navigator collapses the screen on blur', () => {
|
|
414
|
+
const node = new FakeScrollNode();
|
|
415
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
416
|
+
const content = unseenContent();
|
|
417
|
+
harness.show({ node, content });
|
|
418
|
+
|
|
419
|
+
// User scrolls to 3520 — the live scroll listener records it.
|
|
420
|
+
scrollTo(node, 3520);
|
|
229
421
|
|
|
230
422
|
// Navigator collapses the hidden screen and forces scrollTop to 0 while the
|
|
231
423
|
// screen is still technically focused (a stray scroll event fires). This
|
|
@@ -236,99 +428,596 @@ describe('web scroll-restoration hook', () => {
|
|
|
236
428
|
});
|
|
237
429
|
|
|
238
430
|
// Blur: cleanup runs and must persist the last GOOD offset, not the 0.
|
|
239
|
-
harness.
|
|
431
|
+
harness.show({ node, content, focused: false });
|
|
240
432
|
|
|
241
433
|
// Re-show with full content height: restore should reach 3520 in one frame.
|
|
242
|
-
node.growTo(
|
|
434
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
243
435
|
node.scrollTop = 0;
|
|
244
|
-
harness.
|
|
436
|
+
harness.show({ node, content });
|
|
245
437
|
act(() => {
|
|
246
438
|
frames.flushOneFrame();
|
|
247
439
|
});
|
|
248
440
|
expect(node.scrollTop).toBe(3520);
|
|
249
441
|
});
|
|
250
442
|
|
|
251
|
-
it('
|
|
443
|
+
it('persists a genuine scroll-to-top over a previously-saved offset', () => {
|
|
252
444
|
const node = new FakeScrollNode();
|
|
253
|
-
node.growTo(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
node.emitScroll();
|
|
258
|
-
});
|
|
259
|
-
harness.blur();
|
|
445
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
446
|
+
const content = unseenContent();
|
|
447
|
+
harness.show({ node, content });
|
|
448
|
+
scrollTo(node, 3520);
|
|
260
449
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
450
|
+
// User scrolls all the way back to the top; container is NOT collapsed, so
|
|
451
|
+
// this 0 is genuine and must overwrite the saved 3520.
|
|
452
|
+
scrollTo(node, 0);
|
|
453
|
+
harness.show({ node, content, focused: false });
|
|
454
|
+
|
|
455
|
+
// Re-show: the saved 0 is written back, so anything that nudged the shared
|
|
456
|
+
// scroller in between is undone.
|
|
457
|
+
node.scrollTop = 500;
|
|
458
|
+
harness.show({ node, content });
|
|
459
|
+
expect(node.scrollTop).toBe(0);
|
|
460
|
+
// A write to 0 always sticks, so it never schedules the re-apply loop.
|
|
461
|
+
expect(frames.pending).toBe(0);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('stops retrying after the frame cap even if the content never grows', () => {
|
|
465
|
+
const node = new FakeScrollNode();
|
|
466
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
467
|
+
const content = unseenContent();
|
|
468
|
+
harness.show({ node, content });
|
|
469
|
+
scrollTo(node, 3520);
|
|
470
|
+
harness.show({ node, content, focused: false });
|
|
471
|
+
|
|
472
|
+
// Re-show that never reaches full height.
|
|
263
473
|
node.collapseLikeNavigator();
|
|
264
|
-
harness.
|
|
474
|
+
harness.show({ node, content });
|
|
475
|
+
|
|
476
|
+
// Flush far more than the cap; the loop must terminate.
|
|
477
|
+
for (let i = 0; i < 60; i++) {
|
|
478
|
+
act(() => {
|
|
479
|
+
frames.flushOneFrame();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
expect(frames.pending).toBe(0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it.each(['wheel', 'touchstart', 'pointerdown', 'keydown'])(
|
|
486
|
+
'abandons the restore as soon as the user takes over (%s)',
|
|
487
|
+
(takeoverEvent) => {
|
|
488
|
+
// HTML §7.4.6.5 makes this a condition on the browser's own restoration:
|
|
489
|
+
// it re-attempts the saved position "until document's has been scrolled by
|
|
490
|
+
// the user becomes true". Without the abort the loop yanks the user back
|
|
491
|
+
// up to once a frame for the whole frame budget.
|
|
492
|
+
const node = new FakeScrollNode();
|
|
493
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
494
|
+
const content = unseenContent();
|
|
495
|
+
harness.show({ node, content });
|
|
496
|
+
scrollTo(node, 3520);
|
|
497
|
+
harness.show({ node, content, focused: false });
|
|
498
|
+
|
|
499
|
+
// Re-show collapsed, so the loop is still retrying rather than done.
|
|
500
|
+
node.collapseLikeNavigator();
|
|
501
|
+
harness.show({ node, content });
|
|
502
|
+
act(() => {
|
|
503
|
+
frames.flushOneFrame();
|
|
504
|
+
});
|
|
505
|
+
expect(frames.pending).toBeGreaterThan(0);
|
|
506
|
+
|
|
507
|
+
// The user grabs it and scrolls somewhere of their own.
|
|
508
|
+
act(() => {
|
|
509
|
+
node.emitUserTakeover(takeoverEvent);
|
|
510
|
+
});
|
|
511
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
512
|
+
scrollTo(node, 120);
|
|
513
|
+
|
|
514
|
+
for (let i = 0; i < 10; i++) {
|
|
515
|
+
act(() => {
|
|
516
|
+
frames.flushOneFrame();
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
expect(frames.pending).toBe(0);
|
|
520
|
+
expect(node.scrollTop).toBe(120);
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
it('does not abandon the restore on the scroll events its own writes produce', () => {
|
|
525
|
+
// The naive form of the abort above — "any scroll event cancels" — cancels
|
|
526
|
+
// on the first frame, because `setOffset` IS a scroll. This is the control
|
|
527
|
+
// that keeps the two apart.
|
|
528
|
+
const node = new FakeScrollNode();
|
|
529
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
530
|
+
const content = unseenContent();
|
|
531
|
+
harness.show({ node, content });
|
|
532
|
+
scrollTo(node, 3520);
|
|
533
|
+
harness.show({ node, content, focused: false });
|
|
534
|
+
|
|
535
|
+
node.collapseLikeNavigator();
|
|
536
|
+
harness.show({ node, content });
|
|
265
537
|
|
|
266
|
-
// Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
|
|
267
538
|
act(() => {
|
|
268
539
|
frames.flushOneFrame();
|
|
540
|
+
node.emitScroll(); // what a real browser emits after our clamped write
|
|
269
541
|
});
|
|
270
|
-
expect(node.scrollTop).toBe(0);
|
|
271
542
|
expect(frames.pending).toBeGreaterThan(0);
|
|
272
543
|
|
|
273
|
-
|
|
274
|
-
node.growTo(6586);
|
|
544
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
275
545
|
act(() => {
|
|
276
546
|
frames.flushOneFrame();
|
|
277
547
|
});
|
|
278
548
|
expect(node.scrollTop).toBe(3520);
|
|
279
549
|
});
|
|
280
550
|
|
|
281
|
-
it('
|
|
551
|
+
it('is inert when the adapter cannot identify the screen', () => {
|
|
282
552
|
const node = new FakeScrollNode();
|
|
283
|
-
node.growTo(
|
|
284
|
-
|
|
285
|
-
|
|
553
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
554
|
+
node.scrollTop = 3000;
|
|
555
|
+
|
|
556
|
+
harness.show({ node, content: null });
|
|
557
|
+
|
|
558
|
+
expect(node.scrollTop).toBe(3000);
|
|
286
559
|
expect(frames.pending).toBe(0);
|
|
287
560
|
});
|
|
288
561
|
|
|
289
|
-
it('
|
|
562
|
+
it('follows the real navigation sequence: home -> post -> TAP HOME -> back -> back', () => {
|
|
563
|
+
// The user's own scenario, and the reason the key is CONTENT rather than
|
|
564
|
+
// the navigation entry. Driving expo-router's own forked router
|
|
565
|
+
// (`build/layouts/StackClient.js`) on 56.2.10 and 57.0.9 shows what tapping
|
|
566
|
+
// Home actually does:
|
|
567
|
+
//
|
|
568
|
+
// [home#A] user scrolls home
|
|
569
|
+
// push /p/1 -> [home#A, post#B]
|
|
570
|
+
// navigate / -> [home#A, post#B, home#C] <- a NEW entry, NEW key
|
|
571
|
+
// back -> post#B (resetRoot to the recorded state, keys intact)
|
|
572
|
+
// back -> home#A
|
|
573
|
+
//
|
|
574
|
+
// Keyed on the entry, home#C misses and opens at the top. Keyed on content,
|
|
575
|
+
// every one of these is `index?` or `p/[id]?"id":"1"` and the user gets
|
|
576
|
+
// back what they were looking at — the Twitter/Instagram convention.
|
|
290
577
|
const node = new FakeScrollNode();
|
|
291
|
-
node.growTo(
|
|
292
|
-
|
|
293
|
-
|
|
578
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
579
|
+
const home = 'index?';
|
|
580
|
+
const post = 'p/[id]?"id":"1"';
|
|
581
|
+
|
|
582
|
+
harness.show({ node, content: home });
|
|
583
|
+
scrollTo(node, 2400);
|
|
584
|
+
|
|
585
|
+
harness.show({ node, content: post });
|
|
586
|
+
expect(node.scrollTop).toBe(0);
|
|
587
|
+
scrollTo(node, 800);
|
|
588
|
+
|
|
589
|
+
// TAP HOME — a new history entry, but the same content.
|
|
590
|
+
harness.show({ node, content: home });
|
|
294
591
|
act(() => {
|
|
295
|
-
|
|
592
|
+
frames.flushOneFrame();
|
|
296
593
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
594
|
+
expect(node.scrollTop).toBe(2400);
|
|
595
|
+
|
|
596
|
+
// Browser Back to the post.
|
|
597
|
+
harness.show({ node, content: post });
|
|
300
598
|
act(() => {
|
|
301
|
-
|
|
599
|
+
frames.flushOneFrame();
|
|
302
600
|
});
|
|
303
|
-
|
|
601
|
+
expect(node.scrollTop).toBe(800);
|
|
304
602
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
603
|
+
// Browser Back again to home.
|
|
604
|
+
harness.show({ node, content: home });
|
|
605
|
+
act(() => {
|
|
606
|
+
frames.flushOneFrame();
|
|
607
|
+
});
|
|
608
|
+
expect(node.scrollTop).toBe(2400);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('opens content never seen this session at the top', () => {
|
|
612
|
+
// The other half of the rule, and the defect that started the task: with
|
|
613
|
+
// one shared document scroller, "never seen" has to mean "written to 0".
|
|
614
|
+
const node = new FakeScrollNode();
|
|
615
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
616
|
+
|
|
617
|
+
harness.show({ node, content: unseenContent() });
|
|
618
|
+
scrollTo(node, 2600);
|
|
619
|
+
|
|
620
|
+
harness.show({ node, content: unseenContent() });
|
|
621
|
+
expect(node.scrollTop).toBe(0);
|
|
308
622
|
expect(frames.pending).toBe(0);
|
|
309
|
-
expect(node.scrollTop).toBe(500);
|
|
310
623
|
});
|
|
311
624
|
|
|
312
|
-
it('
|
|
625
|
+
it('sends content to the top ONCE, not on every re-run for the same key', () => {
|
|
626
|
+
// `enabled` turning on after being off is not a fresh arrival. The hook was
|
|
627
|
+
// inert while it was off, so a scroll during that window was never
|
|
628
|
+
// recorded — resetting on the way back in would throw it away with nothing
|
|
629
|
+
// to restore from. Restoring twice is harmless; resetting twice is loss.
|
|
313
630
|
const node = new FakeScrollNode();
|
|
314
|
-
node.growTo(
|
|
315
|
-
|
|
316
|
-
|
|
631
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
632
|
+
const content = unseenContent();
|
|
633
|
+
|
|
634
|
+
harness.show({ node, content });
|
|
635
|
+
expect(node.scrollTop).toBe(0);
|
|
636
|
+
|
|
637
|
+
harness.show({ node, content, enabled: false });
|
|
638
|
+
node.scrollTop = 1800; // the user scrolls while the hook is inert
|
|
639
|
+
|
|
640
|
+
harness.show({ node, content, enabled: true });
|
|
641
|
+
expect(node.scrollTop).toBe(1800);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('does not persist the browser echo of its own reset write', () => {
|
|
645
|
+
// A real browser dispatches `scroll` for our `setOffset(0)` (jsdom does
|
|
646
|
+
// not, hence the explicit emit). Persisting it records an offset the user
|
|
647
|
+
// never chose — and under content keying a SIBLING screen showing the same
|
|
648
|
+
// content shares the key, so the echo can land after that sibling saved a
|
|
649
|
+
// real offset and clobber it. This is the two-live-entries trade made
|
|
650
|
+
// observable.
|
|
651
|
+
const shared = 'p/[id]?"id":"42"';
|
|
652
|
+
const first = new FakeScrollNode();
|
|
653
|
+
const second = new FakeScrollNode();
|
|
654
|
+
first.growTo(FULL_CONTENT_HEIGHT);
|
|
655
|
+
second.growTo(FULL_CONTENT_HEIGHT);
|
|
656
|
+
|
|
657
|
+
// Two live screens on the same content, both arriving with nothing saved.
|
|
317
658
|
act(() => {
|
|
318
|
-
|
|
659
|
+
harness.root.render(
|
|
660
|
+
createElement(ScrollRestorationProvider, {
|
|
661
|
+
adapter: testAdapter,
|
|
662
|
+
children: createElement(
|
|
663
|
+
ContentContext.Provider,
|
|
664
|
+
{ value: shared },
|
|
665
|
+
createElement(Screen, { node: first }),
|
|
666
|
+
createElement(Screen, { node: second }),
|
|
667
|
+
),
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
319
670
|
});
|
|
320
|
-
harness.blur();
|
|
321
671
|
|
|
322
|
-
//
|
|
672
|
+
// The first screen scrolls and saves a real offset under the shared key...
|
|
673
|
+
scrollTo(first, 2400);
|
|
674
|
+
// ...and only then does the second screen's reset echo arrive.
|
|
675
|
+
act(() => {
|
|
676
|
+
second.emitScroll();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// A later visit to that content must still get 2400, not the echoed 0.
|
|
680
|
+
const later = new FakeScrollNode();
|
|
681
|
+
later.growTo(FULL_CONTENT_HEIGHT);
|
|
682
|
+
harness.show({ node: later, content: shared });
|
|
683
|
+
act(() => {
|
|
684
|
+
frames.flushOneFrame();
|
|
685
|
+
});
|
|
686
|
+
expect(later.scrollTop).toBe(2400);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('does not persist a PARTIAL offset when the restore is aborted mid-loop', () => {
|
|
690
|
+
// The corruption this closes: every loop write echoes back as a `scroll`
|
|
691
|
+
// event, so before suppression an interrupted restore left whatever the
|
|
692
|
+
// browser had clamped to in the store, and that content's remembered
|
|
693
|
+
// position stayed wrong for the rest of the session. A swipe that commits
|
|
694
|
+
// a route change mid-restore hits this every time.
|
|
695
|
+
const node = new FakeScrollNode();
|
|
696
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
697
|
+
const content = unseenContent();
|
|
698
|
+
|
|
699
|
+
harness.show({ node, content });
|
|
700
|
+
scrollTo(node, 3520);
|
|
701
|
+
harness.show({ node, content, focused: false });
|
|
702
|
+
|
|
703
|
+
// Re-show with the content still short, so the write clamps to a partial.
|
|
323
704
|
node.collapseLikeNavigator();
|
|
324
|
-
|
|
705
|
+
node.growTo(1500); // 621px of scroll range against a 3520 target
|
|
706
|
+
harness.show({ node, content });
|
|
707
|
+
act(() => {
|
|
708
|
+
frames.flushOneFrame();
|
|
709
|
+
node.emitScroll(); // the browser echoing our own clamped write
|
|
710
|
+
});
|
|
711
|
+
expect(node.scrollTop).toBe(1500 - 879);
|
|
325
712
|
|
|
326
|
-
//
|
|
327
|
-
|
|
713
|
+
// The user grabs it, which aborts the loop, and then navigates away.
|
|
714
|
+
act(() => {
|
|
715
|
+
node.emitUserTakeover('touchstart');
|
|
716
|
+
});
|
|
717
|
+
harness.show({ node, content, focused: false });
|
|
718
|
+
|
|
719
|
+
// The original offset must have survived, not the partial.
|
|
720
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
721
|
+
node.scrollTop = 0;
|
|
722
|
+
harness.show({ node, content });
|
|
723
|
+
act(() => {
|
|
724
|
+
frames.flushOneFrame();
|
|
725
|
+
});
|
|
726
|
+
expect(node.scrollTop).toBe(3520);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('keeps the original target when the loop exhausts its frame budget', () => {
|
|
730
|
+
// Same mechanism, the other end state. Content that is short right now is
|
|
731
|
+
// usually a list still loading, not a permanent shrink — remembering the
|
|
732
|
+
// target means the position is right once the rows arrive.
|
|
733
|
+
const node = new FakeScrollNode();
|
|
734
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
735
|
+
const content = unseenContent();
|
|
736
|
+
|
|
737
|
+
harness.show({ node, content });
|
|
738
|
+
scrollTo(node, 3520);
|
|
739
|
+
harness.show({ node, content, focused: false });
|
|
740
|
+
|
|
741
|
+
node.collapseLikeNavigator();
|
|
742
|
+
node.growTo(1500);
|
|
743
|
+
harness.show({ node, content });
|
|
744
|
+
for (let i = 0; i < 40; i++) {
|
|
745
|
+
const wrote = frames.pending > 0;
|
|
328
746
|
act(() => {
|
|
329
747
|
frames.flushOneFrame();
|
|
748
|
+
// Only a frame that actually wrote produces an echo; a real browser
|
|
749
|
+
// dispatches `scroll` when something moves, not on a timer.
|
|
750
|
+
if (wrote) node.emitScroll();
|
|
330
751
|
});
|
|
331
752
|
}
|
|
332
753
|
expect(frames.pending).toBe(0);
|
|
754
|
+
harness.show({ node, content, focused: false });
|
|
755
|
+
|
|
756
|
+
node.growTo(FULL_CONTENT_HEIGHT);
|
|
757
|
+
node.scrollTop = 0;
|
|
758
|
+
harness.show({ node, content });
|
|
759
|
+
act(() => {
|
|
760
|
+
frames.flushOneFrame();
|
|
761
|
+
});
|
|
762
|
+
expect(node.scrollTop).toBe(3520);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
describe('the window sentinel and a collapsing document', () => {
|
|
766
|
+
// Every Oxy web feed passes `'window'`. Under a TABBED navigator each tab
|
|
767
|
+
// stays MOUNTED and non-focused ones are `display: none`, which collapses
|
|
768
|
+
// the DOCUMENT — measured in Chrome 150: `documentElement.scrollHeight`
|
|
769
|
+
// 8040 -> 800, `scrollY` forced to 0, two `scroll` events dispatched. Blur
|
|
770
|
+
// is emitted from an effect that has not run yet, so the outgoing tab's
|
|
771
|
+
// listener is still attached when they arrive.
|
|
772
|
+
const DOC_VIEWPORT = 800;
|
|
773
|
+
let docHeight = 8040;
|
|
774
|
+
let scrollY = 0;
|
|
775
|
+
|
|
776
|
+
beforeEach(() => {
|
|
777
|
+
docHeight = 8040;
|
|
778
|
+
scrollY = 0;
|
|
779
|
+
Object.defineProperty(window, 'scrollY', {
|
|
780
|
+
configurable: true,
|
|
781
|
+
get: () => scrollY,
|
|
782
|
+
});
|
|
783
|
+
Object.defineProperty(window, 'innerHeight', {
|
|
784
|
+
configurable: true,
|
|
785
|
+
get: () => DOC_VIEWPORT,
|
|
786
|
+
});
|
|
787
|
+
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
|
788
|
+
configurable: true,
|
|
789
|
+
get: () => docHeight,
|
|
790
|
+
});
|
|
791
|
+
window.scrollTo = ((_x: number, y: number) => {
|
|
792
|
+
scrollY = Math.min(Math.max(0, y), Math.max(0, docHeight - DOC_VIEWPORT));
|
|
793
|
+
}) as typeof window.scrollTo;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
function emitWindowScroll(): void {
|
|
797
|
+
act(() => {
|
|
798
|
+
window.dispatchEvent(new Event('scroll'));
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
it('does not persist the 0 a collapsing document forces', () => {
|
|
803
|
+
const node = new FakeScrollNode();
|
|
804
|
+
const content = unseenContent();
|
|
805
|
+
|
|
806
|
+
harness.show({ node, content, windowTarget: true });
|
|
807
|
+
// Positive control: the document is genuinely scrollable, so a 0 read
|
|
808
|
+
// here would be a real user position rather than a collapse artefact.
|
|
809
|
+
expect(document.documentElement.scrollHeight).toBeGreaterThan(
|
|
810
|
+
window.innerHeight,
|
|
811
|
+
);
|
|
812
|
+
scrollY = 5000;
|
|
813
|
+
emitWindowScroll();
|
|
814
|
+
|
|
815
|
+
// The tab switch: this screen is hidden, the document collapses to the
|
|
816
|
+
// viewport and the browser forces the offset to 0, dispatching `scroll`
|
|
817
|
+
// while this screen's listener is still attached.
|
|
818
|
+
docHeight = DOC_VIEWPORT;
|
|
819
|
+
scrollY = 0;
|
|
820
|
+
emitWindowScroll();
|
|
821
|
+
|
|
822
|
+
// Leaving and returning must bring back 5000, not the forced 0.
|
|
823
|
+
harness.show({ node, content, windowTarget: true, focused: false });
|
|
824
|
+
docHeight = 8040;
|
|
825
|
+
harness.show({ node, content, windowTarget: true });
|
|
826
|
+
act(() => {
|
|
827
|
+
frames.flushOneFrame();
|
|
828
|
+
});
|
|
829
|
+
expect(window.scrollY).toBe(5000);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('does not persist a PARTIAL clamp when a shorter tab takes over the document', () => {
|
|
833
|
+
// The residual the collapse guard cannot see: the destination tab is
|
|
834
|
+
// still taller than the viewport, so `maxOffset > 0` and the offset is
|
|
835
|
+
// not 0 — it is the destination's MAXIMUM. Measured in Chrome 150:
|
|
836
|
+
// 5000 -> 2215 with `scrollHeight` 8040 -> 3015, dispatched as TWO
|
|
837
|
+
// identical scroll events.
|
|
838
|
+
const node = new FakeScrollNode();
|
|
839
|
+
const content = unseenContent();
|
|
840
|
+
|
|
841
|
+
harness.show({ node, content, windowTarget: true });
|
|
842
|
+
scrollY = 5000;
|
|
843
|
+
emitWindowScroll();
|
|
844
|
+
|
|
845
|
+
// The shorter tab takes over: the range shrinks and the browser parks the
|
|
846
|
+
// offset at the new bottom, twice.
|
|
847
|
+
docHeight = 3015;
|
|
848
|
+
scrollY = 3015 - DOC_VIEWPORT;
|
|
849
|
+
emitWindowScroll();
|
|
850
|
+
emitWindowScroll();
|
|
851
|
+
|
|
852
|
+
harness.show({ node, content, windowTarget: true, focused: false });
|
|
853
|
+
docHeight = 8040;
|
|
854
|
+
harness.show({ node, content, windowTarget: true });
|
|
855
|
+
act(() => {
|
|
856
|
+
frames.flushOneFrame();
|
|
857
|
+
});
|
|
858
|
+
expect(window.scrollY).toBe(5000);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('persists a GENUINE scroll to the very bottom when nothing shrank', () => {
|
|
862
|
+
// The collision worth worrying about, and it does not fire: reaching the
|
|
863
|
+
// bottom under your own steam does not shrink the page in the same
|
|
864
|
+
// breath, so only one of the two conditions holds.
|
|
865
|
+
const node = new FakeScrollNode();
|
|
866
|
+
const content = unseenContent();
|
|
867
|
+
|
|
868
|
+
harness.show({ node, content, windowTarget: true });
|
|
869
|
+
scrollY = docHeight - DOC_VIEWPORT; // 7240, exactly at the maximum
|
|
870
|
+
emitWindowScroll();
|
|
871
|
+
|
|
872
|
+
harness.show({ node, content, windowTarget: true, focused: false });
|
|
873
|
+
scrollY = 0;
|
|
874
|
+
harness.show({ node, content, windowTarget: true });
|
|
875
|
+
act(() => {
|
|
876
|
+
frames.flushOneFrame();
|
|
877
|
+
});
|
|
878
|
+
expect(window.scrollY).toBe(7240);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('recovers as soon as the user scrolls off the bottom after a shrink', () => {
|
|
882
|
+
// The suppression must not stick: while it holds, the reference range is
|
|
883
|
+
// not updated, so the only thing that clears it is an offset that is not
|
|
884
|
+
// at the maximum. Measured to recover on the first such scroll.
|
|
885
|
+
const node = new FakeScrollNode();
|
|
886
|
+
const content = unseenContent();
|
|
887
|
+
|
|
888
|
+
harness.show({ node, content, windowTarget: true });
|
|
889
|
+
scrollY = 7240;
|
|
890
|
+
emitWindowScroll();
|
|
891
|
+
|
|
892
|
+
docHeight = 4020; // something collapsed under them
|
|
893
|
+
scrollY = 4020 - DOC_VIEWPORT;
|
|
894
|
+
emitWindowScroll();
|
|
895
|
+
|
|
896
|
+
scrollY = 1000; // and now they scroll on their own
|
|
897
|
+
emitWindowScroll();
|
|
898
|
+
|
|
899
|
+
harness.show({ node, content, windowTarget: true, focused: false });
|
|
900
|
+
scrollY = 0;
|
|
901
|
+
docHeight = 4020;
|
|
902
|
+
harness.show({ node, content, windowTarget: true });
|
|
903
|
+
act(() => {
|
|
904
|
+
frames.flushOneFrame();
|
|
905
|
+
});
|
|
906
|
+
expect(window.scrollY).toBe(1000);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('still persists a GENUINE scroll to the top of a scrollable document', () => {
|
|
910
|
+
// The other direction, and the reason `canScroll` cannot simply answer
|
|
911
|
+
// `false`: on a document that can scroll, 0 is where the user is.
|
|
912
|
+
const node = new FakeScrollNode();
|
|
913
|
+
const content = unseenContent();
|
|
914
|
+
|
|
915
|
+
harness.show({ node, content, windowTarget: true });
|
|
916
|
+
scrollY = 5000;
|
|
917
|
+
emitWindowScroll();
|
|
918
|
+
scrollY = 0; // the user scrolls back up; the document is still tall
|
|
919
|
+
emitWindowScroll();
|
|
920
|
+
expect(document.documentElement.scrollHeight).toBeGreaterThan(
|
|
921
|
+
window.innerHeight,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
harness.show({ node, content, windowTarget: true, focused: false });
|
|
925
|
+
scrollY = 3000; // something nudges the shared scroller in between
|
|
926
|
+
harness.show({ node, content, windowTarget: true });
|
|
927
|
+
expect(window.scrollY).toBe(0);
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('returns a binding that is safe to wire onto a list on either platform', () => {
|
|
932
|
+
const node = new FakeScrollNode();
|
|
933
|
+
const seen: ScrollRestorationBinding[] = [];
|
|
934
|
+
|
|
935
|
+
harness.show({
|
|
936
|
+
node,
|
|
937
|
+
content: unseenContent(),
|
|
938
|
+
onBinding: (binding) => seen.push(binding),
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
expect(typeof seen[0]?.onScroll).toBe('function');
|
|
942
|
+
// Calling it on web must be harmless: this platform records offsets from
|
|
943
|
+
// the DOM node's own scroll event, and a shared call site still passes it.
|
|
944
|
+
expect(() =>
|
|
945
|
+
seen[0]?.onScroll({
|
|
946
|
+
nativeEvent: { contentOffset: { x: 0, y: 1234 } },
|
|
947
|
+
} as NativeSyntheticEvent<NativeScrollEvent>),
|
|
948
|
+
).not.toThrow();
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// ---- history.scrollRestoration ---------------------------------------------
|
|
953
|
+
// jsdom does not implement `history.scrollRestoration` at all ('scrollRestoration'
|
|
954
|
+
// in history === false), so the provider's feature detect makes it a no-op in
|
|
955
|
+
// every other suite here. This block installs the property first, which is what
|
|
956
|
+
// makes these the only tests that exercise the switch.
|
|
957
|
+
|
|
958
|
+
describe('manual browser scroll restoration', () => {
|
|
959
|
+
let installed: HTMLElement | null = null;
|
|
960
|
+
let root: Root | null = null;
|
|
961
|
+
|
|
962
|
+
function mountProvider(): void {
|
|
963
|
+
installed = document.createElement('div');
|
|
964
|
+
document.body.appendChild(installed);
|
|
965
|
+
root = createRoot(installed);
|
|
966
|
+
act(() => {
|
|
967
|
+
root?.render(
|
|
968
|
+
createElement(ScrollRestorationProvider, {
|
|
969
|
+
adapter: testAdapter,
|
|
970
|
+
children: null,
|
|
971
|
+
}),
|
|
972
|
+
);
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
beforeEach(() => {
|
|
977
|
+
Object.defineProperty(window.history, 'scrollRestoration', {
|
|
978
|
+
configurable: true,
|
|
979
|
+
writable: true,
|
|
980
|
+
value: 'auto',
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
afterEach(() => {
|
|
985
|
+
if (root && installed) {
|
|
986
|
+
act(() => {
|
|
987
|
+
root?.unmount();
|
|
988
|
+
});
|
|
989
|
+
installed.remove();
|
|
990
|
+
}
|
|
991
|
+
root = null;
|
|
992
|
+
installed = null;
|
|
993
|
+
delete (window.history as { scrollRestoration?: string }).scrollRestoration;
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('takes restoration over while the provider is mounted', () => {
|
|
997
|
+
mountProvider();
|
|
998
|
+
expect(history.scrollRestoration).toBe('manual');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('hands restoration back to the browser on pagehide', () => {
|
|
1002
|
+
// The offset map is in memory only: a reload starts empty and a bfcache
|
|
1003
|
+
// restore resumes a document whose focus effects never re-run. Leaving it
|
|
1004
|
+
// 'manual' on the way out means nobody restores at all.
|
|
1005
|
+
mountProvider();
|
|
1006
|
+
act(() => {
|
|
1007
|
+
window.dispatchEvent(new Event('pagehide'));
|
|
1008
|
+
});
|
|
1009
|
+
expect(history.scrollRestoration).toBe('auto');
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('hands restoration back on unmount', () => {
|
|
1013
|
+
mountProvider();
|
|
1014
|
+
expect(history.scrollRestoration).toBe('manual');
|
|
1015
|
+
act(() => {
|
|
1016
|
+
root?.unmount();
|
|
1017
|
+
});
|
|
1018
|
+
installed?.remove();
|
|
1019
|
+
root = null;
|
|
1020
|
+
installed = null;
|
|
1021
|
+
expect(history.scrollRestoration).toBe('auto');
|
|
333
1022
|
});
|
|
334
1023
|
});
|