@oxyhq/bloom 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +3 -3
  2. package/lib/commonjs/provider/index.js +11 -7
  3. package/lib/commonjs/provider/index.js.map +1 -1
  4. package/lib/commonjs/scroll/context.js +96 -0
  5. package/lib/commonjs/scroll/context.js.map +1 -0
  6. package/lib/commonjs/scroll/expo-router/index.js +133 -0
  7. package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
  8. package/lib/commonjs/scroll/index.js +85 -21
  9. package/lib/commonjs/scroll/index.js.map +1 -1
  10. package/lib/commonjs/scroll/index.web.js +221 -121
  11. package/lib/commonjs/scroll/index.web.js.map +1 -1
  12. package/lib/commonjs/scroll/scrollable.native.js +58 -0
  13. package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
  14. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  15. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  16. package/lib/commonjs/scroll/store.js +47 -25
  17. package/lib/commonjs/scroll/store.js.map +1 -1
  18. package/lib/commonjs/tabs/Tabs.js +263 -63
  19. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  20. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  21. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  22. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  23. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  24. package/lib/commonjs/theme/color-policy.js +11 -10
  25. package/lib/commonjs/theme/color-policy.js.map +1 -1
  26. package/lib/module/provider/index.js +10 -6
  27. package/lib/module/provider/index.js.map +1 -1
  28. package/lib/module/scroll/context.js +90 -0
  29. package/lib/module/scroll/context.js.map +1 -0
  30. package/lib/module/scroll/expo-router/index.js +128 -0
  31. package/lib/module/scroll/expo-router/index.js.map +1 -0
  32. package/lib/module/scroll/index.js +80 -21
  33. package/lib/module/scroll/index.js.map +1 -1
  34. package/lib/module/scroll/index.web.js +218 -122
  35. package/lib/module/scroll/index.web.js.map +1 -1
  36. package/lib/module/scroll/scrollable.native.js +54 -0
  37. package/lib/module/scroll/scrollable.native.js.map +1 -0
  38. package/lib/module/scroll/scrollable.web.js +9 -3
  39. package/lib/module/scroll/scrollable.web.js.map +1 -1
  40. package/lib/module/scroll/store.js +47 -25
  41. package/lib/module/scroll/store.js.map +1 -1
  42. package/lib/module/tabs/Tabs.js +271 -65
  43. package/lib/module/tabs/Tabs.js.map +1 -1
  44. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  45. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  46. package/lib/module/tabs/expo-router/index.js +10 -0
  47. package/lib/module/tabs/expo-router/index.js.map +1 -0
  48. package/lib/module/theme/color-policy.js +11 -10
  49. package/lib/module/theme/color-policy.js.map +1 -1
  50. package/lib/typescript/commonjs/provider/index.d.ts +7 -5
  51. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
  53. package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
  55. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
  57. package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
  59. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
  61. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  63. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
  65. package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
  67. package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  69. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  71. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  73. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  75. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  77. package/lib/typescript/module/provider/index.d.ts +7 -5
  78. package/lib/typescript/module/provider/index.d.ts.map +1 -1
  79. package/lib/typescript/module/scroll/context.d.ts +14 -0
  80. package/lib/typescript/module/scroll/context.d.ts.map +1 -0
  81. package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
  82. package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
  83. package/lib/typescript/module/scroll/index.d.ts +14 -23
  84. package/lib/typescript/module/scroll/index.d.ts.map +1 -1
  85. package/lib/typescript/module/scroll/index.web.d.ts +17 -19
  86. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  87. package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
  88. package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
  89. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  90. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  91. package/lib/typescript/module/scroll/store.d.ts +41 -22
  92. package/lib/typescript/module/scroll/store.d.ts.map +1 -1
  93. package/lib/typescript/module/scroll/types.d.ts +82 -12
  94. package/lib/typescript/module/scroll/types.d.ts.map +1 -1
  95. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  96. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  97. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  98. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  99. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  100. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  101. package/lib/typescript/module/tabs/types.d.ts +48 -4
  102. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  103. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  104. package/package.json +29 -1
  105. package/src/__tests__/BloomProvider.web.test.tsx +8 -15
  106. package/src/__tests__/Tabs.test.tsx +187 -0
  107. package/src/__tests__/optional-peer-imports.test.ts +7 -2
  108. package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
  109. package/src/__tests__/scroll-native.test.tsx +465 -0
  110. package/src/__tests__/scroll-store.test.ts +41 -17
  111. package/src/__tests__/scroll-web.test.tsx +797 -108
  112. package/src/provider/index.tsx +10 -7
  113. package/src/scroll/context.tsx +107 -0
  114. package/src/scroll/expo-router/index.ts +140 -0
  115. package/src/scroll/index.ts +92 -25
  116. package/src/scroll/index.web.tsx +255 -162
  117. package/src/scroll/scrollable.native.ts +64 -0
  118. package/src/scroll/scrollable.web.ts +30 -8
  119. package/src/scroll/store.ts +47 -25
  120. package/src/scroll/types.ts +82 -12
  121. package/src/tabs/Tabs.tsx +352 -77
  122. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  123. package/src/tabs/expo-router/index.ts +8 -0
  124. package/src/tabs/types.ts +48 -4
  125. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +5 -5
  126. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +11 -11
  127. package/src/theme/__tests__/policy-legibility.test.ts +10 -7
  128. package/src/theme/color-policy.ts +11 -10
  129. package/lib/commonjs/provider/scroll-provider.js +0 -13
  130. package/lib/commonjs/provider/scroll-provider.js.map +0 -1
  131. package/lib/commonjs/provider/scroll-provider.web.js +0 -13
  132. package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
  133. package/lib/module/provider/scroll-provider.js +0 -16
  134. package/lib/module/provider/scroll-provider.js.map +0 -1
  135. package/lib/module/provider/scroll-provider.web.js +0 -5
  136. package/lib/module/provider/scroll-provider.web.js.map +0 -1
  137. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
  138. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
  139. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
  140. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
  141. package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
  142. package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
  143. package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
  144. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
  145. package/src/__tests__/scroll-native.test.ts +0 -25
  146. package/src/provider/scroll-provider.ts +0 -13
  147. package/src/provider/scroll-provider.web.ts +0 -2
@@ -1,22 +1,22 @@
1
1
  /**
2
2
  * Web variant of the scroll-restoration primitive.
3
3
  *
4
- * Mirrors the proven Bluesky pattern (`history.scrollRestoration = 'manual'`
5
- * plus an in-memory `Map<routeKey, offset>`) with two deliberate differences
6
- * forced by Oxy's layouts and the behaviour of the (expo-router-wrapped)
7
- * React Navigation web stack:
4
+ * Mirrors the proven Bluesky pattern (manual `history.scrollRestoration` plus
5
+ * an in-memory `Map<key, offset>`) with three deliberate differences forced by
6
+ * Oxy's layouts and by the behaviour of the (expo-router-wrapped) React
7
+ * Navigation web stack:
8
8
  *
9
9
  * 1. Bluesky restores the WINDOW scroller, whereas Oxy apps keep multi-column
10
10
  * layouts whose feed scrolls an INNER container. So we restore the offset
11
11
  * of a caller-registered scrollable (a ref to an element / RN scroll
12
- * component, or the `'window'` sentinel), keyed by the active route.
12
+ * component, or the `'window'` sentinel).
13
13
  *
14
- * 2. The web stack HIDES the background screen on push. While
15
- * hidden, the previous screen's scroll container collapses
16
- * (`scrollHeight === clientHeight`) and the navigator forces its
17
- * `scrollTop` to 0. The screen is NOT unmounted, so a virtualized list
18
- * (e.g. FlashList) keeps its rows but re-lays them out over SEVERAL frames
19
- * once the screen is re-shown. Two problems follow, both handled here:
14
+ * 2. The web stack HIDES the background screen on push. While hidden, the
15
+ * previous screen's scroll container collapses (`scrollHeight ===
16
+ * clientHeight`) and the navigator forces its `scrollTop` to 0. The screen
17
+ * is NOT unmounted, so a virtualized list (e.g. FlashList) keeps its rows
18
+ * but re-lays them out over SEVERAL frames once the screen is re-shown. Two
19
+ * problems follow, both handled here:
20
20
  *
21
21
  * (a) A blur-time read of `scrollTop` returns the navigator's forced 0,
22
22
  * not the user's real offset — saving it would clobber the good
@@ -30,45 +30,44 @@
30
30
  * sticks (the content has grown tall enough) or a small frame cap is
31
31
  * reached.
32
32
  *
33
- * Native bundlers use `./index.ts` (a no-op); web bundlers select this file via
34
- * the `"browser"` export condition in `package.json`.
33
+ * 3. A document-scrolled app (one window scroller shared by every route) has
34
+ * no per-screen container to reset, so an unrecognised key MUST be written
35
+ * to 0 explicitly. Doing nothing leaves the previous screen's offset in
36
+ * place and the new screen opens mid-page.
35
37
  *
36
- * The navigation hooks are imported from `expo-router` (which re-exports
37
- * `useFocusEffect` and `useRoute` from its bundled React Navigation core) rather
38
- * than from `@react-navigation/native` directly. Every Oxy app uses expo-router
39
- * as its router, so it is always a DIRECT, top-level dependency that resolves
40
- * cleanly under Bun's isolated linker — whereas `@react-navigation/native` is
41
- * only a nested/transitive dependency of expo-router and would fail to resolve
42
- * when bundling those apps.
38
+ * Native bundlers use `./index.ts`; web bundlers select this file via the
39
+ * `"browser"` export condition in `package.json`.
43
40
  */
44
- import { createContext, useCallback, useContext, useMemo, useRef } from 'react';
45
- import { useFocusEffect, useRoute } from 'expo-router';
41
+ import { useCallback, useRef } from 'react';
46
42
 
43
+ import { useScrollRestorationContext } from './context';
47
44
  import { createScroller } from './scrollable.web';
48
- import { ScrollOffsetStore, deriveScrollKey } from './store';
45
+ import { deriveScrollKey } from './store';
49
46
  import type {
50
- ScrollRestorationProviderProps,
47
+ ScrollRestorationBinding,
51
48
  ScrollRestorationTarget,
52
49
  UseScrollRestorationOptions,
53
50
  } from './types';
54
51
 
52
+ export { ScrollRestorationProvider } from './context';
53
+
55
54
  export type {
55
+ ScreenFocusEffect,
56
56
  ScrollableHandle,
57
+ ScrollRestorationBinding,
57
58
  ScrollRestorationProviderProps,
58
59
  ScrollRestorationTarget,
60
+ ScrollRouterAdapter,
59
61
  UseScrollRestorationOptions,
60
62
  } from './types';
61
63
 
62
- const ScrollOffsetContext = createContext<ScrollOffsetStore | null>(null);
63
- ScrollOffsetContext.displayName = 'BloomScrollOffsetContext';
64
-
65
64
  /**
66
- * Maximum number of animation frames the focus restore will re-apply the saved
67
- * offset before giving up. A virtualized list re-lays out its rows over a
68
- * handful of frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps)
69
- * is comfortably longer than any observed relayout while staying short enough
70
- * that the loop never lingers as a perceptible cost. The loop normally exits
71
- * far earlier — as soon as the write sticks.
65
+ * Maximum number of animation frames the restore will re-apply the saved offset
66
+ * before giving up. A virtualized list re-lays out its rows over a handful of
67
+ * frames after its screen is re-shown; ~30 frames (≈0.5s at 60fps) is
68
+ * comfortably longer than any observed relayout while staying short enough that
69
+ * the loop never lingers as a perceptible cost. The loop normally exits far
70
+ * earlier — as soon as the write sticks.
72
71
  */
73
72
  const RESTORE_FRAME_CAP = 30;
74
73
 
@@ -82,162 +81,256 @@ const RESTORE_FRAME_CAP = 30;
82
81
  const RESTORE_STICK_TOLERANCE_PX = 2;
83
82
 
84
83
  /**
85
- * Switch the browser to manual scroll restoration exactly once per document.
86
- *
87
- * The browser's default `'auto'` restoration fights our manual restore on
88
- * Back/Forward navigations. Doing this at module scope (guarded for SSR) means
89
- * it is set before any provider mounts, matching Bluesky's module-level call.
84
+ * The web binding is a constant: this platform observes offsets by subscribing
85
+ * to the resolved DOM node's own `scroll` event, so it needs nothing from the
86
+ * caller. It is still returned and still safe to spread onto a list — so a
87
+ * call site is written once and runs on both platforms.
90
88
  */
91
- if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
92
- history.scrollRestoration = 'manual';
93
- }
89
+ const WEB_BINDING: ScrollRestorationBinding = { onScroll: () => undefined };
94
90
 
95
91
  /**
96
- * Holds the per-route offset map for the subtree. One provider near the app
97
- * root is enough; the store lives for the document's lifetime so offsets
98
- * survive navigating away and back (including browser Back/Forward).
92
+ * Events that mean the USER has taken over the scroller, and the restore must
93
+ * stop trying to put them back.
94
+ *
95
+ * The browser's own restoration is specified the same way: HTML §7.4.6.5 has
96
+ * the UA re-attempt the saved position and "may continue to attempt to do so
97
+ * periodically, until document's has been scrolled by the user becomes true".
98
+ * Without this the loop fights anyone who starts scrolling inside the frame
99
+ * budget, yanking them back once per frame for up to {@link RESTORE_FRAME_CAP}
100
+ * frames.
101
+ *
102
+ * Two designs were rejected, and the reasons are the point:
103
+ *
104
+ * - **"Any `scroll` event aborts"** — the obvious version, and it aborts on the
105
+ * FIRST frame every time: `setOffset` is itself a scroll, so the loop would
106
+ * cancel on the event its own write produced and no restore would ever
107
+ * complete.
108
+ * - **"Abort on an offset we did not write"** — tempting because it needs no
109
+ * listeners, but a virtualized list inserting rows ABOVE the viewport makes
110
+ * the browser's scroll anchoring move `scrollTop` on its own. That is
111
+ * precisely the situation the loop exists for, so the unexplained-delta test
112
+ * would abort exactly when it must not.
113
+ *
114
+ * What is being detected is user INPUT, so this detects user input. `pointerdown`
115
+ * covers a scrollbar drag and a touch-less press; `keydown` covers arrow/page
116
+ * keys, and reaches us whenever focus is inside the scroller it would scroll.
99
117
  */
100
- export function ScrollRestorationProvider({
101
- children,
102
- }: ScrollRestorationProviderProps) {
103
- const store = useMemo(() => new ScrollOffsetStore(), []);
104
- return (
105
- <ScrollOffsetContext.Provider value={store}>
106
- {children}
107
- </ScrollOffsetContext.Provider>
108
- );
109
- }
110
-
111
- function useScrollOffsetStore(): ScrollOffsetStore {
112
- const store = useContext(ScrollOffsetContext);
113
- if (store === null) {
114
- throw new Error(
115
- 'useScrollRestoration must be used within a <ScrollRestorationProvider>.',
116
- );
117
- }
118
- return store;
119
- }
118
+ const USER_TAKEOVER_EVENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown'];
120
119
 
121
120
  /**
122
121
  * Preserve and restore the scroll offset of `target` across navigation, keyed
123
- * by the active route (plus an optional `options.key` for routes that host
124
- * multiple scrollables).
122
+ * by the content its screen is showing plus an optional `options.key`.
125
123
  *
126
124
  * Behaviour (web):
127
125
  * - On every scroll while the screen is focused, the current offset is recorded
128
- * in memory and persisted. This live stream of saves is the source of truth.
129
- * - On focus, the saved offset is re-applied across a bounded run of animation
130
- * frames, stopping as soon as the write sticks (the list has re-rendered its
131
- * rows and grown tall enough) or {@link RESTORE_FRAME_CAP} is reached. A
132
- * saved offset of 0 is a no-op (nothing to restore).
133
- * - On blur, the LAST OBSERVED offset is persisted as a final safety net — not
134
- * a fresh `scrollTop` read, which the navigator may already have forced to 0
135
- * while collapsing the hidden screen.
126
+ * under the key that was live when the listener was attached. This stream of
127
+ * saves is the source of truth.
128
+ * - On focus and again whenever the key changes while the screen STAYS
129
+ * focused, which is how an in-screen tab or folder swap is seen at all — the
130
+ * offset saved for the new key is re-applied across a bounded run of
131
+ * animation frames, stopping as soon as the write sticks or
132
+ * {@link RESTORE_FRAME_CAP} is reached. Content never seen this session is
133
+ * written to 0 once instead; a write to 0 always sticks, so it needs no loop.
134
+ * - On blur (and on a key change), the LAST OBSERVED offset is persisted under
135
+ * the OUTGOING key as a final safety net — not a fresh `scrollTop` read,
136
+ * which the navigator may already have forced to 0 while collapsing the
137
+ * hidden screen.
136
138
  */
137
139
  export function useScrollRestoration(
138
140
  target: ScrollRestorationTarget,
139
141
  options?: UseScrollRestorationOptions,
140
- ): void {
141
- const store = useScrollOffsetStore();
142
- const route = useRoute();
143
- const subKey = options?.key;
142
+ ): ScrollRestorationBinding {
143
+ const { store, adapter } = useScrollRestorationContext();
144
+ const contentId = adapter.useScreenContentId();
144
145
  const enabled = options?.enabled ?? true;
146
+ const scrollKey = deriveScrollKey(contentId, options?.key);
145
147
 
146
- const scrollKey = deriveScrollKey(route.key, subKey);
147
-
148
- // Keep the latest target/enabled/key in refs so the focus effect can read
149
- // them without being re-subscribed on every render.
150
- const targetRef = useRef(target);
151
- targetRef.current = target;
152
- const enabledRef = useRef(enabled);
153
- enabledRef.current = enabled;
154
- const scrollKeyRef = useRef(scrollKey);
155
- scrollKeyRef.current = scrollKey;
148
+ // The key this hook instance has already sent to the top. Writing 0 is a
149
+ // one-time ARRIVAL action: a later re-run for the SAME key (`enabled` turning
150
+ // on once the rows exist, a screen regaining focus) must not repeat it,
151
+ // because by then the user may have scrolled somewhere the hook never got to
152
+ // record. Restoring twice is harmless; resetting twice is data loss.
153
+ const resetKeyRef = useRef<string | null>(null);
156
154
 
157
- useFocusEffect(
158
- // The effect identity is intentionally stable across renders: it reads all
159
- // varying inputs from refs. expo-router's `useFocusEffect` re-runs it on
160
- // each focus.
161
- useCallback(
162
- () => {
163
- const key = scrollKeyRef.current;
164
- if (!enabledRef.current || key === null) return undefined;
155
+ adapter.useScreenFocusEffect(
156
+ // Every varying input is a dependency rather than a ref read, so the
157
+ // session below CLOSES OVER the key it belongs to. That is what makes a
158
+ // key change mid-focus correct in both directions: the outgoing session's
159
+ // cleanup persists to the old key, and the incoming one restores the new.
160
+ useCallback(() => {
161
+ if (!enabled || scrollKey === null) return undefined;
165
162
 
166
- const scroller = createScroller(targetRef.current);
167
- const element =
168
- targetRef.current === 'window'
169
- ? (typeof window !== 'undefined' ? window : null)
170
- : resolveScrollEventTarget(targetRef.current);
163
+ const scroller = createScroller(target);
164
+ const element =
165
+ target === 'window'
166
+ ? typeof window === 'undefined'
167
+ ? null
168
+ : window
169
+ : resolveScrollEventTarget(target);
171
170
 
172
- // The last offset the live scroll listener observed for this focus
173
- // session. This — not a blur-time `getOffset()` — is what we persist on
174
- // blur, because by blur time the navigator may have collapsed the
175
- // hidden screen and forced its `scrollTop` to 0 (bug A). `null` means
176
- // the user never scrolled this session, so there is nothing newer to
177
- // persist than what the scroll listener already saved live.
178
- let lastObservedOffset: number | null = null;
171
+ // The last offset the live scroll listener observed for this session.
172
+ // This — not a cleanup-time `getOffset()` — is what we persist at the
173
+ // end, because by then the navigator may have collapsed the hidden
174
+ // screen and forced its `scrollTop` to 0 (problem A). `null` means the
175
+ // user never scrolled this session, so there is nothing newer to persist
176
+ // than what the scroll listener already saved live.
177
+ let lastObservedOffset: number | null = null;
179
178
 
180
- const save = () => {
181
- const currentKey = scrollKeyRef.current;
182
- if (!enabledRef.current || currentKey === null) return;
183
- const offset = scroller.getOffset();
184
- // Ignore a spurious 0 produced by the navigator collapsing a hidden
185
- // background screen: while collapsed the container cannot scroll, so
186
- // its `scrollTop` is forced to 0. Persisting it would clobber the
187
- // good offset recorded by earlier live saves (bug A). A genuine
188
- // scroll-to-top keeps the container scrollable and is saved normally.
189
- if (offset === 0 && !scroller.canScroll()) return;
190
- lastObservedOffset = offset;
191
- store.save(currentKey, offset);
192
- };
179
+ // The offset WE last wrote, which a real browser dispatches back as a
180
+ // `scroll` event (jsdom does not, so the tests for this emit it
181
+ // explicitly). Both writers arm it: the reset, and every frame of the
182
+ // restore loop. Persisting either records an offset the user never
183
+ // chose the reset's 0 under a key another LIVE screen may share (the
184
+ // two-live-entries trade content keying makes, see `deriveScrollKey`),
185
+ // and the loop's intermediate value as a PARTIAL that outlives an
186
+ // interrupted restore.
187
+ //
188
+ // A `restoring` FLAG covering the loop's duration is the obvious
189
+ // alternative and is wrong. It could only be cleared by a takeover
190
+ // event, so a session whose scroll came from a source that fires none —
191
+ // `scrollIntoView` from another component, a focus-driven scroll — would
192
+ // suppress saves indefinitely and never record the user's position at
193
+ // all: an unbounded corruption traded for a bounded one. Arming a VALUE
194
+ // is bounded to one event per write and covers every way the loop can
195
+ // end (stuck, capped, aborted), because each frame re-arms.
196
+ let echoOffset: number | null = null;
193
197
 
194
- // Restore across a bounded run of frames. A freshly re-shown
195
- // virtualized list re-lays out its rows over several frames, so a
196
- // single write while it is still collapsed would be clamped to 0 and
197
- // never re-applied (bug B). We re-apply each frame until the write
198
- // sticks or the frame cap is hit.
199
- const targetOffset = store.read(key);
200
- let rafId: number | null = null;
198
+ // The largest offset reachable when we last PERSISTED one. A browser
199
+ // clamp is recognised against this rather than against the previous
200
+ // OBSERVATION, and that distinction is the whole mechanism: Chrome
201
+ // dispatches TWO `scroll` events for one clamp, so an
202
+ // observation-relative test sees the shrink on the first and nothing on
203
+ // the second, which then saves the clamped value anyway. Measured.
204
+ let maxOffsetAtLastSave = scroller.getMaxOffset();
201
205
 
202
- if (targetOffset > 0 && typeof requestAnimationFrame !== 'undefined') {
203
- let framesLeft = RESTORE_FRAME_CAP;
204
- const applyOffset = () => {
205
- rafId = null;
206
- scroller.setOffset(targetOffset);
207
- framesLeft -= 1;
208
- // Stop once the write took effect (content grew tall enough) or we
209
- // exhaust the frame budget. `getOffset` re-reads the clamped value.
210
- const reached =
211
- Math.abs(scroller.getOffset() - targetOffset) <=
212
- RESTORE_STICK_TOLERANCE_PX;
213
- if (!reached && framesLeft > 0) {
214
- rafId = requestAnimationFrame(applyOffset);
215
- }
216
- };
217
- rafId = requestAnimationFrame(applyOffset);
206
+ const save = () => {
207
+ const offset = scroller.getOffset();
208
+ const maxOffset = scroller.getMaxOffset();
209
+ // Ignore a spurious 0 produced by the navigator collapsing a hidden
210
+ // background screen: while collapsed the container cannot scroll, so
211
+ // its `scrollTop` is forced to 0. Persisting it would clobber the good
212
+ // offset recorded by earlier live saves (problem A). A genuine
213
+ // scroll-to-top keeps the container scrollable and is saved normally.
214
+ if (offset === 0 && maxOffset <= 0) return;
215
+ // A partial clamp: the reachable range SHRANK and the offset is sitting
216
+ // exactly at the new bottom. That is what the browser does to a tab
217
+ // whose document has been taken over by a shorter sibling — the two
218
+ // together are a signature a genuine scroll does not have, because
219
+ // reaching the bottom on your own does not shrink the page in the same
220
+ // breath. Suppressing keeps the pre-clamp offset, which on return
221
+ // simply clamps to the same place if the content really did shrink.
222
+ if (
223
+ maxOffset < maxOffsetAtLastSave &&
224
+ Math.abs(offset - maxOffset) <= RESTORE_STICK_TOLERANCE_PX
225
+ ) {
226
+ return;
227
+ }
228
+ if (offset === echoOffset) {
229
+ // Our own write coming back. Consume the arming so a real scroll to
230
+ // the same offset later is saved normally.
231
+ echoOffset = null;
232
+ return;
218
233
  }
234
+ echoOffset = null;
235
+ lastObservedOffset = offset;
236
+ maxOffsetAtLastSave = maxOffset;
237
+ store.save(scrollKey, offset);
238
+ };
219
239
 
220
- element?.addEventListener('scroll', save, { passive: true });
240
+ // Restore and reset are ONE write. A miss reads 0, and 0 is exactly what
241
+ // an unseen screen must be written to — left alone it would show the
242
+ // previous screen's offset, since a document-scrolled app shares one
243
+ // scroller across every route.
244
+ const targetOffset = store.read(scrollKey);
221
245
 
222
- return () => {
223
- if (rafId !== null) cancelAnimationFrame(rafId);
224
- element?.removeEventListener('scroll', save);
225
- // Final capture on blur: persist the last offset the scroll listener
226
- // OBSERVED, never a fresh read (which the navigator may have forced
227
- // to 0 while collapsing the hidden screen). When the user never
228
- // scrolled this session there is nothing newer to persist than the
229
- // live saves already recorded.
230
- if (lastObservedOffset !== null) {
231
- const currentKey = scrollKeyRef.current;
232
- if (enabledRef.current && currentKey !== null) {
233
- store.save(currentKey, lastObservedOffset);
234
- }
246
+ // They remain two DECISIONS, though, and `read` cannot tell them apart:
247
+ // a key never seen and a key deliberately saved at the top both read 0.
248
+ // Only the first is a RESET, and a reset is a one-time ARRIVAL action
249
+ // gated on `resetKeyRef` — repeating it on a later re-run for the same
250
+ // key would send a user who has since scrolled back to the top, and the
251
+ // hook was inert while they did it so there is nothing to restore them
252
+ // to afterwards. A 0 the user deliberately saved is theirs, and always
253
+ // re-applies. `has` is the only thing that answers this.
254
+ const isReset = !store.has(scrollKey);
255
+ const alreadyReset = isReset && resetKeyRef.current === scrollKey;
256
+ if (isReset) resetKeyRef.current = scrollKey;
257
+
258
+ let rafId: number | null = null;
259
+
260
+ const stopRestore = () => {
261
+ if (rafId === null) return;
262
+ cancelAnimationFrame(rafId);
263
+ rafId = null;
264
+ };
265
+
266
+ if (alreadyReset) {
267
+ // Nothing to write: this content has already been sent to the top once
268
+ // and the user is wherever they have moved to since.
269
+ } else if (targetOffset === 0) {
270
+ // A write to 0 is never clamped away, so it needs no re-apply loop.
271
+ echoOffset = 0;
272
+ scroller.setOffset(0);
273
+ } else if (typeof requestAnimationFrame !== 'undefined') {
274
+ // Re-apply across a bounded run of frames. A freshly re-shown
275
+ // virtualized list re-lays out its rows over several frames, so a
276
+ // single write while it is still collapsed would be clamped to 0 and
277
+ // never re-applied (problem B).
278
+ //
279
+ // Every write the loop makes is suppressed from the save path (see
280
+ // `echoOffset` below), so an interrupted restore leaves the ORIGINAL
281
+ // target stored rather than a partial. Nothing is lost on the success
282
+ // path: `targetOffset` came out of the store, so persisting it again
283
+ // would write back the same number.
284
+ let framesLeft = RESTORE_FRAME_CAP;
285
+ const applyOffset = () => {
286
+ rafId = null;
287
+ scroller.setOffset(targetOffset);
288
+ framesLeft -= 1;
289
+ // `getOffset` re-reads the value the browser actually took, which is
290
+ // clamped to the content height it has reached so far. Arm it as the
291
+ // echo to swallow: this write is about to come back as a `scroll`
292
+ // event, and persisting a PARTIAL offset we wrote ourselves is how a
293
+ // restore that is aborted or capped corrupts the stored position.
294
+ const landed = scroller.getOffset();
295
+ echoOffset = landed;
296
+ // Stop once the write took effect (content grew tall enough) or we
297
+ // exhaust the frame budget.
298
+ const reached =
299
+ Math.abs(landed - targetOffset) <= RESTORE_STICK_TOLERANCE_PX;
300
+ if (!reached && framesLeft > 0) {
301
+ rafId = requestAnimationFrame(applyOffset);
235
302
  }
236
303
  };
237
- },
238
- [store],
239
- ),
304
+ rafId = requestAnimationFrame(applyOffset);
305
+ // Only while a loop is actually in flight — the reset path has nothing
306
+ // to abort, and the listeners would be idle on every screen.
307
+ for (const type of USER_TAKEOVER_EVENTS) {
308
+ element?.addEventListener(type, stopRestore, { passive: true });
309
+ }
310
+ }
311
+
312
+ element?.addEventListener('scroll', save, { passive: true });
313
+
314
+ return () => {
315
+ stopRestore();
316
+ element?.removeEventListener('scroll', save);
317
+ // Removing a listener that was never added is a no-op, so this needs no
318
+ // flag tracking whether the loop branch ran.
319
+ for (const type of USER_TAKEOVER_EVENTS) {
320
+ element?.removeEventListener(type, stopRestore);
321
+ }
322
+ // Final capture: persist the last offset the scroll listener OBSERVED,
323
+ // never a fresh read (which the navigator may have forced to 0 while
324
+ // collapsing the hidden screen). When the user never scrolled this
325
+ // session there is nothing newer than the live saves already recorded.
326
+ if (lastObservedOffset !== null) {
327
+ store.save(scrollKey, lastObservedOffset);
328
+ }
329
+ };
330
+ }, [store, scrollKey, enabled, target]),
240
331
  );
332
+
333
+ return WEB_BINDING;
241
334
  }
242
335
 
243
336
  /**
@@ -247,7 +340,7 @@ export function useScrollRestoration(
247
340
  function resolveScrollEventTarget(
248
341
  target: Exclude<ScrollRestorationTarget, 'window'>,
249
342
  ): EventTarget | null {
250
- const current = (target as { current: unknown }).current;
343
+ const current = target.current;
251
344
  if (current == null) return null;
252
345
  if (typeof EventTarget !== 'undefined' && current instanceof EventTarget) {
253
346
  return current;
@@ -0,0 +1,64 @@
1
+ import type { ScrollableHandle, ScrollRestorationTarget } from './types';
2
+
3
+ /**
4
+ * The write half of a scrollable, on native. There is no read half: native
5
+ * offers no way to sample a list's offset from outside it, so offsets arrive
6
+ * through the `onScroll` binding the hook returns instead.
7
+ */
8
+ export interface NativeScroller {
9
+ setOffset: (offset: number) => void;
10
+ }
11
+
12
+ function hasScrollToOffset(
13
+ value: unknown,
14
+ ): value is Required<Pick<ScrollableHandle, 'scrollToOffset'>> {
15
+ return (
16
+ typeof value === 'object' &&
17
+ value !== null &&
18
+ typeof (value as ScrollableHandle).scrollToOffset === 'function'
19
+ );
20
+ }
21
+
22
+ function hasScrollTo(
23
+ value: unknown,
24
+ ): value is Required<Pick<ScrollableHandle, 'scrollTo'>> {
25
+ return (
26
+ typeof value === 'object' &&
27
+ value !== null &&
28
+ typeof (value as ScrollableHandle).scrollTo === 'function'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Build a {@link NativeScroller} for a target.
34
+ *
35
+ * `scrollToOffset` (`FlatList`, `FlashList`) is preferred over `scrollTo`
36
+ * (`ScrollView`) because a list exposes both shapes on some versions and only
37
+ * the former accounts for the list's own header. The `'window'` sentinel has no
38
+ * native meaning — there is no document scroller — so it resolves to a no-op
39
+ * rather than an error: a call site shared with web legitimately passes it.
40
+ *
41
+ * The handle is re-resolved on every write; it is never cached, because a ref
42
+ * can be swapped or detached between the schedule and the write.
43
+ */
44
+ export function createScroller(
45
+ target: ScrollRestorationTarget,
46
+ ): NativeScroller {
47
+ if (target === 'window') {
48
+ return { setOffset: () => undefined };
49
+ }
50
+
51
+ return {
52
+ setOffset: (offset) => {
53
+ const current = target.current;
54
+ if (current == null) return;
55
+ if (hasScrollToOffset(current)) {
56
+ current.scrollToOffset({ offset, animated: false });
57
+ return;
58
+ }
59
+ if (hasScrollTo(current)) {
60
+ current.scrollTo({ y: offset, animated: false });
61
+ }
62
+ },
63
+ };
64
+ }
@@ -9,17 +9,30 @@ export interface ResolvedScroller {
9
9
  getOffset: () => number;
10
10
  setOffset: (offset: number) => void;
11
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`).
12
+ * The largest offset the container can currently hold `scrollHeight -
13
+ * clientHeight`, clamped at 0. Answers two questions the save path needs:
14
+ * whether a 0 read is real (`maxOffset > 0`) and whether an offset is sitting
15
+ * exactly at the bottom, which is the signature of a browser clamp.
14
16
  *
15
17
  * React Navigation's web stack collapses a hidden background screen so its
16
18
  * content height drops to the viewport height; while collapsed the container
17
19
  * cannot be scrolled and its `scrollTop` is forced to 0. The hook uses this
18
20
  * 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
+ * persisting it over a previously-saved good offset.
22
+ *
23
+ * The DOCUMENT collapses the same way, so the `'window'` sentinel answers the
24
+ * same question honestly rather than hardcoding `true`. It used to, on the
25
+ * premise that the navigator never collapses the document — false under a
26
+ * tabbed navigator, where every tab stays MOUNTED and non-focused ones are
27
+ * `display: none` (expo-router's `ui/TabSlot`, and react-native-screens' web
28
+ * `Screen` at `activityState === 0`). Measured in Chrome 150: switching from
29
+ * a tall tab to a short one drops `documentElement.scrollHeight` from 8040 to
30
+ * the 800px viewport, forces `scrollY` to 0 and dispatches two `scroll`
31
+ * events — which the outgoing tab's still-attached listener would otherwise
32
+ * persist as 0 over its real offset, because blur is emitted from an effect
33
+ * that has not run yet.
21
34
  */
22
- canScroll: () => boolean;
35
+ getMaxOffset: () => number;
23
36
  }
24
37
 
25
38
  function isElement(value: unknown): value is HTMLElement {
@@ -71,7 +84,15 @@ export function createScroller(target: ScrollRestorationTarget): ResolvedScrolle
71
84
  setOffset: (offset) => {
72
85
  if (typeof window !== 'undefined') window.scrollTo(0, offset);
73
86
  },
74
- canScroll: () => true,
87
+ getMaxOffset: () => {
88
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
89
+ return 0;
90
+ }
91
+ return Math.max(
92
+ 0,
93
+ document.documentElement.scrollHeight - window.innerHeight,
94
+ );
95
+ },
75
96
  };
76
97
  }
77
98
 
@@ -84,9 +105,10 @@ export function createScroller(target: ScrollRestorationTarget): ResolvedScrolle
84
105
  const element = resolveElement(target);
85
106
  if (element) element.scrollTop = offset;
86
107
  },
87
- canScroll: () => {
108
+ getMaxOffset: () => {
88
109
  const element = resolveElement(target);
89
- return element ? element.scrollHeight > element.clientHeight : false;
110
+ if (!element) return 0;
111
+ return Math.max(0, element.scrollHeight - element.clientHeight);
90
112
  },
91
113
  };
92
114
  }