@oxyhq/bloom 0.6.22 → 0.6.24

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/scroll/index.web.tsx"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,8BAA8B,EAC9B,uBAAuB,EACvB,2BAA2B,EAC5B,MAAM,SAAS,CAAC;AAEjB,YAAY,EACV,gBAAgB,EAChB,8BAA8B,EAC9B,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,SAAS,CAAC;AAgBjB;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,QAAQ,GACT,EAAE,8BAA8B,2CAOhC;AAYD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,uBAAuB,EAC/B,OAAO,CAAC,EAAE,2BAA2B,GACpC,IAAI,CAyDN"}
1
+ {"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/scroll/index.web.tsx"],"names":[],"mappings":"AAgDA,OAAO,KAAK,EACV,8BAA8B,EAC9B,uBAAuB,EACvB,2BAA2B,EAC5B,MAAM,SAAS,CAAC;AAEjB,YAAY,EACV,gBAAgB,EAChB,8BAA8B,EAC9B,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,SAAS,CAAC;AAmCjB;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,QAAQ,GACT,EAAE,8BAA8B,2CAOhC;AAYD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,uBAAuB,EAC/B,OAAO,CAAC,EAAE,2BAA2B,GACpC,IAAI,CAqGN"}
@@ -1,12 +1,24 @@
1
1
  import type { ScrollRestorationTarget } from './types';
2
2
  /**
3
3
  * A normalized read/write interface over whatever the caller registered, so
4
- * the hook does not branch on target shape. Both methods are no-ops when the
5
- * underlying element is not yet (or no longer) attached.
4
+ * the hook does not branch on target shape. All methods are safe no-ops when
5
+ * the underlying element is not yet (or no longer) attached.
6
6
  */
7
7
  export interface ResolvedScroller {
8
8
  getOffset: () => number;
9
9
  setOffset: (offset: number) => void;
10
+ /**
11
+ * Whether the scroll container can currently hold a non-zero offset, i.e.
12
+ * its content is taller than its viewport (`scrollHeight > clientHeight`).
13
+ *
14
+ * React Navigation's web stack collapses a hidden background screen so its
15
+ * content height drops to the viewport height; while collapsed the container
16
+ * cannot be scrolled and its `scrollTop` is forced to 0. The hook uses this
17
+ * to ignore a spurious 0 read coming from a collapsed container rather than
18
+ * persisting it over a previously-saved good offset. The `'window'` scroller
19
+ * is never collapsed by the navigator, so it always reports `true`.
20
+ */
21
+ canScroll: () => boolean;
10
22
  }
11
23
  /**
12
24
  * Build a {@link ResolvedScroller} for a target. The window sentinel reads and
@@ -1 +1 @@
1
- {"version":3,"file":"scrollable.web.d.ts","sourceRoot":"","sources":["../../../../src/scroll/scrollable.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEzE;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,MAAM,CAAC;IACxB,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAuCD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,uBAAuB,GAAG,gBAAgB,CAoBhF"}
1
+ {"version":3,"file":"scrollable.web.d.ts","sourceRoot":"","sources":["../../../../src/scroll/scrollable.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAEzE;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,MAAM,CAAC;IACxB,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;;;;;;;OAUG;IACH,SAAS,EAAE,MAAM,OAAO,CAAC;CAC1B;AAuCD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,uBAAuB,GAAG,gBAAgB,CAyBhF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.6.22",
3
+ "version": "0.6.24",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -631,7 +631,6 @@
631
631
  }
632
632
  },
633
633
  "devDependencies": {
634
- "@react-navigation/native": "^7.0.0",
635
634
  "@storybook/addon-docs": "^10",
636
635
  "@storybook/react-vite": "^10",
637
636
  "@testing-library/react-native": "^13.3.3",
@@ -641,6 +640,7 @@
641
640
  "@types/react-native": "*",
642
641
  "@vitejs/plugin-react": "^6.0.2",
643
642
  "expo-font": "^56.0.5",
643
+ "expo-router": "^56.0.0",
644
644
  "jest": "^30.3.0",
645
645
  "jest-environment-jsdom": "^30.4.1",
646
646
  "react": "19.2.0",
@@ -662,9 +662,9 @@
662
662
  "vite": "^7"
663
663
  },
664
664
  "peerDependencies": {
665
- "@react-navigation/native": ">=6.0.0",
666
665
  "expo": "*",
667
666
  "expo-font": "*",
667
+ "expo-router": ">=3.0.0",
668
668
  "react": ">=18.0.0",
669
669
  "react-dom": ">=18.0.0",
670
670
  "react-native": ">=0.73.0",
@@ -676,15 +676,15 @@
676
676
  "sonner-native": ">=0.17.0"
677
677
  },
678
678
  "peerDependenciesMeta": {
679
- "@react-navigation/native": {
680
- "optional": true
681
- },
682
679
  "expo": {
683
680
  "optional": true
684
681
  },
685
682
  "expo-font": {
686
683
  "optional": true
687
684
  },
685
+ "expo-router": {
686
+ "optional": true
687
+ },
688
688
  "react-dom": {
689
689
  "optional": true
690
690
  },
@@ -0,0 +1,334 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
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.
11
+ //
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.
16
+
17
+ import { createElement, useRef, type ReactNode } from 'react';
18
+ import { act } from 'react';
19
+ import { createRoot, type Root } from 'react-dom/client';
20
+
21
+ // React 19's `act` requires this flag to be set when driving updates manually
22
+ // outside a testing-library renderer.
23
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
24
+ true;
25
+
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
+
50
+ // Imported AFTER the mock is registered.
51
+ import {
52
+ ScrollRestorationProvider,
53
+ useScrollRestoration,
54
+ } from '../scroll/index.web';
55
+
56
+ // ---- requestAnimationFrame harness ---------------------------------------
57
+
58
+ type FrameCallback = (time: number) => void;
59
+
60
+ class FrameScheduler {
61
+ private queue = new Map<number, FrameCallback>();
62
+ private nextId = 1;
63
+
64
+ install(): void {
65
+ window.requestAnimationFrame = ((cb: FrameCallback): number => {
66
+ const id = this.nextId++;
67
+ this.queue.set(id, cb);
68
+ return id;
69
+ }) as typeof window.requestAnimationFrame;
70
+ window.cancelAnimationFrame = (id: number): void => {
71
+ this.queue.delete(id);
72
+ };
73
+ }
74
+
75
+ /** Run one frame's worth of scheduled callbacks (those queued before now). */
76
+ flushOneFrame(): void {
77
+ const batch = [...this.queue.entries()];
78
+ this.queue.clear();
79
+ for (const [, cb] of batch) cb(performance.now());
80
+ }
81
+
82
+ get pending(): number {
83
+ return this.queue.size;
84
+ }
85
+ }
86
+
87
+ // ---- A fake RNW scrollable -------------------------------------------------
88
+ // Models a FlashList's underlying DOM node. It MUST be a real HTMLElement
89
+ // because the scroller resolves the live node via `instanceof HTMLElement`.
90
+ // jsdom does no layout, so we own `scrollHeight`/`clientHeight`/`scrollTop`
91
+ // ourselves: `scrollTop` is clamped to `scrollHeight - clientHeight` on write
92
+ // (as a real element is), content starts collapsed and grows on "relayout".
93
+
94
+ const VIEWPORT_HEIGHT = 879;
95
+
96
+ class FakeScrollNode {
97
+ readonly el: HTMLDivElement;
98
+ private _scrollHeight = VIEWPORT_HEIGHT; // collapsed: equals clientHeight
99
+ private _scrollTop = 0;
100
+
101
+ constructor() {
102
+ const el = document.createElement('div');
103
+ Object.defineProperty(el, 'clientHeight', {
104
+ configurable: true,
105
+ get: () => VIEWPORT_HEIGHT,
106
+ });
107
+ Object.defineProperty(el, 'scrollHeight', {
108
+ configurable: true,
109
+ get: () => this._scrollHeight,
110
+ });
111
+ Object.defineProperty(el, 'scrollTop', {
112
+ configurable: true,
113
+ get: () => this._scrollTop,
114
+ set: (value: number) => {
115
+ const max = Math.max(0, this._scrollHeight - VIEWPORT_HEIGHT);
116
+ this._scrollTop = Math.min(Math.max(0, value), max);
117
+ },
118
+ });
119
+ this.el = el;
120
+ }
121
+
122
+ get scrollTop(): number {
123
+ return this.el.scrollTop;
124
+ }
125
+ set scrollTop(value: number) {
126
+ this.el.scrollTop = value;
127
+ }
128
+
129
+ /** Grow content to its full laid-out height (re-show relayout). */
130
+ growTo(height: number): void {
131
+ this._scrollHeight = height;
132
+ }
133
+
134
+ /**
135
+ * Reproduce what React Navigation's web stack does to a hidden background
136
+ * screen: collapse its content to the viewport height and force scrollTop to
137
+ * 0 (a collapsed container cannot hold a non-zero offset).
138
+ */
139
+ collapseLikeNavigator(): void {
140
+ this._scrollHeight = VIEWPORT_HEIGHT;
141
+ this._scrollTop = 0;
142
+ }
143
+
144
+ emitScroll(): void {
145
+ this.el.dispatchEvent(new Event('scroll'));
146
+ }
147
+ }
148
+
149
+ // A RNW-style handle exposing `getScrollableNode()` -> the DOM node.
150
+ function makeHandle(node: FakeScrollNode): { getScrollableNode: () => HTMLElement } {
151
+ return { getScrollableNode: () => node.el };
152
+ }
153
+
154
+ // ---- Render harness --------------------------------------------------------
155
+ //
156
+ // A single <ScrollRestorationProvider> stays mounted (mirroring the real app
157
+ // 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.
159
+
160
+ function Screen({ node }: { node: FakeScrollNode }): ReactNode {
161
+ const ref = useRef(makeHandle(node));
162
+ useScrollRestoration(ref);
163
+ return null;
164
+ }
165
+
166
+ class Harness {
167
+ readonly root: Root;
168
+ private readonly container: HTMLElement;
169
+
170
+ constructor() {
171
+ this.container = document.createElement('div');
172
+ document.body.appendChild(this.container);
173
+ this.root = createRoot(this.container);
174
+ }
175
+
176
+ /** Mount (focus) the screen bound to `node`. */
177
+ focus(node: FakeScrollNode): void {
178
+ act(() => {
179
+ this.root.render(
180
+ createElement(
181
+ ScrollRestorationProvider,
182
+ null,
183
+ createElement(Screen, { node }),
184
+ ),
185
+ );
186
+ });
187
+ }
188
+
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
+ teardown(): void {
197
+ act(() => {
198
+ this.root.unmount();
199
+ });
200
+ this.container.remove();
201
+ }
202
+ }
203
+
204
+ describe('web scroll-restoration hook', () => {
205
+ let frames: FrameScheduler;
206
+ let harness: Harness;
207
+
208
+ beforeEach(() => {
209
+ frames = new FrameScheduler();
210
+ frames.install();
211
+ currentRouteKey = `route-${Math.random().toString(36).slice(2)}`;
212
+ harness = new Harness();
213
+ });
214
+
215
+ afterEach(() => {
216
+ harness.teardown();
217
+ });
218
+
219
+ it('does not clobber the saved offset when the navigator collapses the screen on blur (bug A)', () => {
220
+ const node = new FakeScrollNode();
221
+ node.growTo(6586); // real content
222
+ harness.focus(node);
223
+
224
+ // User scrolls to 3520 — the live scroll listener records it.
225
+ node.scrollTop = 3520;
226
+ act(() => {
227
+ node.emitScroll();
228
+ });
229
+
230
+ // Navigator collapses the hidden screen and forces scrollTop to 0 while the
231
+ // screen is still technically focused (a stray scroll event fires). This
232
+ // must NOT be persisted over the good 3520.
233
+ node.collapseLikeNavigator();
234
+ act(() => {
235
+ node.emitScroll();
236
+ });
237
+
238
+ // Blur: cleanup runs and must persist the last GOOD offset, not the 0.
239
+ harness.blur();
240
+
241
+ // Re-show with full content height: restore should reach 3520 in one frame.
242
+ node.growTo(6586);
243
+ node.scrollTop = 0;
244
+ harness.focus(node);
245
+ act(() => {
246
+ frames.flushOneFrame();
247
+ });
248
+ expect(node.scrollTop).toBe(3520);
249
+ });
250
+
251
+ it('re-applies the offset across frames until the list reaches full height (bug B)', () => {
252
+ 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();
260
+
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.
263
+ node.collapseLikeNavigator();
264
+ harness.focus(node);
265
+
266
+ // Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
267
+ act(() => {
268
+ frames.flushOneFrame();
269
+ });
270
+ expect(node.scrollTop).toBe(0);
271
+ expect(frames.pending).toBeGreaterThan(0);
272
+
273
+ // A few frames later the rows lay out and the content reaches full height.
274
+ node.growTo(6586);
275
+ act(() => {
276
+ frames.flushOneFrame();
277
+ });
278
+ expect(node.scrollTop).toBe(3520);
279
+ });
280
+
281
+ it('treats a saved offset of 0 as a no-op (no restore loop scheduled)', () => {
282
+ 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.
286
+ expect(frames.pending).toBe(0);
287
+ });
288
+
289
+ it('persists a genuine scroll-to-top over a previously-saved offset', () => {
290
+ const node = new FakeScrollNode();
291
+ node.growTo(6586);
292
+ harness.focus(node);
293
+ node.scrollTop = 3520;
294
+ act(() => {
295
+ node.emitScroll();
296
+ });
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;
300
+ act(() => {
301
+ node.emitScroll();
302
+ });
303
+ harness.blur();
304
+
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);
308
+ expect(frames.pending).toBe(0);
309
+ expect(node.scrollTop).toBe(500);
310
+ });
311
+
312
+ it('stops retrying after the frame cap even if the content never grows', () => {
313
+ const node = new FakeScrollNode();
314
+ node.growTo(6586);
315
+ harness.focus(node);
316
+ node.scrollTop = 3520;
317
+ act(() => {
318
+ node.emitScroll();
319
+ });
320
+ harness.blur();
321
+
322
+ // Re-show that never reaches full height.
323
+ node.collapseLikeNavigator();
324
+ harness.focus(node);
325
+
326
+ // Flush far more than the cap; the loop must terminate.
327
+ for (let i = 0; i < 60; i++) {
328
+ act(() => {
329
+ frames.flushOneFrame();
330
+ });
331
+ }
332
+ expect(frames.pending).toBe(0);
333
+ });
334
+ });
@@ -2,18 +2,47 @@
2
2
  * Web variant of the scroll-restoration primitive.
3
3
  *
4
4
  * Mirrors the proven Bluesky pattern (`history.scrollRestoration = 'manual'`
5
- * plus an in-memory `Map<routeKey, offset>` saved on blur and restored on focus
6
- * inside a single `requestAnimationFrame`) with one deliberate difference:
7
- * Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
8
- * layouts whose feed scrolls an INNER container. So we restore the offset of a
9
- * caller-registered scrollable (a ref to an element / RN scroll component, or
10
- * the `'window'` sentinel), keyed by the active navigation route.
5
+ * plus an in-memory `Map<routeKey, offset>`) with two deliberate differences
6
+ * forced by Oxy's layouts and the behaviour of the (expo-router-wrapped)
7
+ * React Navigation web stack:
8
+ *
9
+ * 1. Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
10
+ * layouts whose feed scrolls an INNER container. So we restore the offset
11
+ * of a caller-registered scrollable (a ref to an element / RN scroll
12
+ * component, or the `'window'` sentinel), keyed by the active route.
13
+ *
14
+ * 2. The web stack HIDES the background screen on push. While
15
+ * hidden, the previous screen's scroll container collapses
16
+ * (`scrollHeight === clientHeight`) and the navigator forces its
17
+ * `scrollTop` to 0. The screen is NOT unmounted, so a virtualized list
18
+ * (e.g. FlashList) keeps its rows but re-lays them out over SEVERAL frames
19
+ * once the screen is re-shown. Two problems follow, both handled here:
20
+ *
21
+ * (a) A blur-time read of `scrollTop` returns the navigator's forced 0,
22
+ * not the user's real offset — saving it would clobber the good
23
+ * value. We therefore persist the last offset OBSERVED by the live
24
+ * scroll listener, never a fresh read taken at blur time.
25
+ *
26
+ * (b) A single-frame restore writes `scrollTop` while the list is still
27
+ * collapsed; the write is clamped to 0 and never re-applied once the
28
+ * content grows. We therefore re-apply the target offset across a
29
+ * bounded run of animation frames, stopping as soon as the write
30
+ * sticks (the content has grown tall enough) or a small frame cap is
31
+ * reached.
11
32
  *
12
33
  * Native bundlers use `./index.ts` (a no-op); web bundlers select this file via
13
34
  * the `"browser"` export condition in `package.json`.
35
+ *
36
+ * The navigation hooks are imported from `expo-router` (which re-exports
37
+ * `useFocusEffect` and `useRoute` from its bundled React Navigation core) rather
38
+ * than from `@react-navigation/native` directly. Every Oxy app uses expo-router
39
+ * as its router, so it is always a DIRECT, top-level dependency that resolves
40
+ * cleanly under Bun's isolated linker — whereas `@react-navigation/native` is
41
+ * only a nested/transitive dependency of expo-router and would fail to resolve
42
+ * when bundling those apps.
14
43
  */
15
44
  import { createContext, useCallback, useContext, useMemo, useRef } from 'react';
16
- import { useFocusEffect, useRoute } from '@react-navigation/native';
45
+ import { useFocusEffect, useRoute } from 'expo-router';
17
46
 
18
47
  import { createScroller } from './scrollable.web';
19
48
  import { ScrollOffsetStore, deriveScrollKey } from './store';
@@ -33,6 +62,25 @@ export type {
33
62
  const ScrollOffsetContext = createContext<ScrollOffsetStore | null>(null);
34
63
  ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
35
64
 
65
+ /**
66
+ * Maximum number of animation frames the focus restore will re-apply the saved
67
+ * offset before giving up. A virtualized list re-lays out its rows over a
68
+ * handful of frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps)
69
+ * is comfortably longer than any observed relayout while staying short enough
70
+ * that the loop never lingers as a perceptible cost. The loop normally exits
71
+ * far earlier — as soon as the write sticks.
72
+ */
73
+ const RESTORE_FRAME_CAP = 30;
74
+
75
+ /**
76
+ * Tolerance (in CSS pixels) for considering a restore "stuck". After writing
77
+ * `element.scrollTop = target`, the browser may clamp it to the current
78
+ * `scrollHeight - clientHeight`; if the resulting offset is within this many
79
+ * pixels of the target we treat the restore as complete. Sub-pixel rounding and
80
+ * fractional device-pixel ratios make an exact equality check unreliable.
81
+ */
82
+ const RESTORE_STICK_TOLERANCE_PX = 2;
83
+
36
84
  /**
37
85
  * Switch the browser to manual scroll restoration exactly once per document.
38
86
  *
@@ -76,11 +124,15 @@ function useScrollOffsetStore(): ScrollOffsetStore {
76
124
  * multiple scrollables).
77
125
  *
78
126
  * Behaviour (web):
79
- * - On every scroll while the screen is focused, the current offset is saved.
80
- * - On focus, the saved offset is applied in a single `requestAnimationFrame`,
81
- * giving a remounted list one frame to lay out its content first (no retry
82
- * loops, no hide/show tricks).
83
- * - On blur, the latest offset is captured as a final safety net.
127
+ * - On every scroll while the screen is focused, the current offset is recorded
128
+ * in memory and persisted. This live stream of saves is the source of truth.
129
+ * - On focus, the saved offset is re-applied across a bounded run of animation
130
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
131
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
132
+ * saved offset of 0 is a no-op (nothing to restore).
133
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
134
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
135
+ * while collapsing the hidden screen.
84
136
  */
85
137
  export function useScrollRestoration(
86
138
  target: ScrollRestorationTarget,
@@ -104,7 +156,8 @@ export function useScrollRestoration(
104
156
 
105
157
  useFocusEffect(
106
158
  // The effect identity is intentionally stable across renders: it reads all
107
- // varying inputs from refs. React Navigation re-runs it on each focus.
159
+ // varying inputs from refs. expo-router's `useFocusEffect` re-runs it on
160
+ // each focus.
108
161
  useCallback(
109
162
  () => {
110
163
  const key = scrollKeyRef.current;
@@ -116,27 +169,70 @@ export function useScrollRestoration(
116
169
  ? (typeof window !== 'undefined' ? window : null)
117
170
  : resolveScrollEventTarget(targetRef.current);
118
171
 
172
+ // The last offset the live scroll listener observed for this focus
173
+ // session. This — not a blur-time `getOffset()` — is what we persist on
174
+ // blur, because by blur time the navigator may have collapsed the
175
+ // hidden screen and forced its `scrollTop` to 0 (bug A). `null` means
176
+ // the user never scrolled this session, so there is nothing newer to
177
+ // persist than what the scroll listener already saved live.
178
+ let lastObservedOffset: number | null = null;
179
+
119
180
  const save = () => {
120
181
  const currentKey = scrollKeyRef.current;
121
- if (enabledRef.current && currentKey !== null) {
122
- store.save(currentKey, scroller.getOffset());
123
- }
182
+ if (!enabledRef.current || currentKey === null) return;
183
+ const offset = scroller.getOffset();
184
+ // Ignore a spurious 0 produced by the navigator collapsing a hidden
185
+ // background screen: while collapsed the container cannot scroll, so
186
+ // its `scrollTop` is forced to 0. Persisting it would clobber the
187
+ // good offset recorded by earlier live saves (bug A). A genuine
188
+ // scroll-to-top keeps the container scrollable and is saved normally.
189
+ if (offset === 0 && !scroller.canScroll()) return;
190
+ lastObservedOffset = offset;
191
+ store.save(currentKey, offset);
124
192
  };
125
193
 
126
- // Restore on the next frame so a freshly remounted list has rendered
127
- // its content (and thus reached its full scroll height) before we move.
128
- const frame = requestAnimationFrame(() => {
129
- scroller.setOffset(store.read(key));
130
- });
194
+ // Restore across a bounded run of frames. A freshly re-shown
195
+ // virtualized list re-lays out its rows over several frames, so a
196
+ // single write while it is still collapsed would be clamped to 0 and
197
+ // never re-applied (bug B). We re-apply each frame until the write
198
+ // sticks or the frame cap is hit.
199
+ const targetOffset = store.read(key);
200
+ let rafId: number | null = null;
201
+
202
+ if (targetOffset > 0 && typeof requestAnimationFrame !== 'undefined') {
203
+ let framesLeft = RESTORE_FRAME_CAP;
204
+ const applyOffset = () => {
205
+ rafId = null;
206
+ scroller.setOffset(targetOffset);
207
+ framesLeft -= 1;
208
+ // Stop once the write took effect (content grew tall enough) or we
209
+ // exhaust the frame budget. `getOffset` re-reads the clamped value.
210
+ const reached =
211
+ Math.abs(scroller.getOffset() - targetOffset) <=
212
+ RESTORE_STICK_TOLERANCE_PX;
213
+ if (!reached && framesLeft > 0) {
214
+ rafId = requestAnimationFrame(applyOffset);
215
+ }
216
+ };
217
+ rafId = requestAnimationFrame(applyOffset);
218
+ }
131
219
 
132
220
  element?.addEventListener('scroll', save, { passive: true });
133
221
 
134
222
  return () => {
135
- cancelAnimationFrame(frame);
223
+ if (rafId !== null) cancelAnimationFrame(rafId);
136
224
  element?.removeEventListener('scroll', save);
137
- // Final capture on blur, covering navigations that don't fire a
138
- // trailing scroll event.
139
- save();
225
+ // Final capture on blur: persist the last offset the scroll listener
226
+ // OBSERVED, never a fresh read (which the navigator may have forced
227
+ // to 0 while collapsing the hidden screen). When the user never
228
+ // scrolled this session there is nothing newer to persist than the
229
+ // live saves already recorded.
230
+ if (lastObservedOffset !== null) {
231
+ const currentKey = scrollKeyRef.current;
232
+ if (enabledRef.current && currentKey !== null) {
233
+ store.save(currentKey, lastObservedOffset);
234
+ }
235
+ }
140
236
  };
141
237
  },
142
238
  [store],
@@ -2,12 +2,24 @@ import type { ScrollableHandle, ScrollRestorationTarget } from './types';
2
2
 
3
3
  /**
4
4
  * A normalized read/write interface over whatever the caller registered, so
5
- * the hook does not branch on target shape. Both methods are no-ops when the
6
- * underlying element is not yet (or no longer) attached.
5
+ * the hook does not branch on target shape. All methods are safe no-ops when
6
+ * the underlying element is not yet (or no longer) attached.
7
7
  */
8
8
  export interface ResolvedScroller {
9
9
  getOffset: () => number;
10
10
  setOffset: (offset: number) => void;
11
+ /**
12
+ * Whether the scroll container can currently hold a non-zero offset, i.e.
13
+ * its content is taller than its viewport (`scrollHeight > clientHeight`).
14
+ *
15
+ * React Navigation's web stack collapses a hidden background screen so its
16
+ * content height drops to the viewport height; while collapsed the container
17
+ * cannot be scrolled and its `scrollTop` is forced to 0. The hook uses this
18
+ * to ignore a spurious 0 read coming from a collapsed container rather than
19
+ * persisting it over a previously-saved good offset. The `'window'` scroller
20
+ * is never collapsed by the navigator, so it always reports `true`.
21
+ */
22
+ canScroll: () => boolean;
11
23
  }
12
24
 
13
25
  function isElement(value: unknown): value is HTMLElement {
@@ -59,6 +71,7 @@ export function createScroller(target: ScrollRestorationTarget): ResolvedScrolle
59
71
  setOffset: (offset) => {
60
72
  if (typeof window !== 'undefined') window.scrollTo(0, offset);
61
73
  },
74
+ canScroll: () => true,
62
75
  };
63
76
  }
64
77
 
@@ -71,5 +84,9 @@ export function createScroller(target: ScrollRestorationTarget): ResolvedScrolle
71
84
  const element = resolveElement(target);
72
85
  if (element) element.scrollTop = offset;
73
86
  },
87
+ canScroll: () => {
88
+ const element = resolveElement(target);
89
+ return element ? element.scrollHeight > element.clientHeight : false;
90
+ },
74
91
  };
75
92
  }