@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.
Files changed (147) hide show
  1. package/README.md +3 -3
  2. package/lib/commonjs/provider/index.js +11 -7
  3. package/lib/commonjs/provider/index.js.map +1 -1
  4. package/lib/commonjs/scroll/context.js +96 -0
  5. package/lib/commonjs/scroll/context.js.map +1 -0
  6. package/lib/commonjs/scroll/expo-router/index.js +133 -0
  7. package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
  8. package/lib/commonjs/scroll/index.js +85 -21
  9. package/lib/commonjs/scroll/index.js.map +1 -1
  10. package/lib/commonjs/scroll/index.web.js +221 -121
  11. package/lib/commonjs/scroll/index.web.js.map +1 -1
  12. package/lib/commonjs/scroll/scrollable.native.js +58 -0
  13. package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
  14. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  15. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  16. package/lib/commonjs/scroll/store.js +47 -25
  17. package/lib/commonjs/scroll/store.js.map +1 -1
  18. package/lib/commonjs/tabs/Tabs.js +263 -63
  19. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  20. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  21. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  22. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  23. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  24. package/lib/commonjs/theme/color-policy.js +11 -10
  25. package/lib/commonjs/theme/color-policy.js.map +1 -1
  26. package/lib/module/provider/index.js +10 -6
  27. package/lib/module/provider/index.js.map +1 -1
  28. package/lib/module/scroll/context.js +90 -0
  29. package/lib/module/scroll/context.js.map +1 -0
  30. package/lib/module/scroll/expo-router/index.js +128 -0
  31. package/lib/module/scroll/expo-router/index.js.map +1 -0
  32. package/lib/module/scroll/index.js +80 -21
  33. package/lib/module/scroll/index.js.map +1 -1
  34. package/lib/module/scroll/index.web.js +218 -122
  35. package/lib/module/scroll/index.web.js.map +1 -1
  36. package/lib/module/scroll/scrollable.native.js +54 -0
  37. package/lib/module/scroll/scrollable.native.js.map +1 -0
  38. package/lib/module/scroll/scrollable.web.js +9 -3
  39. package/lib/module/scroll/scrollable.web.js.map +1 -1
  40. package/lib/module/scroll/store.js +47 -25
  41. package/lib/module/scroll/store.js.map +1 -1
  42. package/lib/module/tabs/Tabs.js +271 -65
  43. package/lib/module/tabs/Tabs.js.map +1 -1
  44. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  45. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  46. package/lib/module/tabs/expo-router/index.js +10 -0
  47. package/lib/module/tabs/expo-router/index.js.map +1 -0
  48. package/lib/module/theme/color-policy.js +11 -10
  49. package/lib/module/theme/color-policy.js.map +1 -1
  50. package/lib/typescript/commonjs/provider/index.d.ts +7 -5
  51. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
  53. package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
  55. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
  57. package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
  59. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
  61. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  63. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
  65. package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
  67. package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  69. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  71. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  73. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  75. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  77. package/lib/typescript/module/provider/index.d.ts +7 -5
  78. package/lib/typescript/module/provider/index.d.ts.map +1 -1
  79. package/lib/typescript/module/scroll/context.d.ts +14 -0
  80. package/lib/typescript/module/scroll/context.d.ts.map +1 -0
  81. package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
  82. package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
  83. package/lib/typescript/module/scroll/index.d.ts +14 -23
  84. package/lib/typescript/module/scroll/index.d.ts.map +1 -1
  85. package/lib/typescript/module/scroll/index.web.d.ts +17 -19
  86. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  87. package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
  88. package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
  89. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  90. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  91. package/lib/typescript/module/scroll/store.d.ts +41 -22
  92. package/lib/typescript/module/scroll/store.d.ts.map +1 -1
  93. package/lib/typescript/module/scroll/types.d.ts +82 -12
  94. package/lib/typescript/module/scroll/types.d.ts.map +1 -1
  95. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  96. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  97. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  98. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  99. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  100. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  101. package/lib/typescript/module/tabs/types.d.ts +48 -4
  102. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  103. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  104. package/package.json +29 -1
  105. package/src/__tests__/BloomProvider.web.test.tsx +8 -15
  106. package/src/__tests__/Tabs.test.tsx +187 -0
  107. package/src/__tests__/optional-peer-imports.test.ts +7 -2
  108. package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
  109. package/src/__tests__/scroll-native.test.tsx +465 -0
  110. package/src/__tests__/scroll-store.test.ts +41 -17
  111. package/src/__tests__/scroll-web.test.tsx +797 -108
  112. package/src/provider/index.tsx +10 -7
  113. package/src/scroll/context.tsx +107 -0
  114. package/src/scroll/expo-router/index.ts +140 -0
  115. package/src/scroll/index.ts +92 -25
  116. package/src/scroll/index.web.tsx +255 -162
  117. package/src/scroll/scrollable.native.ts +64 -0
  118. package/src/scroll/scrollable.web.ts +30 -8
  119. package/src/scroll/store.ts +47 -25
  120. package/src/scroll/types.ts +82 -12
  121. package/src/tabs/Tabs.tsx +352 -77
  122. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  123. package/src/tabs/expo-router/index.ts +8 -0
  124. package/src/tabs/types.ts +48 -4
  125. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +5 -5
  126. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +11 -11
  127. package/src/theme/__tests__/policy-legibility.test.ts +10 -7
  128. package/src/theme/color-policy.ts +11 -10
  129. package/lib/commonjs/provider/scroll-provider.js +0 -13
  130. package/lib/commonjs/provider/scroll-provider.js.map +0 -1
  131. package/lib/commonjs/provider/scroll-provider.web.js +0 -13
  132. package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
  133. package/lib/module/provider/scroll-provider.js +0 -16
  134. package/lib/module/provider/scroll-provider.js.map +0 -1
  135. package/lib/module/provider/scroll-provider.web.js +0 -5
  136. package/lib/module/provider/scroll-provider.web.js.map +0 -1
  137. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
  138. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
  139. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
  140. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
  141. package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
  142. package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
  143. package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
  144. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
  145. package/src/__tests__/scroll-native.test.ts +0 -25
  146. package/src/provider/scroll-provider.ts +0 -13
  147. 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
- // exact failure mode it was written to survive: the (expo-router-wrapped)
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
- // We mock `expo-router` (the module the hook imports its navigation hooks from)
13
- // so `useFocusEffect` runs the effect on mount and its cleanup on unmount, and
14
- // drive `requestAnimationFrame` manually so the multi-frame restore is
15
- // deterministic.
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 { createElement, useRef, type ReactNode } from 'react';
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
- // ---- expo-router mock ------------------------------------------------------
27
- // `useFocusEffect` here mirrors the real contract closely enough for this hook:
28
- // it runs the callback in a layout effect and runs the returned cleanup on
29
- // unmount. `useRoute` yields a stable per-test route key.
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
- // Imported AFTER the mock is registered.
51
- import {
52
- ScrollRestorationProvider,
53
- useScrollRestoration,
54
- } from '../scroll/index.web';
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 mounts and unmounts to model push/pop navigation.
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({ node }: { node: FakeScrollNode }): ReactNode {
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
- /** Mount (focus) the screen bound to `node`. */
177
- focus(node: FakeScrollNode): void {
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
- ScrollRestorationProvider,
182
- null,
183
- createElement(Screen, { node }),
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('does not clobber the saved offset when the navigator collapses the screen on blur (bug A)', () => {
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(6586); // real content
222
- harness.focus(node);
278
+ node.growTo(FULL_CONTENT_HEIGHT);
279
+ node.scrollTop = 3000; // where the previous screen left the shared scroller
223
280
 
224
- // User scrolls to 3520 — the live scroll listener records it.
225
- node.scrollTop = 3520;
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
- node.emitScroll();
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.blur();
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(6586);
434
+ node.growTo(FULL_CONTENT_HEIGHT);
243
435
  node.scrollTop = 0;
244
- harness.focus(node);
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('re-applies the offset across frames until the list reaches full height (bug B)', () => {
443
+ it('persists a genuine scroll-to-top over a previously-saved offset', () => {
252
444
  const node = new FakeScrollNode();
253
- node.growTo(6586);
254
- harness.focus(node);
255
- node.scrollTop = 3520;
256
- act(() => {
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
- // Re-show: list starts collapsed (rows not yet rendered) and grows over
262
- // frames. A single-frame restore would be clamped to 0 and never recover.
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.focus(node);
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
- // A few frames later the rows lay out and the content reaches full height.
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('treats a saved offset of 0 as a no-op (no restore loop scheduled)', () => {
551
+ it('is inert when the adapter cannot identify the screen', () => {
282
552
  const node = new FakeScrollNode();
283
- node.growTo(6586);
284
- harness.focus(node);
285
- // Nothing saved for this fresh route key => read() is 0 => no rAF queued.
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('persists a genuine scroll-to-top over a previously-saved offset', () => {
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(6586);
292
- harness.focus(node);
293
- node.scrollTop = 3520;
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
- node.emitScroll();
592
+ frames.flushOneFrame();
296
593
  });
297
- // User scrolls all the way back to the top; container is NOT collapsed, so
298
- // this 0 is genuine and must overwrite the saved 3520.
299
- node.scrollTop = 0;
594
+ expect(node.scrollTop).toBe(2400);
595
+
596
+ // Browser Back to the post.
597
+ harness.show({ node, content: post });
300
598
  act(() => {
301
- node.emitScroll();
599
+ frames.flushOneFrame();
302
600
  });
303
- harness.blur();
601
+ expect(node.scrollTop).toBe(800);
304
602
 
305
- // Re-show: saved offset is 0 => restore is a no-op, nothing scheduled.
306
- node.scrollTop = 500; // pretend something nudged it after re-show
307
- harness.focus(node);
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('stops retrying after the frame cap even if the content never grows', () => {
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(6586);
315
- harness.focus(node);
316
- node.scrollTop = 3520;
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
- node.emitScroll();
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
- // Re-show that never reaches full height.
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
- harness.focus(node);
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
- // Flush far more than the cap; the loop must terminate.
327
- for (let i = 0; i < 60; i++) {
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
  });