@oxyhq/bloom 0.6.22 → 0.6.23

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.
@@ -14,12 +14,32 @@ var _jsxRuntime = require("react/jsx-runtime");
14
14
  * Web variant of the scroll-restoration primitive.
15
15
  *
16
16
  * Mirrors the proven Bluesky pattern (`history.scrollRestoration = 'manual'`
17
- * plus an in-memory `Map<routeKey, offset>` saved on blur and restored on focus
18
- * inside a single `requestAnimationFrame`) with one deliberate difference:
19
- * Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
20
- * layouts whose feed scrolls an INNER container. So we restore the offset of a
21
- * caller-registered scrollable (a ref to an element / RN scroll component, or
22
- * the `'window'` sentinel), keyed by the active navigation route.
17
+ * plus an in-memory `Map<routeKey, offset>`) with two deliberate differences
18
+ * forced by Oxy's layouts and the behaviour of React Navigation's web stack:
19
+ *
20
+ * 1. Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
21
+ * layouts whose feed scrolls an INNER container. So we restore the offset
22
+ * of a caller-registered scrollable (a ref to an element / RN scroll
23
+ * component, or the `'window'` sentinel), keyed by the active route.
24
+ *
25
+ * 2. React Navigation's web stack HIDES the background screen on push. While
26
+ * hidden, the previous screen's scroll container collapses
27
+ * (`scrollHeight === clientHeight`) and the navigator forces its
28
+ * `scrollTop` to 0. The screen is NOT unmounted, so a virtualized list
29
+ * (e.g. FlashList) keeps its rows but re-lays them out over SEVERAL frames
30
+ * once the screen is re-shown. Two problems follow, both handled here:
31
+ *
32
+ * (a) A blur-time read of `scrollTop` returns the navigator's forced 0,
33
+ * not the user's real offset — saving it would clobber the good
34
+ * value. We therefore persist the last offset OBSERVED by the live
35
+ * scroll listener, never a fresh read taken at blur time.
36
+ *
37
+ * (b) A single-frame restore writes `scrollTop` while the list is still
38
+ * collapsed; the write is clamped to 0 and never re-applied once the
39
+ * content grows. We therefore re-apply the target offset across a
40
+ * bounded run of animation frames, stopping as soon as the write
41
+ * sticks (the content has grown tall enough) or a small frame cap is
42
+ * reached.
23
43
  *
24
44
  * Native bundlers use `./index.ts` (a no-op); web bundlers select this file via
25
45
  * the `"browser"` export condition in `package.json`.
@@ -28,6 +48,25 @@ var _jsxRuntime = require("react/jsx-runtime");
28
48
  const ScrollOffsetContext = /*#__PURE__*/(0, _react.createContext)(null);
29
49
  ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
30
50
 
51
+ /**
52
+ * Maximum number of animation frames the focus restore will re-apply the saved
53
+ * offset before giving up. A virtualized list re-lays out its rows over a
54
+ * handful of frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps)
55
+ * is comfortably longer than any observed relayout while staying short enough
56
+ * that the loop never lingers as a perceptible cost. The loop normally exits
57
+ * far earlier — as soon as the write sticks.
58
+ */
59
+ const RESTORE_FRAME_CAP = 30;
60
+
61
+ /**
62
+ * Tolerance (in CSS pixels) for considering a restore "stuck". After writing
63
+ * `element.scrollTop = target`, the browser may clamp it to the current
64
+ * `scrollHeight - clientHeight`; if the resulting offset is within this many
65
+ * pixels of the target we treat the restore as complete. Sub-pixel rounding and
66
+ * fractional device-pixel ratios make an exact equality check unreliable.
67
+ */
68
+ const RESTORE_STICK_TOLERANCE_PX = 2;
69
+
31
70
  /**
32
71
  * Switch the browser to manual scroll restoration exactly once per document.
33
72
  *
@@ -67,11 +106,15 @@ function useScrollOffsetStore() {
67
106
  * multiple scrollables).
68
107
  *
69
108
  * Behaviour (web):
70
- * - On every scroll while the screen is focused, the current offset is saved.
71
- * - On focus, the saved offset is applied in a single `requestAnimationFrame`,
72
- * giving a remounted list one frame to lay out its content first (no retry
73
- * loops, no hide/show tricks).
74
- * - On blur, the latest offset is captured as a final safety net.
109
+ * - On every scroll while the screen is focused, the current offset is recorded
110
+ * in memory and persisted. This live stream of saves is the source of truth.
111
+ * - On focus, the saved offset is re-applied across a bounded run of animation
112
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
113
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
114
+ * saved offset of 0 is a no-op (nothing to restore).
115
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
116
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
117
+ * while collapsing the hidden screen.
75
118
  */
76
119
  function useScrollRestoration(target, options) {
77
120
  const store = useScrollOffsetStore();
@@ -96,27 +139,67 @@ function useScrollRestoration(target, options) {
96
139
  if (!enabledRef.current || key === null) return undefined;
97
140
  const scroller = (0, _scrollableWeb.createScroller)(targetRef.current);
98
141
  const element = targetRef.current === 'window' ? typeof window !== 'undefined' ? window : null : resolveScrollEventTarget(targetRef.current);
142
+
143
+ // The last offset the live scroll listener observed for this focus
144
+ // session. This — not a blur-time `getOffset()` — is what we persist on
145
+ // blur, because by blur time the navigator may have collapsed the
146
+ // hidden screen and forced its `scrollTop` to 0 (bug A). `null` means
147
+ // the user never scrolled this session, so there is nothing newer to
148
+ // persist than what the scroll listener already saved live.
149
+ let lastObservedOffset = null;
99
150
  const save = () => {
100
151
  const currentKey = scrollKeyRef.current;
101
- if (enabledRef.current && currentKey !== null) {
102
- store.save(currentKey, scroller.getOffset());
103
- }
152
+ if (!enabledRef.current || currentKey === null) return;
153
+ const offset = scroller.getOffset();
154
+ // Ignore a spurious 0 produced by the navigator collapsing a hidden
155
+ // background screen: while collapsed the container cannot scroll, so
156
+ // its `scrollTop` is forced to 0. Persisting it would clobber the
157
+ // good offset recorded by earlier live saves (bug A). A genuine
158
+ // scroll-to-top keeps the container scrollable and is saved normally.
159
+ if (offset === 0 && !scroller.canScroll()) return;
160
+ lastObservedOffset = offset;
161
+ store.save(currentKey, offset);
104
162
  };
105
163
 
106
- // Restore on the next frame so a freshly remounted list has rendered
107
- // its content (and thus reached its full scroll height) before we move.
108
- const frame = requestAnimationFrame(() => {
109
- scroller.setOffset(store.read(key));
110
- });
164
+ // Restore across a bounded run of frames. A freshly re-shown
165
+ // virtualized list re-lays out its rows over several frames, so a
166
+ // single write while it is still collapsed would be clamped to 0 and
167
+ // never re-applied (bug B). We re-apply each frame until the write
168
+ // sticks or the frame cap is hit.
169
+ const targetOffset = store.read(key);
170
+ let rafId = null;
171
+ if (targetOffset > 0 && typeof requestAnimationFrame !== 'undefined') {
172
+ let framesLeft = RESTORE_FRAME_CAP;
173
+ const applyOffset = () => {
174
+ rafId = null;
175
+ scroller.setOffset(targetOffset);
176
+ framesLeft -= 1;
177
+ // Stop once the write took effect (content grew tall enough) or we
178
+ // exhaust the frame budget. `getOffset` re-reads the clamped value.
179
+ const reached = Math.abs(scroller.getOffset() - targetOffset) <= RESTORE_STICK_TOLERANCE_PX;
180
+ if (!reached && framesLeft > 0) {
181
+ rafId = requestAnimationFrame(applyOffset);
182
+ }
183
+ };
184
+ rafId = requestAnimationFrame(applyOffset);
185
+ }
111
186
  element?.addEventListener('scroll', save, {
112
187
  passive: true
113
188
  });
114
189
  return () => {
115
- cancelAnimationFrame(frame);
190
+ if (rafId !== null) cancelAnimationFrame(rafId);
116
191
  element?.removeEventListener('scroll', save);
117
- // Final capture on blur, covering navigations that don't fire a
118
- // trailing scroll event.
119
- save();
192
+ // Final capture on blur: persist the last offset the scroll listener
193
+ // OBSERVED, never a fresh read (which the navigator may have forced
194
+ // to 0 while collapsing the hidden screen). When the user never
195
+ // scrolled this session there is nothing newer to persist than the
196
+ // live saves already recorded.
197
+ if (lastObservedOffset !== null) {
198
+ const currentKey = scrollKeyRef.current;
199
+ if (enabledRef.current && currentKey !== null) {
200
+ store.save(currentKey, lastObservedOffset);
201
+ }
202
+ }
120
203
  };
121
204
  }, [store]));
122
205
  }
@@ -1 +1 @@
1
- {"version":3,"names":["_react","require","_native","_scrollableWeb","_store","_jsxRuntime","ScrollOffsetContext","createContext","displayName","history","scrollRestoration","ScrollRestorationProvider","children","store","useMemo","ScrollOffsetStore","jsx","Provider","value","useScrollOffsetStore","useContext","Error","useScrollRestoration","target","options","route","useRoute","subKey","key","enabled","scrollKey","deriveScrollKey","targetRef","useRef","current","enabledRef","scrollKeyRef","useFocusEffect","useCallback","undefined","scroller","createScroller","element","window","resolveScrollEventTarget","save","currentKey","getOffset","frame","requestAnimationFrame","setOffset","read","addEventListener","passive","cancelAnimationFrame","removeEventListener","EventTarget","handle","getScrollableNode","node"],"sourceRoot":"../../../src","sources":["scroll/index.web.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["_react","require","_native","_scrollableWeb","_store","_jsxRuntime","ScrollOffsetContext","createContext","displayName","RESTORE_FRAME_CAP","RESTORE_STICK_TOLERANCE_PX","history","scrollRestoration","ScrollRestorationProvider","children","store","useMemo","ScrollOffsetStore","jsx","Provider","value","useScrollOffsetStore","useContext","Error","useScrollRestoration","target","options","route","useRoute","subKey","key","enabled","scrollKey","deriveScrollKey","targetRef","useRef","current","enabledRef","scrollKeyRef","useFocusEffect","useCallback","undefined","scroller","createScroller","element","window","resolveScrollEventTarget","lastObservedOffset","save","currentKey","offset","getOffset","canScroll","targetOffset","read","rafId","requestAnimationFrame","framesLeft","applyOffset","setOffset","reached","Math","abs","addEventListener","passive","cancelAnimationFrame","removeEventListener","EventTarget","handle","getScrollableNode","node"],"sourceRoot":"../../../src","sources":["scroll/index.web.tsx"],"mappings":"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","ignoreList":[]}
@@ -6,8 +6,8 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.createScroller = createScroller;
7
7
  /**
8
8
  * A normalized read/write interface over whatever the caller registered, so
9
- * the hook does not branch on target shape. Both methods are no-ops when the
10
- * underlying element is not yet (or no longer) attached.
9
+ * the hook does not branch on target shape. All methods are safe no-ops when
10
+ * the underlying element is not yet (or no longer) attached.
11
11
  */
12
12
 
13
13
  function isElement(value) {
@@ -47,7 +47,8 @@ function createScroller(target) {
47
47
  getOffset: () => typeof window === 'undefined' ? 0 : window.scrollY,
48
48
  setOffset: offset => {
49
49
  if (typeof window !== 'undefined') window.scrollTo(0, offset);
50
- }
50
+ },
51
+ canScroll: () => true
51
52
  };
52
53
  }
53
54
  return {
@@ -58,6 +59,10 @@ function createScroller(target) {
58
59
  setOffset: offset => {
59
60
  const element = resolveElement(target);
60
61
  if (element) element.scrollTop = offset;
62
+ },
63
+ canScroll: () => {
64
+ const element = resolveElement(target);
65
+ return element ? element.scrollHeight > element.clientHeight : false;
61
66
  }
62
67
  };
63
68
  }
@@ -1 +1 @@
1
- {"version":3,"names":["isElement","value","HTMLElement","hasGetScrollableNode","getScrollableNode","resolveElement","target","current","node","createScroller","getOffset","window","scrollY","setOffset","offset","scrollTo","element","scrollTop"],"sourceRoot":"../../../src","sources":["scroll/scrollable.web.ts"],"mappings":";;;;;;AAEA;AACA;AACA;AACA;AACA;;AAMA,SAASA,SAASA,CAACC,KAAc,EAAwB;EACvD,OAAO,OAAOC,WAAW,KAAK,WAAW,IAAID,KAAK,YAAYC,WAAW;AAC3E;AAEA,SAASC,oBAAoBA,CAC3BF,KAAc,EACkD;EAChE,OACE,OAAOA,KAAK,KAAK,QAAQ,IACzBA,KAAK,KAAK,IAAI,IACd,OAAQA,KAAK,CAAsBG,iBAAiB,KAAK,UAAU;AAEvE;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASC,cAAcA,CAACC,MAA+B,EAAsB;EAC3E,IAAIA,MAAM,KAAK,QAAQ,EAAE,OAAO,IAAI;EAEpC,MAAMC,OAAO,GAAID,MAAM,CAA0BC,OAAO;EACxD,IAAIA,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI;EAEhC,IAAIP,SAAS,CAACO,OAAO,CAAC,EAAE,OAAOA,OAAO;EAEtC,IAAIJ,oBAAoB,CAACI,OAAO,CAAC,EAAE;IACjC,MAAMC,IAAI,GAAGD,OAAO,CAACH,iBAAiB,CAAC,CAAC;IACxC,IAAIJ,SAAS,CAACQ,IAAI,CAAC,EAAE,OAAOA,IAAI;EAClC;EAEA,OAAO,IAAI;AACb;;AAEA;AACA;AACA;AACA;AACA;AACO,SAASC,cAAcA,CAACH,MAA+B,EAAoB;EAChF,IAAIA,MAAM,KAAK,QAAQ,EAAE;IACvB,OAAO;MACLI,SAAS,EAAEA,CAAA,KAAO,OAAOC,MAAM,KAAK,WAAW,GAAG,CAAC,GAAGA,MAAM,CAACC,OAAQ;MACrEC,SAAS,EAAGC,MAAM,IAAK;QACrB,IAAI,OAAOH,MAAM,KAAK,WAAW,EAAEA,MAAM,CAACI,QAAQ,CAAC,CAAC,EAAED,MAAM,CAAC;MAC/D;IACF,CAAC;EACH;EAEA,OAAO;IACLJ,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMM,OAAO,GAAGX,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOU,OAAO,GAAGA,OAAO,CAACC,SAAS,GAAG,CAAC;IACxC,CAAC;IACDJ,SAAS,EAAGC,MAAM,IAAK;MACrB,MAAME,OAAO,GAAGX,cAAc,CAACC,MAAM,CAAC;MACtC,IAAIU,OAAO,EAAEA,OAAO,CAACC,SAAS,GAAGH,MAAM;IACzC;EACF,CAAC;AACH","ignoreList":[]}
1
+ {"version":3,"names":["isElement","value","HTMLElement","hasGetScrollableNode","getScrollableNode","resolveElement","target","current","node","createScroller","getOffset","window","scrollY","setOffset","offset","scrollTo","canScroll","element","scrollTop","scrollHeight","clientHeight"],"sourceRoot":"../../../src","sources":["scroll/scrollable.web.ts"],"mappings":";;;;;;AAEA;AACA;AACA;AACA;AACA;;AAkBA,SAASA,SAASA,CAACC,KAAc,EAAwB;EACvD,OAAO,OAAOC,WAAW,KAAK,WAAW,IAAID,KAAK,YAAYC,WAAW;AAC3E;AAEA,SAASC,oBAAoBA,CAC3BF,KAAc,EACkD;EAChE,OACE,OAAOA,KAAK,KAAK,QAAQ,IACzBA,KAAK,KAAK,IAAI,IACd,OAAQA,KAAK,CAAsBG,iBAAiB,KAAK,UAAU;AAEvE;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASC,cAAcA,CAACC,MAA+B,EAAsB;EAC3E,IAAIA,MAAM,KAAK,QAAQ,EAAE,OAAO,IAAI;EAEpC,MAAMC,OAAO,GAAID,MAAM,CAA0BC,OAAO;EACxD,IAAIA,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI;EAEhC,IAAIP,SAAS,CAACO,OAAO,CAAC,EAAE,OAAOA,OAAO;EAEtC,IAAIJ,oBAAoB,CAACI,OAAO,CAAC,EAAE;IACjC,MAAMC,IAAI,GAAGD,OAAO,CAACH,iBAAiB,CAAC,CAAC;IACxC,IAAIJ,SAAS,CAACQ,IAAI,CAAC,EAAE,OAAOA,IAAI;EAClC;EAEA,OAAO,IAAI;AACb;;AAEA;AACA;AACA;AACA;AACA;AACO,SAASC,cAAcA,CAACH,MAA+B,EAAoB;EAChF,IAAIA,MAAM,KAAK,QAAQ,EAAE;IACvB,OAAO;MACLI,SAAS,EAAEA,CAAA,KAAO,OAAOC,MAAM,KAAK,WAAW,GAAG,CAAC,GAAGA,MAAM,CAACC,OAAQ;MACrEC,SAAS,EAAGC,MAAM,IAAK;QACrB,IAAI,OAAOH,MAAM,KAAK,WAAW,EAAEA,MAAM,CAACI,QAAQ,CAAC,CAAC,EAAED,MAAM,CAAC;MAC/D,CAAC;MACDE,SAAS,EAAEA,CAAA,KAAM;IACnB,CAAC;EACH;EAEA,OAAO;IACLN,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMO,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOW,OAAO,GAAGA,OAAO,CAACC,SAAS,GAAG,CAAC;IACxC,CAAC;IACDL,SAAS,EAAGC,MAAM,IAAK;MACrB,MAAMG,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,IAAIW,OAAO,EAAEA,OAAO,CAACC,SAAS,GAAGJ,MAAM;IACzC,CAAC;IACDE,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMC,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOW,OAAO,GAAGA,OAAO,CAACE,YAAY,GAAGF,OAAO,CAACG,YAAY,GAAG,KAAK;IACtE;EACF,CAAC;AACH","ignoreList":[]}
@@ -4,12 +4,32 @@
4
4
  * Web variant of the scroll-restoration primitive.
5
5
  *
6
6
  * Mirrors the proven Bluesky pattern (`history.scrollRestoration = 'manual'`
7
- * plus an in-memory `Map<routeKey, offset>` saved on blur and restored on focus
8
- * inside a single `requestAnimationFrame`) with one deliberate difference:
9
- * Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
10
- * layouts whose feed scrolls an INNER container. So we restore the offset of a
11
- * caller-registered scrollable (a ref to an element / RN scroll component, or
12
- * the `'window'` sentinel), keyed by the active navigation route.
7
+ * plus an in-memory `Map<routeKey, offset>`) with two deliberate differences
8
+ * forced by Oxy's layouts and the behaviour of React Navigation's web stack:
9
+ *
10
+ * 1. Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
11
+ * layouts whose feed scrolls an INNER container. So we restore the offset
12
+ * of a caller-registered scrollable (a ref to an element / RN scroll
13
+ * component, or the `'window'` sentinel), keyed by the active route.
14
+ *
15
+ * 2. React Navigation's web stack HIDES the background screen on push. While
16
+ * hidden, the previous screen's scroll container collapses
17
+ * (`scrollHeight === clientHeight`) and the navigator forces its
18
+ * `scrollTop` to 0. The screen is NOT unmounted, so a virtualized list
19
+ * (e.g. FlashList) keeps its rows but re-lays them out over SEVERAL frames
20
+ * once the screen is re-shown. Two problems follow, both handled here:
21
+ *
22
+ * (a) A blur-time read of `scrollTop` returns the navigator's forced 0,
23
+ * not the user's real offset — saving it would clobber the good
24
+ * value. We therefore persist the last offset OBSERVED by the live
25
+ * scroll listener, never a fresh read taken at blur time.
26
+ *
27
+ * (b) A single-frame restore writes `scrollTop` while the list is still
28
+ * collapsed; the write is clamped to 0 and never re-applied once the
29
+ * content grows. We therefore re-apply the target offset across a
30
+ * bounded run of animation frames, stopping as soon as the write
31
+ * sticks (the content has grown tall enough) or a small frame cap is
32
+ * reached.
13
33
  *
14
34
  * Native bundlers use `./index.ts` (a no-op); web bundlers select this file via
15
35
  * the `"browser"` export condition in `package.json`.
@@ -22,6 +42,25 @@ import { jsx as _jsx } from "react/jsx-runtime";
22
42
  const ScrollOffsetContext = /*#__PURE__*/createContext(null);
23
43
  ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
24
44
 
45
+ /**
46
+ * Maximum number of animation frames the focus restore will re-apply the saved
47
+ * offset before giving up. A virtualized list re-lays out its rows over a
48
+ * handful of frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps)
49
+ * is comfortably longer than any observed relayout while staying short enough
50
+ * that the loop never lingers as a perceptible cost. The loop normally exits
51
+ * far earlier — as soon as the write sticks.
52
+ */
53
+ const RESTORE_FRAME_CAP = 30;
54
+
55
+ /**
56
+ * Tolerance (in CSS pixels) for considering a restore "stuck". After writing
57
+ * `element.scrollTop = target`, the browser may clamp it to the current
58
+ * `scrollHeight - clientHeight`; if the resulting offset is within this many
59
+ * pixels of the target we treat the restore as complete. Sub-pixel rounding and
60
+ * fractional device-pixel ratios make an exact equality check unreliable.
61
+ */
62
+ const RESTORE_STICK_TOLERANCE_PX = 2;
63
+
25
64
  /**
26
65
  * Switch the browser to manual scroll restoration exactly once per document.
27
66
  *
@@ -61,11 +100,15 @@ function useScrollOffsetStore() {
61
100
  * multiple scrollables).
62
101
  *
63
102
  * Behaviour (web):
64
- * - On every scroll while the screen is focused, the current offset is saved.
65
- * - On focus, the saved offset is applied in a single `requestAnimationFrame`,
66
- * giving a remounted list one frame to lay out its content first (no retry
67
- * loops, no hide/show tricks).
68
- * - On blur, the latest offset is captured as a final safety net.
103
+ * - On every scroll while the screen is focused, the current offset is recorded
104
+ * in memory and persisted. This live stream of saves is the source of truth.
105
+ * - On focus, the saved offset is re-applied across a bounded run of animation
106
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
107
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
108
+ * saved offset of 0 is a no-op (nothing to restore).
109
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
110
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
111
+ * while collapsing the hidden screen.
69
112
  */
70
113
  export function useScrollRestoration(target, options) {
71
114
  const store = useScrollOffsetStore();
@@ -90,27 +133,67 @@ export function useScrollRestoration(target, options) {
90
133
  if (!enabledRef.current || key === null) return undefined;
91
134
  const scroller = createScroller(targetRef.current);
92
135
  const element = targetRef.current === 'window' ? typeof window !== 'undefined' ? window : null : resolveScrollEventTarget(targetRef.current);
136
+
137
+ // The last offset the live scroll listener observed for this focus
138
+ // session. This — not a blur-time `getOffset()` — is what we persist on
139
+ // blur, because by blur time the navigator may have collapsed the
140
+ // hidden screen and forced its `scrollTop` to 0 (bug A). `null` means
141
+ // the user never scrolled this session, so there is nothing newer to
142
+ // persist than what the scroll listener already saved live.
143
+ let lastObservedOffset = null;
93
144
  const save = () => {
94
145
  const currentKey = scrollKeyRef.current;
95
- if (enabledRef.current && currentKey !== null) {
96
- store.save(currentKey, scroller.getOffset());
97
- }
146
+ if (!enabledRef.current || currentKey === null) return;
147
+ const offset = scroller.getOffset();
148
+ // Ignore a spurious 0 produced by the navigator collapsing a hidden
149
+ // background screen: while collapsed the container cannot scroll, so
150
+ // its `scrollTop` is forced to 0. Persisting it would clobber the
151
+ // good offset recorded by earlier live saves (bug A). A genuine
152
+ // scroll-to-top keeps the container scrollable and is saved normally.
153
+ if (offset === 0 && !scroller.canScroll()) return;
154
+ lastObservedOffset = offset;
155
+ store.save(currentKey, offset);
98
156
  };
99
157
 
100
- // Restore on the next frame so a freshly remounted list has rendered
101
- // its content (and thus reached its full scroll height) before we move.
102
- const frame = requestAnimationFrame(() => {
103
- scroller.setOffset(store.read(key));
104
- });
158
+ // Restore across a bounded run of frames. A freshly re-shown
159
+ // virtualized list re-lays out its rows over several frames, so a
160
+ // single write while it is still collapsed would be clamped to 0 and
161
+ // never re-applied (bug B). We re-apply each frame until the write
162
+ // sticks or the frame cap is hit.
163
+ const targetOffset = store.read(key);
164
+ let rafId = null;
165
+ if (targetOffset > 0 && typeof requestAnimationFrame !== 'undefined') {
166
+ let framesLeft = RESTORE_FRAME_CAP;
167
+ const applyOffset = () => {
168
+ rafId = null;
169
+ scroller.setOffset(targetOffset);
170
+ framesLeft -= 1;
171
+ // Stop once the write took effect (content grew tall enough) or we
172
+ // exhaust the frame budget. `getOffset` re-reads the clamped value.
173
+ const reached = Math.abs(scroller.getOffset() - targetOffset) <= RESTORE_STICK_TOLERANCE_PX;
174
+ if (!reached && framesLeft > 0) {
175
+ rafId = requestAnimationFrame(applyOffset);
176
+ }
177
+ };
178
+ rafId = requestAnimationFrame(applyOffset);
179
+ }
105
180
  element?.addEventListener('scroll', save, {
106
181
  passive: true
107
182
  });
108
183
  return () => {
109
- cancelAnimationFrame(frame);
184
+ if (rafId !== null) cancelAnimationFrame(rafId);
110
185
  element?.removeEventListener('scroll', save);
111
- // Final capture on blur, covering navigations that don't fire a
112
- // trailing scroll event.
113
- save();
186
+ // Final capture on blur: persist the last offset the scroll listener
187
+ // OBSERVED, never a fresh read (which the navigator may have forced
188
+ // to 0 while collapsing the hidden screen). When the user never
189
+ // scrolled this session there is nothing newer to persist than the
190
+ // live saves already recorded.
191
+ if (lastObservedOffset !== null) {
192
+ const currentKey = scrollKeyRef.current;
193
+ if (enabledRef.current && currentKey !== null) {
194
+ store.save(currentKey, lastObservedOffset);
195
+ }
196
+ }
114
197
  };
115
198
  }, [store]));
116
199
  }
@@ -1 +1 @@
1
- {"version":3,"names":["createContext","useCallback","useContext","useMemo","useRef","useFocusEffect","useRoute","createScroller","ScrollOffsetStore","deriveScrollKey","jsx","_jsx","ScrollOffsetContext","displayName","history","scrollRestoration","ScrollRestorationProvider","children","store","Provider","value","useScrollOffsetStore","Error","useScrollRestoration","target","options","route","subKey","key","enabled","scrollKey","targetRef","current","enabledRef","scrollKeyRef","undefined","scroller","element","window","resolveScrollEventTarget","save","currentKey","getOffset","frame","requestAnimationFrame","setOffset","read","addEventListener","passive","cancelAnimationFrame","removeEventListener","EventTarget","handle","getScrollableNode","node"],"sourceRoot":"../../../src","sources":["scroll/index.web.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["createContext","useCallback","useContext","useMemo","useRef","useFocusEffect","useRoute","createScroller","ScrollOffsetStore","deriveScrollKey","jsx","_jsx","ScrollOffsetContext","displayName","RESTORE_FRAME_CAP","RESTORE_STICK_TOLERANCE_PX","history","scrollRestoration","ScrollRestorationProvider","children","store","Provider","value","useScrollOffsetStore","Error","useScrollRestoration","target","options","route","subKey","key","enabled","scrollKey","targetRef","current","enabledRef","scrollKeyRef","undefined","scroller","element","window","resolveScrollEventTarget","lastObservedOffset","save","currentKey","offset","getOffset","canScroll","targetOffset","read","rafId","requestAnimationFrame","framesLeft","applyOffset","setOffset","reached","Math","abs","addEventListener","passive","cancelAnimationFrame","removeEventListener","EventTarget","handle","getScrollableNode","node"],"sourceRoot":"../../../src","sources":["scroll/index.web.tsx"],"mappings":"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","ignoreList":[]}
@@ -2,8 +2,8 @@
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
 
9
9
  function isElement(value) {
@@ -43,7 +43,8 @@ export function createScroller(target) {
43
43
  getOffset: () => typeof window === 'undefined' ? 0 : window.scrollY,
44
44
  setOffset: offset => {
45
45
  if (typeof window !== 'undefined') window.scrollTo(0, offset);
46
- }
46
+ },
47
+ canScroll: () => true
47
48
  };
48
49
  }
49
50
  return {
@@ -54,6 +55,10 @@ export function createScroller(target) {
54
55
  setOffset: offset => {
55
56
  const element = resolveElement(target);
56
57
  if (element) element.scrollTop = offset;
58
+ },
59
+ canScroll: () => {
60
+ const element = resolveElement(target);
61
+ return element ? element.scrollHeight > element.clientHeight : false;
57
62
  }
58
63
  };
59
64
  }
@@ -1 +1 @@
1
- {"version":3,"names":["isElement","value","HTMLElement","hasGetScrollableNode","getScrollableNode","resolveElement","target","current","node","createScroller","getOffset","window","scrollY","setOffset","offset","scrollTo","element","scrollTop"],"sourceRoot":"../../../src","sources":["scroll/scrollable.web.ts"],"mappings":";;AAEA;AACA;AACA;AACA;AACA;;AAMA,SAASA,SAASA,CAACC,KAAc,EAAwB;EACvD,OAAO,OAAOC,WAAW,KAAK,WAAW,IAAID,KAAK,YAAYC,WAAW;AAC3E;AAEA,SAASC,oBAAoBA,CAC3BF,KAAc,EACkD;EAChE,OACE,OAAOA,KAAK,KAAK,QAAQ,IACzBA,KAAK,KAAK,IAAI,IACd,OAAQA,KAAK,CAAsBG,iBAAiB,KAAK,UAAU;AAEvE;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASC,cAAcA,CAACC,MAA+B,EAAsB;EAC3E,IAAIA,MAAM,KAAK,QAAQ,EAAE,OAAO,IAAI;EAEpC,MAAMC,OAAO,GAAID,MAAM,CAA0BC,OAAO;EACxD,IAAIA,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI;EAEhC,IAAIP,SAAS,CAACO,OAAO,CAAC,EAAE,OAAOA,OAAO;EAEtC,IAAIJ,oBAAoB,CAACI,OAAO,CAAC,EAAE;IACjC,MAAMC,IAAI,GAAGD,OAAO,CAACH,iBAAiB,CAAC,CAAC;IACxC,IAAIJ,SAAS,CAACQ,IAAI,CAAC,EAAE,OAAOA,IAAI;EAClC;EAEA,OAAO,IAAI;AACb;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,cAAcA,CAACH,MAA+B,EAAoB;EAChF,IAAIA,MAAM,KAAK,QAAQ,EAAE;IACvB,OAAO;MACLI,SAAS,EAAEA,CAAA,KAAO,OAAOC,MAAM,KAAK,WAAW,GAAG,CAAC,GAAGA,MAAM,CAACC,OAAQ;MACrEC,SAAS,EAAGC,MAAM,IAAK;QACrB,IAAI,OAAOH,MAAM,KAAK,WAAW,EAAEA,MAAM,CAACI,QAAQ,CAAC,CAAC,EAAED,MAAM,CAAC;MAC/D;IACF,CAAC;EACH;EAEA,OAAO;IACLJ,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMM,OAAO,GAAGX,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOU,OAAO,GAAGA,OAAO,CAACC,SAAS,GAAG,CAAC;IACxC,CAAC;IACDJ,SAAS,EAAGC,MAAM,IAAK;MACrB,MAAME,OAAO,GAAGX,cAAc,CAACC,MAAM,CAAC;MACtC,IAAIU,OAAO,EAAEA,OAAO,CAACC,SAAS,GAAGH,MAAM;IACzC;EACF,CAAC;AACH","ignoreList":[]}
1
+ {"version":3,"names":["isElement","value","HTMLElement","hasGetScrollableNode","getScrollableNode","resolveElement","target","current","node","createScroller","getOffset","window","scrollY","setOffset","offset","scrollTo","canScroll","element","scrollTop","scrollHeight","clientHeight"],"sourceRoot":"../../../src","sources":["scroll/scrollable.web.ts"],"mappings":";;AAEA;AACA;AACA;AACA;AACA;;AAkBA,SAASA,SAASA,CAACC,KAAc,EAAwB;EACvD,OAAO,OAAOC,WAAW,KAAK,WAAW,IAAID,KAAK,YAAYC,WAAW;AAC3E;AAEA,SAASC,oBAAoBA,CAC3BF,KAAc,EACkD;EAChE,OACE,OAAOA,KAAK,KAAK,QAAQ,IACzBA,KAAK,KAAK,IAAI,IACd,OAAQA,KAAK,CAAsBG,iBAAiB,KAAK,UAAU;AAEvE;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASC,cAAcA,CAACC,MAA+B,EAAsB;EAC3E,IAAIA,MAAM,KAAK,QAAQ,EAAE,OAAO,IAAI;EAEpC,MAAMC,OAAO,GAAID,MAAM,CAA0BC,OAAO;EACxD,IAAIA,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI;EAEhC,IAAIP,SAAS,CAACO,OAAO,CAAC,EAAE,OAAOA,OAAO;EAEtC,IAAIJ,oBAAoB,CAACI,OAAO,CAAC,EAAE;IACjC,MAAMC,IAAI,GAAGD,OAAO,CAACH,iBAAiB,CAAC,CAAC;IACxC,IAAIJ,SAAS,CAACQ,IAAI,CAAC,EAAE,OAAOA,IAAI;EAClC;EAEA,OAAO,IAAI;AACb;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,cAAcA,CAACH,MAA+B,EAAoB;EAChF,IAAIA,MAAM,KAAK,QAAQ,EAAE;IACvB,OAAO;MACLI,SAAS,EAAEA,CAAA,KAAO,OAAOC,MAAM,KAAK,WAAW,GAAG,CAAC,GAAGA,MAAM,CAACC,OAAQ;MACrEC,SAAS,EAAGC,MAAM,IAAK;QACrB,IAAI,OAAOH,MAAM,KAAK,WAAW,EAAEA,MAAM,CAACI,QAAQ,CAAC,CAAC,EAAED,MAAM,CAAC;MAC/D,CAAC;MACDE,SAAS,EAAEA,CAAA,KAAM;IACnB,CAAC;EACH;EAEA,OAAO;IACLN,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMO,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOW,OAAO,GAAGA,OAAO,CAACC,SAAS,GAAG,CAAC;IACxC,CAAC;IACDL,SAAS,EAAGC,MAAM,IAAK;MACrB,MAAMG,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,IAAIW,OAAO,EAAEA,OAAO,CAACC,SAAS,GAAGJ,MAAM;IACzC,CAAC;IACDE,SAAS,EAAEA,CAAA,KAAM;MACf,MAAMC,OAAO,GAAGZ,cAAc,CAACC,MAAM,CAAC;MACtC,OAAOW,OAAO,GAAGA,OAAO,CAACE,YAAY,GAAGF,OAAO,CAACG,YAAY,GAAG,KAAK;IACtE;EACF,CAAC;AACH","ignoreList":[]}
@@ -12,11 +12,15 @@ export declare function ScrollRestorationProvider({ children, }: ScrollRestorati
12
12
  * multiple scrollables).
13
13
  *
14
14
  * Behaviour (web):
15
- * - On every scroll while the screen is focused, the current offset is saved.
16
- * - On focus, the saved offset is applied in a single `requestAnimationFrame`,
17
- * giving a remounted list one frame to lay out its content first (no retry
18
- * loops, no hide/show tricks).
19
- * - On blur, the latest offset is captured as a final safety net.
15
+ * - On every scroll while the screen is focused, the current offset is recorded
16
+ * in memory and persisted. This live stream of saves is the source of truth.
17
+ * - On focus, the saved offset is re-applied across a bounded run of animation
18
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
19
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
20
+ * saved offset of 0 is a no-op (nothing to restore).
21
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
22
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
23
+ * while collapsing the hidden screen.
20
24
  */
21
25
  export declare function useScrollRestoration(target: ScrollRestorationTarget, options?: UseScrollRestorationOptions): void;
22
26
  //# sourceMappingURL=index.web.d.ts.map
@@ -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":"AAuCA,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,CAoGN"}
@@ -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"}
@@ -12,11 +12,15 @@ export declare function ScrollRestorationProvider({ children, }: ScrollRestorati
12
12
  * multiple scrollables).
13
13
  *
14
14
  * Behaviour (web):
15
- * - On every scroll while the screen is focused, the current offset is saved.
16
- * - On focus, the saved offset is applied in a single `requestAnimationFrame`,
17
- * giving a remounted list one frame to lay out its content first (no retry
18
- * loops, no hide/show tricks).
19
- * - On blur, the latest offset is captured as a final safety net.
15
+ * - On every scroll while the screen is focused, the current offset is recorded
16
+ * in memory and persisted. This live stream of saves is the source of truth.
17
+ * - On focus, the saved offset is re-applied across a bounded run of animation
18
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
19
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
20
+ * saved offset of 0 is a no-op (nothing to restore).
21
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
22
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
23
+ * while collapsing the hidden screen.
20
24
  */
21
25
  export declare function useScrollRestoration(target: ScrollRestorationTarget, options?: UseScrollRestorationOptions): void;
22
26
  //# sourceMappingURL=index.web.d.ts.map
@@ -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":"AAuCA,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,CAoGN"}
@@ -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.23",
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",
@@ -0,0 +1,325 @@
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: React Navigation's web stack
7
+ // collapses a hidden background screen (forcing its `scrollTop` to 0) on push,
8
+ // and a re-shown virtualized list re-lays out its rows — and thus reaches its
9
+ // full scroll height — over SEVERAL frames after focus.
10
+ //
11
+ // We mock `@react-navigation/native` so `useFocusEffect` runs the effect on
12
+ // mount and its cleanup on unmount, and drive `requestAnimationFrame` manually
13
+ // so the multi-frame restore is deterministic.
14
+
15
+ import { createElement, useRef, type ReactNode } from 'react';
16
+ import { act } from 'react';
17
+ import { createRoot, type Root } from 'react-dom/client';
18
+
19
+ // React 19's `act` requires this flag to be set when driving updates manually
20
+ // outside a testing-library renderer.
21
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
22
+ true;
23
+
24
+ // ---- React Navigation mock ------------------------------------------------
25
+ // `useFocusEffect` here mirrors the real contract closely enough for this hook:
26
+ // it runs the callback in a layout effect and runs the returned cleanup on
27
+ // unmount. `useRoute` yields a stable per-test route key.
28
+
29
+ let currentRouteKey = 'route-test';
30
+
31
+ jest.mock('@react-navigation/native', () => {
32
+ const react = jest.requireActual<typeof import('react')>('react');
33
+ return {
34
+ useFocusEffect: (effect: () => undefined | (() => void)) => {
35
+ react.useEffect(effect, [effect]);
36
+ },
37
+ useRoute: () => ({ key: currentRouteKey, name: 'Test', params: {} }),
38
+ };
39
+ });
40
+
41
+ // Imported AFTER the mock is registered.
42
+ import {
43
+ ScrollRestorationProvider,
44
+ useScrollRestoration,
45
+ } from '../scroll/index.web';
46
+
47
+ // ---- requestAnimationFrame harness ---------------------------------------
48
+
49
+ type FrameCallback = (time: number) => void;
50
+
51
+ class FrameScheduler {
52
+ private queue = new Map<number, FrameCallback>();
53
+ private nextId = 1;
54
+
55
+ install(): void {
56
+ window.requestAnimationFrame = ((cb: FrameCallback): number => {
57
+ const id = this.nextId++;
58
+ this.queue.set(id, cb);
59
+ return id;
60
+ }) as typeof window.requestAnimationFrame;
61
+ window.cancelAnimationFrame = (id: number): void => {
62
+ this.queue.delete(id);
63
+ };
64
+ }
65
+
66
+ /** Run one frame's worth of scheduled callbacks (those queued before now). */
67
+ flushOneFrame(): void {
68
+ const batch = [...this.queue.entries()];
69
+ this.queue.clear();
70
+ for (const [, cb] of batch) cb(performance.now());
71
+ }
72
+
73
+ get pending(): number {
74
+ return this.queue.size;
75
+ }
76
+ }
77
+
78
+ // ---- A fake RNW scrollable -------------------------------------------------
79
+ // Models a FlashList's underlying DOM node. It MUST be a real HTMLElement
80
+ // because the scroller resolves the live node via `instanceof HTMLElement`.
81
+ // jsdom does no layout, so we own `scrollHeight`/`clientHeight`/`scrollTop`
82
+ // ourselves: `scrollTop` is clamped to `scrollHeight - clientHeight` on write
83
+ // (as a real element is), content starts collapsed and grows on "relayout".
84
+
85
+ const VIEWPORT_HEIGHT = 879;
86
+
87
+ class FakeScrollNode {
88
+ readonly el: HTMLDivElement;
89
+ private _scrollHeight = VIEWPORT_HEIGHT; // collapsed: equals clientHeight
90
+ private _scrollTop = 0;
91
+
92
+ constructor() {
93
+ const el = document.createElement('div');
94
+ Object.defineProperty(el, 'clientHeight', {
95
+ configurable: true,
96
+ get: () => VIEWPORT_HEIGHT,
97
+ });
98
+ Object.defineProperty(el, 'scrollHeight', {
99
+ configurable: true,
100
+ get: () => this._scrollHeight,
101
+ });
102
+ Object.defineProperty(el, 'scrollTop', {
103
+ configurable: true,
104
+ get: () => this._scrollTop,
105
+ set: (value: number) => {
106
+ const max = Math.max(0, this._scrollHeight - VIEWPORT_HEIGHT);
107
+ this._scrollTop = Math.min(Math.max(0, value), max);
108
+ },
109
+ });
110
+ this.el = el;
111
+ }
112
+
113
+ get scrollTop(): number {
114
+ return this.el.scrollTop;
115
+ }
116
+ set scrollTop(value: number) {
117
+ this.el.scrollTop = value;
118
+ }
119
+
120
+ /** Grow content to its full laid-out height (re-show relayout). */
121
+ growTo(height: number): void {
122
+ this._scrollHeight = height;
123
+ }
124
+
125
+ /**
126
+ * Reproduce what React Navigation's web stack does to a hidden background
127
+ * screen: collapse its content to the viewport height and force scrollTop to
128
+ * 0 (a collapsed container cannot hold a non-zero offset).
129
+ */
130
+ collapseLikeNavigator(): void {
131
+ this._scrollHeight = VIEWPORT_HEIGHT;
132
+ this._scrollTop = 0;
133
+ }
134
+
135
+ emitScroll(): void {
136
+ this.el.dispatchEvent(new Event('scroll'));
137
+ }
138
+ }
139
+
140
+ // A RNW-style handle exposing `getScrollableNode()` -> the DOM node.
141
+ function makeHandle(node: FakeScrollNode): { getScrollableNode: () => HTMLElement } {
142
+ return { getScrollableNode: () => node.el };
143
+ }
144
+
145
+ // ---- Render harness --------------------------------------------------------
146
+ //
147
+ // A single <ScrollRestorationProvider> stays mounted (mirroring the real app
148
+ // root, where the offset store lives for the document's lifetime) while the
149
+ // screen under it mounts and unmounts to model push/pop navigation.
150
+
151
+ function Screen({ node }: { node: FakeScrollNode }): ReactNode {
152
+ const ref = useRef(makeHandle(node));
153
+ useScrollRestoration(ref);
154
+ return null;
155
+ }
156
+
157
+ class Harness {
158
+ readonly root: Root;
159
+ private readonly container: HTMLElement;
160
+
161
+ constructor() {
162
+ this.container = document.createElement('div');
163
+ document.body.appendChild(this.container);
164
+ this.root = createRoot(this.container);
165
+ }
166
+
167
+ /** Mount (focus) the screen bound to `node`. */
168
+ focus(node: FakeScrollNode): void {
169
+ act(() => {
170
+ this.root.render(
171
+ createElement(
172
+ ScrollRestorationProvider,
173
+ null,
174
+ createElement(Screen, { node }),
175
+ ),
176
+ );
177
+ });
178
+ }
179
+
180
+ /** Unmount (blur) the screen while keeping the provider/store alive. */
181
+ blur(): void {
182
+ act(() => {
183
+ this.root.render(createElement(ScrollRestorationProvider, null, null));
184
+ });
185
+ }
186
+
187
+ teardown(): void {
188
+ act(() => {
189
+ this.root.unmount();
190
+ });
191
+ this.container.remove();
192
+ }
193
+ }
194
+
195
+ describe('web scroll-restoration hook', () => {
196
+ let frames: FrameScheduler;
197
+ let harness: Harness;
198
+
199
+ beforeEach(() => {
200
+ frames = new FrameScheduler();
201
+ frames.install();
202
+ currentRouteKey = `route-${Math.random().toString(36).slice(2)}`;
203
+ harness = new Harness();
204
+ });
205
+
206
+ afterEach(() => {
207
+ harness.teardown();
208
+ });
209
+
210
+ it('does not clobber the saved offset when the navigator collapses the screen on blur (bug A)', () => {
211
+ const node = new FakeScrollNode();
212
+ node.growTo(6586); // real content
213
+ harness.focus(node);
214
+
215
+ // User scrolls to 3520 — the live scroll listener records it.
216
+ node.scrollTop = 3520;
217
+ act(() => {
218
+ node.emitScroll();
219
+ });
220
+
221
+ // Navigator collapses the hidden screen and forces scrollTop to 0 while the
222
+ // screen is still technically focused (a stray scroll event fires). This
223
+ // must NOT be persisted over the good 3520.
224
+ node.collapseLikeNavigator();
225
+ act(() => {
226
+ node.emitScroll();
227
+ });
228
+
229
+ // Blur: cleanup runs and must persist the last GOOD offset, not the 0.
230
+ harness.blur();
231
+
232
+ // Re-show with full content height: restore should reach 3520 in one frame.
233
+ node.growTo(6586);
234
+ node.scrollTop = 0;
235
+ harness.focus(node);
236
+ act(() => {
237
+ frames.flushOneFrame();
238
+ });
239
+ expect(node.scrollTop).toBe(3520);
240
+ });
241
+
242
+ it('re-applies the offset across frames until the list reaches full height (bug B)', () => {
243
+ const node = new FakeScrollNode();
244
+ node.growTo(6586);
245
+ harness.focus(node);
246
+ node.scrollTop = 3520;
247
+ act(() => {
248
+ node.emitScroll();
249
+ });
250
+ harness.blur();
251
+
252
+ // Re-show: list starts collapsed (rows not yet rendered) and grows over
253
+ // frames. A single-frame restore would be clamped to 0 and never recover.
254
+ node.collapseLikeNavigator();
255
+ harness.focus(node);
256
+
257
+ // Frame 1: still collapsed — the write is clamped to 0, loop keeps retrying.
258
+ act(() => {
259
+ frames.flushOneFrame();
260
+ });
261
+ expect(node.scrollTop).toBe(0);
262
+ expect(frames.pending).toBeGreaterThan(0);
263
+
264
+ // A few frames later the rows lay out and the content reaches full height.
265
+ node.growTo(6586);
266
+ act(() => {
267
+ frames.flushOneFrame();
268
+ });
269
+ expect(node.scrollTop).toBe(3520);
270
+ });
271
+
272
+ it('treats a saved offset of 0 as a no-op (no restore loop scheduled)', () => {
273
+ const node = new FakeScrollNode();
274
+ node.growTo(6586);
275
+ harness.focus(node);
276
+ // Nothing saved for this fresh route key => read() is 0 => no rAF queued.
277
+ expect(frames.pending).toBe(0);
278
+ });
279
+
280
+ it('persists a genuine scroll-to-top over a previously-saved offset', () => {
281
+ const node = new FakeScrollNode();
282
+ node.growTo(6586);
283
+ harness.focus(node);
284
+ node.scrollTop = 3520;
285
+ act(() => {
286
+ node.emitScroll();
287
+ });
288
+ // User scrolls all the way back to the top; container is NOT collapsed, so
289
+ // this 0 is genuine and must overwrite the saved 3520.
290
+ node.scrollTop = 0;
291
+ act(() => {
292
+ node.emitScroll();
293
+ });
294
+ harness.blur();
295
+
296
+ // Re-show: saved offset is 0 => restore is a no-op, nothing scheduled.
297
+ node.scrollTop = 500; // pretend something nudged it after re-show
298
+ harness.focus(node);
299
+ expect(frames.pending).toBe(0);
300
+ expect(node.scrollTop).toBe(500);
301
+ });
302
+
303
+ it('stops retrying after the frame cap even if the content never grows', () => {
304
+ const node = new FakeScrollNode();
305
+ node.growTo(6586);
306
+ harness.focus(node);
307
+ node.scrollTop = 3520;
308
+ act(() => {
309
+ node.emitScroll();
310
+ });
311
+ harness.blur();
312
+
313
+ // Re-show that never reaches full height.
314
+ node.collapseLikeNavigator();
315
+ harness.focus(node);
316
+
317
+ // Flush far more than the cap; the loop must terminate.
318
+ for (let i = 0; i < 60; i++) {
319
+ act(() => {
320
+ frames.flushOneFrame();
321
+ });
322
+ }
323
+ expect(frames.pending).toBe(0);
324
+ });
325
+ });
@@ -2,12 +2,32 @@
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 React Navigation's web stack:
7
+ *
8
+ * 1. Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
9
+ * layouts whose feed scrolls an INNER container. So we restore the offset
10
+ * of a caller-registered scrollable (a ref to an element / RN scroll
11
+ * component, or the `'window'` sentinel), keyed by the active route.
12
+ *
13
+ * 2. React Navigation's web stack HIDES the background screen on push. While
14
+ * hidden, the previous screen's scroll container collapses
15
+ * (`scrollHeight === clientHeight`) and the navigator forces its
16
+ * `scrollTop` to 0. The screen is NOT unmounted, so a virtualized list
17
+ * (e.g. FlashList) keeps its rows but re-lays them out over SEVERAL frames
18
+ * once the screen is re-shown. Two problems follow, both handled here:
19
+ *
20
+ * (a) A blur-time read of `scrollTop` returns the navigator's forced 0,
21
+ * not the user's real offset — saving it would clobber the good
22
+ * value. We therefore persist the last offset OBSERVED by the live
23
+ * scroll listener, never a fresh read taken at blur time.
24
+ *
25
+ * (b) A single-frame restore writes `scrollTop` while the list is still
26
+ * collapsed; the write is clamped to 0 and never re-applied once the
27
+ * content grows. We therefore re-apply the target offset across a
28
+ * bounded run of animation frames, stopping as soon as the write
29
+ * sticks (the content has grown tall enough) or a small frame cap is
30
+ * reached.
11
31
  *
12
32
  * Native bundlers use `./index.ts` (a no-op); web bundlers select this file via
13
33
  * the `"browser"` export condition in `package.json`.
@@ -33,6 +53,25 @@ export type {
33
53
  const ScrollOffsetContext = createContext<ScrollOffsetStore | null>(null);
34
54
  ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
35
55
 
56
+ /**
57
+ * Maximum number of animation frames the focus restore will re-apply the saved
58
+ * offset before giving up. A virtualized list re-lays out its rows over a
59
+ * handful of frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps)
60
+ * is comfortably longer than any observed relayout while staying short enough
61
+ * that the loop never lingers as a perceptible cost. The loop normally exits
62
+ * far earlier — as soon as the write sticks.
63
+ */
64
+ const RESTORE_FRAME_CAP = 30;
65
+
66
+ /**
67
+ * Tolerance (in CSS pixels) for considering a restore "stuck". After writing
68
+ * `element.scrollTop = target`, the browser may clamp it to the current
69
+ * `scrollHeight - clientHeight`; if the resulting offset is within this many
70
+ * pixels of the target we treat the restore as complete. Sub-pixel rounding and
71
+ * fractional device-pixel ratios make an exact equality check unreliable.
72
+ */
73
+ const RESTORE_STICK_TOLERANCE_PX = 2;
74
+
36
75
  /**
37
76
  * Switch the browser to manual scroll restoration exactly once per document.
38
77
  *
@@ -76,11 +115,15 @@ function useScrollOffsetStore(): ScrollOffsetStore {
76
115
  * multiple scrollables).
77
116
  *
78
117
  * 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.
118
+ * - On every scroll while the screen is focused, the current offset is recorded
119
+ * in memory and persisted. This live stream of saves is the source of truth.
120
+ * - On focus, the saved offset is re-applied across a bounded run of animation
121
+ * frames, stopping as soon as the write sticks (the list has re-rendered its
122
+ * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
123
+ * saved offset of 0 is a no-op (nothing to restore).
124
+ * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
125
+ * a fresh `scrollTop` read, which the navigator may already have forced to 0
126
+ * while collapsing the hidden screen.
84
127
  */
85
128
  export function useScrollRestoration(
86
129
  target: ScrollRestorationTarget,
@@ -116,27 +159,70 @@ export function useScrollRestoration(
116
159
  ? (typeof window !== 'undefined' ? window : null)
117
160
  : resolveScrollEventTarget(targetRef.current);
118
161
 
162
+ // The last offset the live scroll listener observed for this focus
163
+ // session. This — not a blur-time `getOffset()` — is what we persist on
164
+ // blur, because by blur time the navigator may have collapsed the
165
+ // hidden screen and forced its `scrollTop` to 0 (bug A). `null` means
166
+ // the user never scrolled this session, so there is nothing newer to
167
+ // persist than what the scroll listener already saved live.
168
+ let lastObservedOffset: number | null = null;
169
+
119
170
  const save = () => {
120
171
  const currentKey = scrollKeyRef.current;
121
- if (enabledRef.current && currentKey !== null) {
122
- store.save(currentKey, scroller.getOffset());
123
- }
172
+ if (!enabledRef.current || currentKey === null) return;
173
+ const offset = scroller.getOffset();
174
+ // Ignore a spurious 0 produced by the navigator collapsing a hidden
175
+ // background screen: while collapsed the container cannot scroll, so
176
+ // its `scrollTop` is forced to 0. Persisting it would clobber the
177
+ // good offset recorded by earlier live saves (bug A). A genuine
178
+ // scroll-to-top keeps the container scrollable and is saved normally.
179
+ if (offset === 0 && !scroller.canScroll()) return;
180
+ lastObservedOffset = offset;
181
+ store.save(currentKey, offset);
124
182
  };
125
183
 
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
- });
184
+ // Restore across a bounded run of frames. A freshly re-shown
185
+ // virtualized list re-lays out its rows over several frames, so a
186
+ // single write while it is still collapsed would be clamped to 0 and
187
+ // never re-applied (bug B). We re-apply each frame until the write
188
+ // sticks or the frame cap is hit.
189
+ const targetOffset = store.read(key);
190
+ let rafId: number | null = null;
191
+
192
+ if (targetOffset > 0 && typeof requestAnimationFrame !== 'undefined') {
193
+ let framesLeft = RESTORE_FRAME_CAP;
194
+ const applyOffset = () => {
195
+ rafId = null;
196
+ scroller.setOffset(targetOffset);
197
+ framesLeft -= 1;
198
+ // Stop once the write took effect (content grew tall enough) or we
199
+ // exhaust the frame budget. `getOffset` re-reads the clamped value.
200
+ const reached =
201
+ Math.abs(scroller.getOffset() - targetOffset) <=
202
+ RESTORE_STICK_TOLERANCE_PX;
203
+ if (!reached && framesLeft > 0) {
204
+ rafId = requestAnimationFrame(applyOffset);
205
+ }
206
+ };
207
+ rafId = requestAnimationFrame(applyOffset);
208
+ }
131
209
 
132
210
  element?.addEventListener('scroll', save, { passive: true });
133
211
 
134
212
  return () => {
135
- cancelAnimationFrame(frame);
213
+ if (rafId !== null) cancelAnimationFrame(rafId);
136
214
  element?.removeEventListener('scroll', save);
137
- // Final capture on blur, covering navigations that don't fire a
138
- // trailing scroll event.
139
- save();
215
+ // Final capture on blur: persist the last offset the scroll listener
216
+ // OBSERVED, never a fresh read (which the navigator may have forced
217
+ // to 0 while collapsing the hidden screen). When the user never
218
+ // scrolled this session there is nothing newer to persist than the
219
+ // live saves already recorded.
220
+ if (lastObservedOffset !== null) {
221
+ const currentKey = scrollKeyRef.current;
222
+ if (enabledRef.current && currentKey !== null) {
223
+ store.save(currentKey, lastObservedOffset);
224
+ }
225
+ }
140
226
  };
141
227
  },
142
228
  [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
  }