@oxyhq/bloom 0.6.21 → 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.js +39 -0
- package/lib/commonjs/scroll/index.js.map +1 -0
- package/lib/commonjs/scroll/index.web.js +226 -0
- package/lib/commonjs/scroll/index.web.js.map +1 -0
- package/lib/commonjs/scroll/scrollable.web.js +69 -0
- package/lib/commonjs/scroll/scrollable.web.js.map +1 -0
- package/lib/commonjs/scroll/store.js +90 -0
- package/lib/commonjs/scroll/store.js.map +1 -0
- package/lib/commonjs/scroll/types.js +6 -0
- package/lib/commonjs/scroll/types.js.map +1 -0
- package/lib/module/scroll/index.js +34 -0
- package/lib/module/scroll/index.js.map +1 -0
- package/lib/module/scroll/index.web.js +220 -0
- package/lib/module/scroll/index.web.js.map +1 -0
- package/lib/module/scroll/scrollable.web.js +65 -0
- package/lib/module/scroll/scrollable.web.js.map +1 -0
- package/lib/module/scroll/store.js +84 -0
- package/lib/module/scroll/store.js.map +1 -0
- package/lib/module/scroll/types.js +4 -0
- package/lib/module/scroll/types.js.map +1 -0
- package/lib/typescript/commonjs/scroll/index.d.ts +27 -0
- package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/index.web.d.ts +26 -0
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +29 -0
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/store.d.ts +46 -0
- package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/types.d.ts +46 -0
- package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -0
- package/lib/typescript/module/scroll/index.d.ts +27 -0
- package/lib/typescript/module/scroll/index.d.ts.map +1 -0
- package/lib/typescript/module/scroll/index.web.d.ts +26 -0
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -0
- package/lib/typescript/module/scroll/scrollable.web.d.ts +29 -0
- package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -0
- package/lib/typescript/module/scroll/store.d.ts +46 -0
- package/lib/typescript/module/scroll/store.d.ts.map +1 -0
- package/lib/typescript/module/scroll/types.d.ts +46 -0
- package/lib/typescript/module/scroll/types.d.ts.map +1 -0
- package/package.json +22 -1
- package/src/__tests__/scroll-native.test.ts +25 -0
- package/src/__tests__/scroll-store.test.ts +85 -0
- package/src/__tests__/scroll-web.test.tsx +325 -0
- package/src/scroll/index.ts +47 -0
- package/src/scroll/index.web.tsx +253 -0
- package/src/scroll/scrollable.web.ts +92 -0
- package/src/scroll/store.ts +84 -0
- package/src/scroll/types.ts +48 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ScrollRestorationProviderProps, ScrollRestorationTarget, UseScrollRestorationOptions } from './types';
|
|
2
|
+
export type { ScrollableHandle, ScrollRestorationProviderProps, ScrollRestorationTarget, UseScrollRestorationOptions, } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Holds the per-route offset map for the subtree. One provider near the app
|
|
5
|
+
* root is enough; the store lives for the document's lifetime so offsets
|
|
6
|
+
* survive navigating away and back (including browser Back/Forward).
|
|
7
|
+
*/
|
|
8
|
+
export declare function ScrollRestorationProvider({ children, }: ScrollRestorationProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
/**
|
|
10
|
+
* Preserve and restore the scroll offset of `target` across navigation, keyed
|
|
11
|
+
* by the active route (plus an optional `options.key` for routes that host
|
|
12
|
+
* multiple scrollables).
|
|
13
|
+
*
|
|
14
|
+
* Behaviour (web):
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useScrollRestoration(target: ScrollRestorationTarget, options?: UseScrollRestorationOptions): void;
|
|
26
|
+
//# sourceMappingURL=index.web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ScrollRestorationTarget } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* A normalized read/write interface over whatever the caller registered, so
|
|
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
|
+
*/
|
|
7
|
+
export interface ResolvedScroller {
|
|
8
|
+
getOffset: () => number;
|
|
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;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build a {@link ResolvedScroller} for a target. The window sentinel reads and
|
|
25
|
+
* writes the document scroller; everything else operates on the resolved
|
|
26
|
+
* element's `scrollTop`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createScroller(target: ScrollRestorationTarget): ResolvedScroller;
|
|
29
|
+
//# sourceMappingURL=scrollable.web.d.ts.map
|
|
@@ -0,0 +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;IACpC;;;;;;;;;;OAUG;IACH,SAAS,EAAE,MAAM,OAAO,CAAC;CAC1B;AAuCD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,uBAAuB,GAAG,gBAAgB,CAyBhF"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, platform-agnostic core of the scroll-restoration primitive.
|
|
3
|
+
*
|
|
4
|
+
* This file deliberately contains NO React and NO DOM/native imports so the
|
|
5
|
+
* logic can be unit-tested in isolation and shared verbatim by the web and
|
|
6
|
+
* native barrels. The web barrel drives it with real scroll offsets; the
|
|
7
|
+
* native barrel never instantiates it (native-stack already restores scroll).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Derive the storage key for a scrollable from its owning route key and an
|
|
11
|
+
* optional caller sub-key.
|
|
12
|
+
*
|
|
13
|
+
* - `routeKey` is React Navigation's stable per-route `route.key`.
|
|
14
|
+
* - `subKey` distinguishes multiple scrollables that share a single route.
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` when there is no route key to anchor against (the scrollable
|
|
17
|
+
* is not inside a navigator), which the caller treats as "do not persist".
|
|
18
|
+
*/
|
|
19
|
+
export declare function deriveScrollKey(routeKey: string | undefined, subKey?: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* In-memory map of `scrollKey -> last-known scroll offset`.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors the semantics of Bluesky's `Map<screenKey, scrollY>`: offsets live
|
|
24
|
+
* only for the lifetime of the document/session. We never persist them — a
|
|
25
|
+
* full reload should start at the top — and we never auto-evict, because a
|
|
26
|
+
* route can be revisited via browser Forward/Back long after it blurred.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ScrollOffsetStore {
|
|
29
|
+
private readonly offsets;
|
|
30
|
+
/** Persist the offset for a key. A negative offset is clamped to 0. */
|
|
31
|
+
save(key: string, offset: number): void;
|
|
32
|
+
/**
|
|
33
|
+
* Read the saved offset for a key. Returns `0` when nothing was saved, so
|
|
34
|
+
* callers can restore unconditionally (an unseen list restores to the top).
|
|
35
|
+
*/
|
|
36
|
+
read(key: string): number;
|
|
37
|
+
/** Whether an offset was ever saved for this key. */
|
|
38
|
+
has(key: string): boolean;
|
|
39
|
+
/** Drop a saved offset (e.g. when a route is permanently removed). */
|
|
40
|
+
forget(key: string): void;
|
|
41
|
+
/** Clear every saved offset. Primarily for tests and full resets. */
|
|
42
|
+
clear(): void;
|
|
43
|
+
/** Number of distinct keys currently tracked. */
|
|
44
|
+
get size(): number;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../../src/scroll/store.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAcH;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;;GAOG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IAErD,uEAAuE;IACvE,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAIvC;;;OAGG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM;IAIzB,qDAAqD;IACrD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIzB,sEAAsE;IACtE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB,qEAAqE;IACrE,KAAK,IAAI,IAAI;IAIb,iDAAiD;IACjD,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Anything `useScrollRestoration` knows how to read/write a vertical scroll
|
|
4
|
+
* offset from. The hook accepts a ref to one of these:
|
|
5
|
+
*
|
|
6
|
+
* - a React Native `ScrollView` / `FlatList` ref (which on web is backed by a
|
|
7
|
+
* DOM node and exposes `getScrollableNode()`),
|
|
8
|
+
* - a raw DOM element ref (when a component renders its own scroll container),
|
|
9
|
+
* - the literal `'window'` sentinel, for the rare layout where the list IS the
|
|
10
|
+
* window scroller (matches Bluesky's default).
|
|
11
|
+
*
|
|
12
|
+
* Native never reads any of these — the native hook is a no-op — so the type is
|
|
13
|
+
* intentionally permissive rather than coupled to a specific RN class.
|
|
14
|
+
*/
|
|
15
|
+
export interface ScrollableHandle {
|
|
16
|
+
/** Imperative scroll API exposed by RN scrollables. */
|
|
17
|
+
scrollTo?: (options: {
|
|
18
|
+
x?: number;
|
|
19
|
+
y?: number;
|
|
20
|
+
animated?: boolean;
|
|
21
|
+
}) => void;
|
|
22
|
+
/** Web/RNW path: returns the underlying DOM node. */
|
|
23
|
+
getScrollableNode?: () => unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The accepted ref shapes. `'window'` is a sentinel for the document scroller.
|
|
27
|
+
*/
|
|
28
|
+
export type ScrollRestorationTarget = RefObject<ScrollableHandle | null> | RefObject<unknown> | 'window';
|
|
29
|
+
export interface UseScrollRestorationOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Sub-key to disambiguate multiple scrollables that live on the same route
|
|
32
|
+
* (e.g. the tabs of a profile screen). Combined with the active route key to
|
|
33
|
+
* form the storage key. Omit when a route has a single scrollable.
|
|
34
|
+
*/
|
|
35
|
+
key?: string;
|
|
36
|
+
/**
|
|
37
|
+
* When `false`, the hook is inert (saves and restores are skipped). Useful to
|
|
38
|
+
* gate restoration behind a feature flag without changing call sites.
|
|
39
|
+
* Defaults to `true`.
|
|
40
|
+
*/
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface ScrollRestorationProviderProps {
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/scroll/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAElD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uDAAuD;IACvD,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAC/B,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAClC,SAAS,CAAC,OAAO,CAAC,GAClB,QAAQ,CAAC;AAEb,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,EAAE,SAAS,CAAC;CACrB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/bloom",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -535,6 +535,22 @@
|
|
|
535
535
|
"default": "./lib/commonjs/fonts/index.js"
|
|
536
536
|
}
|
|
537
537
|
},
|
|
538
|
+
"./scroll": {
|
|
539
|
+
"react-native": "./src/scroll/index.ts",
|
|
540
|
+
"browser": {
|
|
541
|
+
"types": "./lib/typescript/module/scroll/index.web.d.ts",
|
|
542
|
+
"import": "./lib/module/scroll/index.web.js",
|
|
543
|
+
"require": "./lib/commonjs/scroll/index.web.js"
|
|
544
|
+
},
|
|
545
|
+
"import": {
|
|
546
|
+
"types": "./lib/typescript/module/scroll/index.d.ts",
|
|
547
|
+
"default": "./lib/module/scroll/index.js"
|
|
548
|
+
},
|
|
549
|
+
"require": {
|
|
550
|
+
"types": "./lib/typescript/commonjs/scroll/index.d.ts",
|
|
551
|
+
"default": "./lib/commonjs/scroll/index.js"
|
|
552
|
+
}
|
|
553
|
+
},
|
|
538
554
|
"./package.json": "./package.json"
|
|
539
555
|
},
|
|
540
556
|
"files": [
|
|
@@ -615,6 +631,7 @@
|
|
|
615
631
|
}
|
|
616
632
|
},
|
|
617
633
|
"devDependencies": {
|
|
634
|
+
"@react-navigation/native": "^7.0.0",
|
|
618
635
|
"@storybook/addon-docs": "^10",
|
|
619
636
|
"@storybook/react-vite": "^10",
|
|
620
637
|
"@testing-library/react-native": "^13.3.3",
|
|
@@ -645,6 +662,7 @@
|
|
|
645
662
|
"vite": "^7"
|
|
646
663
|
},
|
|
647
664
|
"peerDependencies": {
|
|
665
|
+
"@react-navigation/native": ">=6.0.0",
|
|
648
666
|
"expo": "*",
|
|
649
667
|
"expo-font": "*",
|
|
650
668
|
"react": ">=18.0.0",
|
|
@@ -658,6 +676,9 @@
|
|
|
658
676
|
"sonner-native": ">=0.17.0"
|
|
659
677
|
},
|
|
660
678
|
"peerDependenciesMeta": {
|
|
679
|
+
"@react-navigation/native": {
|
|
680
|
+
"optional": true
|
|
681
|
+
},
|
|
661
682
|
"expo": {
|
|
662
683
|
"optional": true
|
|
663
684
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScrollRestorationProvider,
|
|
3
|
+
useScrollRestoration,
|
|
4
|
+
} from '../scroll/index';
|
|
5
|
+
|
|
6
|
+
// The native barrel is a deliberate no-op: native-stack already preserves
|
|
7
|
+
// scroll. These tests pin that contract so a future refactor can't silently
|
|
8
|
+
// turn the native path into something that touches the DOM or throws.
|
|
9
|
+
|
|
10
|
+
describe('native scroll-restoration barrel', () => {
|
|
11
|
+
it('ScrollRestorationProvider renders its children unchanged', () => {
|
|
12
|
+
const child = { sentinel: true } as unknown as React.ReactElement;
|
|
13
|
+
expect(ScrollRestorationProvider({ children: child })).toBe(child);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('useScrollRestoration is a no-op for any target/options', () => {
|
|
17
|
+
const ref = { current: null };
|
|
18
|
+
expect(() => useScrollRestoration(ref)).not.toThrow();
|
|
19
|
+
expect(() => useScrollRestoration('window')).not.toThrow();
|
|
20
|
+
expect(() =>
|
|
21
|
+
useScrollRestoration(ref, { key: 'feed', enabled: true }),
|
|
22
|
+
).not.toThrow();
|
|
23
|
+
expect(useScrollRestoration(ref)).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ScrollOffsetStore, deriveScrollKey } from '../scroll/store';
|
|
2
|
+
|
|
3
|
+
describe('deriveScrollKey', () => {
|
|
4
|
+
it('returns null when there is no route key', () => {
|
|
5
|
+
expect(deriveScrollKey(undefined)).toBeNull();
|
|
6
|
+
expect(deriveScrollKey(undefined, 'feed')).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns the route key verbatim when no sub-key is given', () => {
|
|
10
|
+
expect(deriveScrollKey('Home-abc')).toBe('Home-abc');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('treats an empty sub-key the same as no sub-key', () => {
|
|
14
|
+
expect(deriveScrollKey('Home-abc', '')).toBe('Home-abc');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('composes route key and sub-key for multiple lists on one route', () => {
|
|
18
|
+
const a = deriveScrollKey('Profile-xyz', 'posts');
|
|
19
|
+
const b = deriveScrollKey('Profile-xyz', 'media');
|
|
20
|
+
expect(a).not.toBe(b);
|
|
21
|
+
expect(a).toBe('Profile-xyz\0posts');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('does not collide across routes that share a sub-key', () => {
|
|
25
|
+
expect(deriveScrollKey('A', 'feed')).not.toBe(deriveScrollKey('B', 'feed'));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('ScrollOffsetStore', () => {
|
|
30
|
+
it('reads 0 for an unseen key so callers can restore unconditionally', () => {
|
|
31
|
+
const store = new ScrollOffsetStore();
|
|
32
|
+
expect(store.read('missing')).toBe(0);
|
|
33
|
+
expect(store.has('missing')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('saves and reads an offset back', () => {
|
|
37
|
+
const store = new ScrollOffsetStore();
|
|
38
|
+
store.save('k', 420);
|
|
39
|
+
expect(store.read('k')).toBe(420);
|
|
40
|
+
expect(store.has('k')).toBe(true);
|
|
41
|
+
expect(store.size).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps offsets independent per key', () => {
|
|
45
|
+
const store = new ScrollOffsetStore();
|
|
46
|
+
store.save('a', 100);
|
|
47
|
+
store.save('b', 250);
|
|
48
|
+
expect(store.read('a')).toBe(100);
|
|
49
|
+
expect(store.read('b')).toBe(250);
|
|
50
|
+
expect(store.size).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('overwrites the offset on subsequent saves', () => {
|
|
54
|
+
const store = new ScrollOffsetStore();
|
|
55
|
+
store.save('k', 100);
|
|
56
|
+
store.save('k', 300);
|
|
57
|
+
expect(store.read('k')).toBe(300);
|
|
58
|
+
expect(store.size).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('clamps negative offsets to 0 (e.g. rubber-band overscroll)', () => {
|
|
62
|
+
const store = new ScrollOffsetStore();
|
|
63
|
+
store.save('k', -50);
|
|
64
|
+
expect(store.read('k')).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('forgets a single key', () => {
|
|
68
|
+
const store = new ScrollOffsetStore();
|
|
69
|
+
store.save('a', 1);
|
|
70
|
+
store.save('b', 2);
|
|
71
|
+
store.forget('a');
|
|
72
|
+
expect(store.has('a')).toBe(false);
|
|
73
|
+
expect(store.read('b')).toBe(2);
|
|
74
|
+
expect(store.size).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('clears every key', () => {
|
|
78
|
+
const store = new ScrollOffsetStore();
|
|
79
|
+
store.save('a', 1);
|
|
80
|
+
store.save('b', 2);
|
|
81
|
+
store.clear();
|
|
82
|
+
expect(store.size).toBe(0);
|
|
83
|
+
expect(store.has('a')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -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
|
+
});
|