@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.
- package/lib/commonjs/scroll/index.web.js +106 -23
- package/lib/commonjs/scroll/index.web.js.map +1 -1
- package/lib/commonjs/scroll/scrollable.web.js +8 -3
- package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
- package/lib/module/scroll/index.web.js +106 -23
- package/lib/module/scroll/index.web.js.map +1 -1
- package/lib/module/scroll/scrollable.web.js +8 -3
- package/lib/module/scroll/scrollable.web.js.map +1 -1
- package/lib/typescript/commonjs/scroll/index.web.d.ts +9 -5
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +14 -2
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/index.web.d.ts +9 -5
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/scrollable.web.d.ts +14 -2
- package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/scroll-web.test.tsx +325 -0
- package/src/scroll/index.web.tsx +109 -23
- package/src/scroll/scrollable.web.ts +19 -2
|
@@ -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>`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
102
|
-
|
|
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
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
190
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
116
191
|
element?.removeEventListener('scroll', save);
|
|
117
|
-
// Final capture on blur
|
|
118
|
-
//
|
|
119
|
-
|
|
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","
|
|
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.
|
|
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;;
|
|
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>`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
96
|
-
|
|
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
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
184
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
110
185
|
element?.removeEventListener('scroll', save);
|
|
111
|
-
// Final capture on blur
|
|
112
|
-
//
|
|
113
|
-
|
|
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","
|
|
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.
|
|
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;;
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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":"
|
|
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.
|
|
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;
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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":"
|
|
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.
|
|
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;
|
|
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
|
@@ -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
|
+
});
|
package/src/scroll/index.web.tsx
CHANGED
|
@@ -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>`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
122
|
-
|
|
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
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
213
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
136
214
|
element?.removeEventListener('scroll', save);
|
|
137
|
-
// Final capture on blur
|
|
138
|
-
//
|
|
139
|
-
|
|
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.
|
|
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
|
}
|