@oxyhq/bloom 0.79.1 → 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 (52) hide show
  1. package/lib/commonjs/scroll/index.web.js +52 -18
  2. package/lib/commonjs/scroll/index.web.js.map +1 -1
  3. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  4. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  5. package/lib/commonjs/tabs/Tabs.js +263 -63
  6. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  7. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  8. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  9. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  10. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  11. package/lib/module/scroll/index.web.js +52 -18
  12. package/lib/module/scroll/index.web.js.map +1 -1
  13. package/lib/module/scroll/scrollable.web.js +9 -3
  14. package/lib/module/scroll/scrollable.web.js.map +1 -1
  15. package/lib/module/tabs/Tabs.js +271 -65
  16. package/lib/module/tabs/Tabs.js.map +1 -1
  17. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  18. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  19. package/lib/module/tabs/expo-router/index.js +10 -0
  20. package/lib/module/tabs/expo-router/index.js.map +1 -0
  21. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  23. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  25. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  27. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  28. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  29. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  31. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  32. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  33. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  34. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  35. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  36. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  37. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  38. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  39. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  40. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  41. package/lib/typescript/module/tabs/types.d.ts +48 -4
  42. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  43. package/package.json +15 -1
  44. package/src/__tests__/Tabs.test.tsx +187 -0
  45. package/src/__tests__/optional-peer-imports.test.ts +5 -0
  46. package/src/__tests__/scroll-web.test.tsx +255 -2
  47. package/src/scroll/index.web.tsx +55 -19
  48. package/src/scroll/scrollable.web.ts +30 -8
  49. package/src/tabs/Tabs.tsx +352 -77
  50. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  51. package/src/tabs/expo-router/index.ts +8 -0
  52. package/src/tabs/types.ts +48 -4
@@ -177,12 +177,23 @@ interface ScreenProps {
177
177
  node: FakeScrollNode;
178
178
  subKey?: string;
179
179
  enabled?: boolean;
180
+ /** Drive the `'window'` sentinel instead of the node ref. */
181
+ windowTarget?: boolean;
180
182
  onBinding?: (binding: ScrollRestorationBinding) => void;
181
183
  }
182
184
 
183
- function Screen({ node, subKey, enabled, onBinding }: ScreenProps): ReactNode {
185
+ function Screen({
186
+ node,
187
+ subKey,
188
+ enabled,
189
+ windowTarget,
190
+ onBinding,
191
+ }: ScreenProps): ReactNode {
184
192
  const ref = useRef(makeHandle(node));
185
- const binding = useScrollRestoration(ref, { key: subKey, enabled });
193
+ const binding = useScrollRestoration(windowTarget ? 'window' : ref, {
194
+ key: subKey,
195
+ enabled,
196
+ });
186
197
  onBinding?.(binding);
187
198
  return null;
188
199
  }
@@ -675,6 +686,248 @@ describe('web scroll-restoration hook', () => {
675
686
  expect(later.scrollTop).toBe(2400);
676
687
  });
677
688
 
689
+ it('does not persist a PARTIAL offset when the restore is aborted mid-loop', () => {
690
+ // The corruption this closes: every loop write echoes back as a `scroll`
691
+ // event, so before suppression an interrupted restore left whatever the
692
+ // browser had clamped to in the store, and that content's remembered
693
+ // position stayed wrong for the rest of the session. A swipe that commits
694
+ // a route change mid-restore hits this every time.
695
+ const node = new FakeScrollNode();
696
+ node.growTo(FULL_CONTENT_HEIGHT);
697
+ const content = unseenContent();
698
+
699
+ harness.show({ node, content });
700
+ scrollTo(node, 3520);
701
+ harness.show({ node, content, focused: false });
702
+
703
+ // Re-show with the content still short, so the write clamps to a partial.
704
+ node.collapseLikeNavigator();
705
+ node.growTo(1500); // 621px of scroll range against a 3520 target
706
+ harness.show({ node, content });
707
+ act(() => {
708
+ frames.flushOneFrame();
709
+ node.emitScroll(); // the browser echoing our own clamped write
710
+ });
711
+ expect(node.scrollTop).toBe(1500 - 879);
712
+
713
+ // The user grabs it, which aborts the loop, and then navigates away.
714
+ act(() => {
715
+ node.emitUserTakeover('touchstart');
716
+ });
717
+ harness.show({ node, content, focused: false });
718
+
719
+ // The original offset must have survived, not the partial.
720
+ node.growTo(FULL_CONTENT_HEIGHT);
721
+ node.scrollTop = 0;
722
+ harness.show({ node, content });
723
+ act(() => {
724
+ frames.flushOneFrame();
725
+ });
726
+ expect(node.scrollTop).toBe(3520);
727
+ });
728
+
729
+ it('keeps the original target when the loop exhausts its frame budget', () => {
730
+ // Same mechanism, the other end state. Content that is short right now is
731
+ // usually a list still loading, not a permanent shrink — remembering the
732
+ // target means the position is right once the rows arrive.
733
+ const node = new FakeScrollNode();
734
+ node.growTo(FULL_CONTENT_HEIGHT);
735
+ const content = unseenContent();
736
+
737
+ harness.show({ node, content });
738
+ scrollTo(node, 3520);
739
+ harness.show({ node, content, focused: false });
740
+
741
+ node.collapseLikeNavigator();
742
+ node.growTo(1500);
743
+ harness.show({ node, content });
744
+ for (let i = 0; i < 40; i++) {
745
+ const wrote = frames.pending > 0;
746
+ act(() => {
747
+ frames.flushOneFrame();
748
+ // Only a frame that actually wrote produces an echo; a real browser
749
+ // dispatches `scroll` when something moves, not on a timer.
750
+ if (wrote) node.emitScroll();
751
+ });
752
+ }
753
+ expect(frames.pending).toBe(0);
754
+ harness.show({ node, content, focused: false });
755
+
756
+ node.growTo(FULL_CONTENT_HEIGHT);
757
+ node.scrollTop = 0;
758
+ harness.show({ node, content });
759
+ act(() => {
760
+ frames.flushOneFrame();
761
+ });
762
+ expect(node.scrollTop).toBe(3520);
763
+ });
764
+
765
+ describe('the window sentinel and a collapsing document', () => {
766
+ // Every Oxy web feed passes `'window'`. Under a TABBED navigator each tab
767
+ // stays MOUNTED and non-focused ones are `display: none`, which collapses
768
+ // the DOCUMENT — measured in Chrome 150: `documentElement.scrollHeight`
769
+ // 8040 -> 800, `scrollY` forced to 0, two `scroll` events dispatched. Blur
770
+ // is emitted from an effect that has not run yet, so the outgoing tab's
771
+ // listener is still attached when they arrive.
772
+ const DOC_VIEWPORT = 800;
773
+ let docHeight = 8040;
774
+ let scrollY = 0;
775
+
776
+ beforeEach(() => {
777
+ docHeight = 8040;
778
+ scrollY = 0;
779
+ Object.defineProperty(window, 'scrollY', {
780
+ configurable: true,
781
+ get: () => scrollY,
782
+ });
783
+ Object.defineProperty(window, 'innerHeight', {
784
+ configurable: true,
785
+ get: () => DOC_VIEWPORT,
786
+ });
787
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
788
+ configurable: true,
789
+ get: () => docHeight,
790
+ });
791
+ window.scrollTo = ((_x: number, y: number) => {
792
+ scrollY = Math.min(Math.max(0, y), Math.max(0, docHeight - DOC_VIEWPORT));
793
+ }) as typeof window.scrollTo;
794
+ });
795
+
796
+ function emitWindowScroll(): void {
797
+ act(() => {
798
+ window.dispatchEvent(new Event('scroll'));
799
+ });
800
+ }
801
+
802
+ it('does not persist the 0 a collapsing document forces', () => {
803
+ const node = new FakeScrollNode();
804
+ const content = unseenContent();
805
+
806
+ harness.show({ node, content, windowTarget: true });
807
+ // Positive control: the document is genuinely scrollable, so a 0 read
808
+ // here would be a real user position rather than a collapse artefact.
809
+ expect(document.documentElement.scrollHeight).toBeGreaterThan(
810
+ window.innerHeight,
811
+ );
812
+ scrollY = 5000;
813
+ emitWindowScroll();
814
+
815
+ // The tab switch: this screen is hidden, the document collapses to the
816
+ // viewport and the browser forces the offset to 0, dispatching `scroll`
817
+ // while this screen's listener is still attached.
818
+ docHeight = DOC_VIEWPORT;
819
+ scrollY = 0;
820
+ emitWindowScroll();
821
+
822
+ // Leaving and returning must bring back 5000, not the forced 0.
823
+ harness.show({ node, content, windowTarget: true, focused: false });
824
+ docHeight = 8040;
825
+ harness.show({ node, content, windowTarget: true });
826
+ act(() => {
827
+ frames.flushOneFrame();
828
+ });
829
+ expect(window.scrollY).toBe(5000);
830
+ });
831
+
832
+ it('does not persist a PARTIAL clamp when a shorter tab takes over the document', () => {
833
+ // The residual the collapse guard cannot see: the destination tab is
834
+ // still taller than the viewport, so `maxOffset > 0` and the offset is
835
+ // not 0 — it is the destination's MAXIMUM. Measured in Chrome 150:
836
+ // 5000 -> 2215 with `scrollHeight` 8040 -> 3015, dispatched as TWO
837
+ // identical scroll events.
838
+ const node = new FakeScrollNode();
839
+ const content = unseenContent();
840
+
841
+ harness.show({ node, content, windowTarget: true });
842
+ scrollY = 5000;
843
+ emitWindowScroll();
844
+
845
+ // The shorter tab takes over: the range shrinks and the browser parks the
846
+ // offset at the new bottom, twice.
847
+ docHeight = 3015;
848
+ scrollY = 3015 - DOC_VIEWPORT;
849
+ emitWindowScroll();
850
+ emitWindowScroll();
851
+
852
+ harness.show({ node, content, windowTarget: true, focused: false });
853
+ docHeight = 8040;
854
+ harness.show({ node, content, windowTarget: true });
855
+ act(() => {
856
+ frames.flushOneFrame();
857
+ });
858
+ expect(window.scrollY).toBe(5000);
859
+ });
860
+
861
+ it('persists a GENUINE scroll to the very bottom when nothing shrank', () => {
862
+ // The collision worth worrying about, and it does not fire: reaching the
863
+ // bottom under your own steam does not shrink the page in the same
864
+ // breath, so only one of the two conditions holds.
865
+ const node = new FakeScrollNode();
866
+ const content = unseenContent();
867
+
868
+ harness.show({ node, content, windowTarget: true });
869
+ scrollY = docHeight - DOC_VIEWPORT; // 7240, exactly at the maximum
870
+ emitWindowScroll();
871
+
872
+ harness.show({ node, content, windowTarget: true, focused: false });
873
+ scrollY = 0;
874
+ harness.show({ node, content, windowTarget: true });
875
+ act(() => {
876
+ frames.flushOneFrame();
877
+ });
878
+ expect(window.scrollY).toBe(7240);
879
+ });
880
+
881
+ it('recovers as soon as the user scrolls off the bottom after a shrink', () => {
882
+ // The suppression must not stick: while it holds, the reference range is
883
+ // not updated, so the only thing that clears it is an offset that is not
884
+ // at the maximum. Measured to recover on the first such scroll.
885
+ const node = new FakeScrollNode();
886
+ const content = unseenContent();
887
+
888
+ harness.show({ node, content, windowTarget: true });
889
+ scrollY = 7240;
890
+ emitWindowScroll();
891
+
892
+ docHeight = 4020; // something collapsed under them
893
+ scrollY = 4020 - DOC_VIEWPORT;
894
+ emitWindowScroll();
895
+
896
+ scrollY = 1000; // and now they scroll on their own
897
+ emitWindowScroll();
898
+
899
+ harness.show({ node, content, windowTarget: true, focused: false });
900
+ scrollY = 0;
901
+ docHeight = 4020;
902
+ harness.show({ node, content, windowTarget: true });
903
+ act(() => {
904
+ frames.flushOneFrame();
905
+ });
906
+ expect(window.scrollY).toBe(1000);
907
+ });
908
+
909
+ it('still persists a GENUINE scroll to the top of a scrollable document', () => {
910
+ // The other direction, and the reason `canScroll` cannot simply answer
911
+ // `false`: on a document that can scroll, 0 is where the user is.
912
+ const node = new FakeScrollNode();
913
+ const content = unseenContent();
914
+
915
+ harness.show({ node, content, windowTarget: true });
916
+ scrollY = 5000;
917
+ emitWindowScroll();
918
+ scrollY = 0; // the user scrolls back up; the document is still tall
919
+ emitWindowScroll();
920
+ expect(document.documentElement.scrollHeight).toBeGreaterThan(
921
+ window.innerHeight,
922
+ );
923
+
924
+ harness.show({ node, content, windowTarget: true, focused: false });
925
+ scrollY = 3000; // something nudges the shared scroller in between
926
+ harness.show({ node, content, windowTarget: true });
927
+ expect(window.scrollY).toBe(0);
928
+ });
929
+ });
930
+
678
931
  it('returns a binding that is safe to wire onto a list on either platform', () => {
679
932
  const node = new FakeScrollNode();
680
933
  const seen: ScrollRestorationBinding[] = [];
@@ -176,23 +176,55 @@ export function useScrollRestoration(
176
176
  // than what the scroll listener already saved live.
177
177
  let lastObservedOffset: number | null = null;
178
178
 
179
- // The 0 WE wrote, which a real browser dispatches back as a `scroll`
180
- // event (jsdom does not, so the test for this emits it explicitly).
181
- // Persisting it would record an offset the user never chose, under a key
182
- // another LIVE screen may share the two-live-entries trade content
183
- // keying makes (see `deriveScrollKey`) means a sibling showing the same
184
- // content can save a real offset between our write and its echo, and the
185
- // echo would clobber it.
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.
186
196
  let echoOffset: number | null = null;
187
197
 
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();
205
+
188
206
  const save = () => {
189
207
  const offset = scroller.getOffset();
208
+ const maxOffset = scroller.getMaxOffset();
190
209
  // Ignore a spurious 0 produced by the navigator collapsing a hidden
191
210
  // background screen: while collapsed the container cannot scroll, so
192
211
  // its `scrollTop` is forced to 0. Persisting it would clobber the good
193
212
  // offset recorded by earlier live saves (problem A). A genuine
194
213
  // scroll-to-top keeps the container scrollable and is saved normally.
195
- if (offset === 0 && !scroller.canScroll()) return;
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
+ }
196
228
  if (offset === echoOffset) {
197
229
  // Our own write coming back. Consume the arming so a real scroll to
198
230
  // the same offset later is saved normally.
@@ -201,6 +233,7 @@ export function useScrollRestoration(
201
233
  }
202
234
  echoOffset = null;
203
235
  lastObservedOffset = offset;
236
+ maxOffsetAtLastSave = maxOffset;
204
237
  store.save(scrollKey, offset);
205
238
  };
206
239
 
@@ -243,24 +276,27 @@ export function useScrollRestoration(
243
276
  // single write while it is still collapsed would be clamped to 0 and
244
277
  // never re-applied (problem B).
245
278
  //
246
- // KNOWN PROPERTY, not a bug: each of these writes echoes back as a
247
- // `scroll` event and IS persisted (only the reset arms `echoOffset`,
248
- // and `canScroll()` is always true for the `'window'` sentinel so the
249
- // collapsed-container guard never fires there). A loop that reaches
250
- // the target self-heals on its last echo. A loop that exhausts the cap
251
- // leaves the CLAMPED offset stored — which is the right thing to
252
- // remember when the content genuinely shrank, since that is as far as
253
- // the user can now scroll.
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.
254
284
  let framesLeft = RESTORE_FRAME_CAP;
255
285
  const applyOffset = () => {
256
286
  rafId = null;
257
287
  scroller.setOffset(targetOffset);
258
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;
259
296
  // Stop once the write took effect (content grew tall enough) or we
260
- // exhaust the frame budget. `getOffset` re-reads the clamped value.
297
+ // exhaust the frame budget.
261
298
  const reached =
262
- Math.abs(scroller.getOffset() - targetOffset) <=
263
- RESTORE_STICK_TOLERANCE_PX;
299
+ Math.abs(landed - targetOffset) <= RESTORE_STICK_TOLERANCE_PX;
264
300
  if (!reached && framesLeft > 0) {
265
301
  rafId = requestAnimationFrame(applyOffset);
266
302
  }
@@ -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
  }