@oxyhq/bloom 0.78.0 → 0.79.1

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