@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.
- package/lib/commonjs/scroll/index.web.js +52 -18
- package/lib/commonjs/scroll/index.web.js.map +1 -1
- package/lib/commonjs/scroll/scrollable.web.js +9 -3
- package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
- package/lib/commonjs/tabs/Tabs.js +263 -63
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
- package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/commonjs/tabs/expo-router/index.js +13 -0
- package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
- package/lib/module/scroll/index.web.js +52 -18
- package/lib/module/scroll/index.web.js.map +1 -1
- package/lib/module/scroll/scrollable.web.js +9 -3
- package/lib/module/scroll/scrollable.web.js.map +1 -1
- package/lib/module/tabs/Tabs.js +271 -65
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
- package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/module/tabs/expo-router/index.js +10 -0
- package/lib/module/tabs/expo-router/index.js.map +1 -0
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
- package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
- package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
- package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/tabs/types.d.ts +48 -4
- package/lib/typescript/module/tabs/types.d.ts.map +1 -1
- package/package.json +15 -1
- package/src/__tests__/Tabs.test.tsx +187 -0
- package/src/__tests__/optional-peer-imports.test.ts +5 -0
- package/src/__tests__/scroll-web.test.tsx +255 -2
- package/src/scroll/index.web.tsx +55 -19
- package/src/scroll/scrollable.web.ts +30 -8
- package/src/tabs/Tabs.tsx +352 -77
- package/src/tabs/expo-router/RouterTabs.tsx +268 -0
- package/src/tabs/expo-router/index.ts +8 -0
- 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({
|
|
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(
|
|
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[] = [];
|
package/src/scroll/index.web.tsx
CHANGED
|
@@ -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
|
|
180
|
-
// event (jsdom does not, so the
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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 &&
|
|
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
|
-
//
|
|
247
|
-
// `
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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.
|
|
297
|
+
// exhaust the frame budget.
|
|
261
298
|
const reached =
|
|
262
|
-
Math.abs(
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
getMaxOffset: () => {
|
|
88
109
|
const element = resolveElement(target);
|
|
89
|
-
|
|
110
|
+
if (!element) return 0;
|
|
111
|
+
return Math.max(0, element.scrollHeight - element.clientHeight);
|
|
90
112
|
},
|
|
91
113
|
};
|
|
92
114
|
}
|