@oxyhq/bloom 0.78.0 → 0.79.1
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/README.md +3 -3
- package/lib/commonjs/provider/index.js +11 -7
- package/lib/commonjs/provider/index.js.map +1 -1
- package/lib/commonjs/scroll/context.js +96 -0
- package/lib/commonjs/scroll/context.js.map +1 -0
- package/lib/commonjs/scroll/expo-router/index.js +133 -0
- package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
- package/lib/commonjs/scroll/index.js +85 -21
- package/lib/commonjs/scroll/index.js.map +1 -1
- package/lib/commonjs/scroll/index.web.js +184 -118
- package/lib/commonjs/scroll/index.web.js.map +1 -1
- package/lib/commonjs/scroll/scrollable.native.js +58 -0
- package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
- package/lib/commonjs/scroll/store.js +47 -25
- package/lib/commonjs/scroll/store.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +11 -9
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/module/provider/index.js +10 -6
- package/lib/module/provider/index.js.map +1 -1
- package/lib/module/scroll/context.js +90 -0
- package/lib/module/scroll/context.js.map +1 -0
- package/lib/module/scroll/expo-router/index.js +128 -0
- package/lib/module/scroll/expo-router/index.js.map +1 -0
- package/lib/module/scroll/index.js +80 -21
- package/lib/module/scroll/index.js.map +1 -1
- package/lib/module/scroll/index.web.js +181 -119
- package/lib/module/scroll/index.web.js.map +1 -1
- package/lib/module/scroll/scrollable.native.js +54 -0
- package/lib/module/scroll/scrollable.native.js.map +1 -0
- package/lib/module/scroll/store.js +47 -25
- package/lib/module/scroll/store.js.map +1 -1
- package/lib/module/theme/color-policy.js +11 -9
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/typescript/commonjs/provider/index.d.ts +7 -5
- package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
- package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
- package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
- package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
- package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/provider/index.d.ts +7 -5
- package/lib/typescript/module/provider/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/context.d.ts +14 -0
- package/lib/typescript/module/scroll/context.d.ts.map +1 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/scroll/index.d.ts +14 -23
- package/lib/typescript/module/scroll/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/index.web.d.ts +17 -19
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/module/scroll/store.d.ts +41 -22
- package/lib/typescript/module/scroll/store.d.ts.map +1 -1
- package/lib/typescript/module/scroll/types.d.ts +82 -12
- package/lib/typescript/module/scroll/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/package.json +15 -1
- package/src/__tests__/BloomProvider.web.test.tsx +8 -15
- package/src/__tests__/optional-peer-imports.test.ts +2 -2
- package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
- package/src/__tests__/scroll-native.test.tsx +465 -0
- package/src/__tests__/scroll-store.test.ts +41 -17
- package/src/__tests__/scroll-web.test.tsx +547 -111
- package/src/provider/index.tsx +10 -7
- package/src/scroll/context.tsx +107 -0
- package/src/scroll/expo-router/index.ts +140 -0
- package/src/scroll/index.ts +92 -25
- package/src/scroll/index.web.tsx +219 -162
- package/src/scroll/scrollable.native.ts +64 -0
- package/src/scroll/store.ts +47 -25
- package/src/scroll/types.ts +82 -12
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +27 -27
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +55 -55
- package/src/theme/__tests__/policy-legibility.test.ts +21 -1
- package/src/theme/color-policy.ts +13 -9
- package/lib/commonjs/provider/scroll-provider.js +0 -13
- package/lib/commonjs/provider/scroll-provider.js.map +0 -1
- package/lib/commonjs/provider/scroll-provider.web.js +0 -13
- package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
- package/lib/module/provider/scroll-provider.js +0 -16
- package/lib/module/provider/scroll-provider.js.map +0 -1
- package/lib/module/provider/scroll-provider.web.js +0 -5
- package/lib/module/provider/scroll-provider.web.js.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
- package/src/__tests__/scroll-native.test.ts +0 -25
- package/src/provider/scroll-provider.ts +0 -13
- package/src/provider/scroll-provider.web.ts +0 -2
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Exercises the NATIVE scroll-restoration hook (`scroll/index`). The jsdom
|
|
6
|
+
// environment is used only for a DOM-free React renderer host — nothing in this
|
|
7
|
+
// suite touches the DOM; the target is a plain `FlatList`-shaped ref, which is
|
|
8
|
+
// exactly what a native call site passes.
|
|
9
|
+
//
|
|
10
|
+
// The line this suite defends is narrow and easy to erase by accident:
|
|
11
|
+
// native-stack keeps a screen MOUNTED across a push/pop, so it already restores
|
|
12
|
+
// scroll for free and the hook must not interfere. What it does NOT survive is a
|
|
13
|
+
// key change — an in-screen tab swap, a genuine remount — which is the one case
|
|
14
|
+
// the hook exists for.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createContext,
|
|
18
|
+
createElement,
|
|
19
|
+
useContext,
|
|
20
|
+
useEffect,
|
|
21
|
+
useRef,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from 'react';
|
|
24
|
+
import { act } from 'react';
|
|
25
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
26
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
|
27
|
+
|
|
28
|
+
import { ScrollRestorationProvider, useScrollRestoration } from '../scroll/index';
|
|
29
|
+
import type {
|
|
30
|
+
ScreenFocusEffect,
|
|
31
|
+
ScrollRestorationBinding,
|
|
32
|
+
ScrollRouterAdapter,
|
|
33
|
+
} from '../scroll/types';
|
|
34
|
+
|
|
35
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
36
|
+
true;
|
|
37
|
+
|
|
38
|
+
// ---- A test router ---------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const ContentContext = createContext<string | null>(null);
|
|
41
|
+
const FocusContext = createContext(true);
|
|
42
|
+
|
|
43
|
+
const testAdapter: ScrollRouterAdapter = {
|
|
44
|
+
useScreenContentId: () => useContext(ContentContext),
|
|
45
|
+
useScreenFocusEffect: (effect: ScreenFocusEffect) => {
|
|
46
|
+
const focused = useContext(FocusContext);
|
|
47
|
+
useEffect(() => (focused ? effect() : undefined), [effect, focused]);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ---- requestAnimationFrame harness ----------------------------------------
|
|
52
|
+
// The native restore is deferred one frame so the incoming rows get their
|
|
53
|
+
// layout pass. Driving frames by hand keeps that deterministic.
|
|
54
|
+
|
|
55
|
+
type FrameCallback = (time: number) => void;
|
|
56
|
+
|
|
57
|
+
class FrameScheduler {
|
|
58
|
+
private queue = new Map<number, FrameCallback>();
|
|
59
|
+
private nextId = 1;
|
|
60
|
+
|
|
61
|
+
install(): void {
|
|
62
|
+
globalThis.requestAnimationFrame = ((cb: FrameCallback): number => {
|
|
63
|
+
const id = this.nextId++;
|
|
64
|
+
this.queue.set(id, cb);
|
|
65
|
+
return id;
|
|
66
|
+
}) as typeof globalThis.requestAnimationFrame;
|
|
67
|
+
globalThis.cancelAnimationFrame = (id: number): void => {
|
|
68
|
+
this.queue.delete(id);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
flushOneFrame(): void {
|
|
73
|
+
const batch = [...this.queue.entries()];
|
|
74
|
+
this.queue.clear();
|
|
75
|
+
for (const [, cb] of batch) cb(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get pending(): number {
|
|
79
|
+
return this.queue.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- A fake native list ----------------------------------------------------
|
|
84
|
+
// Records every imperative scroll it is asked to perform, so "did not scroll
|
|
85
|
+
// anything" is a claim the suite can actually check rather than infer.
|
|
86
|
+
|
|
87
|
+
class FakeList {
|
|
88
|
+
readonly scrolls: number[] = [];
|
|
89
|
+
|
|
90
|
+
scrollToOffset({ offset }: { offset: number; animated?: boolean }): void {
|
|
91
|
+
this.scrolls.push(offset);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get lastScroll(): number | undefined {
|
|
95
|
+
return this.scrolls[this.scrolls.length - 1];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scrollEvent(y: number): NativeSyntheticEvent<NativeScrollEvent> {
|
|
100
|
+
return {
|
|
101
|
+
nativeEvent: { contentOffset: { x: 0, y } },
|
|
102
|
+
} as NativeSyntheticEvent<NativeScrollEvent>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Render harness --------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
interface ScreenProps {
|
|
108
|
+
list: FakeList;
|
|
109
|
+
subKey?: string;
|
|
110
|
+
enabled?: boolean;
|
|
111
|
+
onBinding: (binding: ScrollRestorationBinding) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function Screen({ list, subKey, enabled, onBinding }: ScreenProps): ReactNode {
|
|
115
|
+
const ref = useRef(list);
|
|
116
|
+
const binding = useScrollRestoration(ref, { key: subKey, enabled });
|
|
117
|
+
onBinding(binding);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ShowOptions {
|
|
122
|
+
list: FakeList;
|
|
123
|
+
content: string | null;
|
|
124
|
+
subKey?: string;
|
|
125
|
+
enabled?: boolean;
|
|
126
|
+
focused?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class Harness {
|
|
130
|
+
readonly root: Root;
|
|
131
|
+
private readonly container: HTMLElement;
|
|
132
|
+
private binding: ScrollRestorationBinding | null = null;
|
|
133
|
+
|
|
134
|
+
constructor() {
|
|
135
|
+
this.container = document.createElement('div');
|
|
136
|
+
document.body.appendChild(this.container);
|
|
137
|
+
this.root = createRoot(this.container);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private render(children: ReactNode): void {
|
|
141
|
+
act(() => {
|
|
142
|
+
this.root.render(
|
|
143
|
+
createElement(ScrollRestorationProvider, { adapter: testAdapter, children }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
show({ content, focused = true, ...screen }: ShowOptions): void {
|
|
149
|
+
this.render(
|
|
150
|
+
createElement(
|
|
151
|
+
FocusContext.Provider,
|
|
152
|
+
{ value: focused },
|
|
153
|
+
createElement(
|
|
154
|
+
ContentContext.Provider,
|
|
155
|
+
{ value: content },
|
|
156
|
+
createElement(Screen, {
|
|
157
|
+
...screen,
|
|
158
|
+
onBinding: (binding) => {
|
|
159
|
+
this.binding = binding;
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Unmount the screen while keeping the provider (and its store) alive. */
|
|
168
|
+
unmountScreen(): void {
|
|
169
|
+
this.render(null);
|
|
170
|
+
this.binding = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Report a scroll the way a list wired to the binding would. */
|
|
174
|
+
scroll(offset: number): void {
|
|
175
|
+
const binding = this.binding;
|
|
176
|
+
if (binding === null) throw new Error('no screen mounted');
|
|
177
|
+
act(() => {
|
|
178
|
+
binding.onScroll(scrollEvent(offset));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
teardown(): void {
|
|
183
|
+
act(() => {
|
|
184
|
+
this.root.unmount();
|
|
185
|
+
});
|
|
186
|
+
this.container.remove();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let contentSeq = 0;
|
|
191
|
+
/** A content id no earlier test has used, i.e. never seen this session. */
|
|
192
|
+
function unseenContent(): string {
|
|
193
|
+
contentSeq += 1;
|
|
194
|
+
return `route-${contentSeq}?`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('native scroll-restoration hook', () => {
|
|
198
|
+
let frames: FrameScheduler;
|
|
199
|
+
let harness: Harness;
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
frames = new FrameScheduler();
|
|
203
|
+
frames.install();
|
|
204
|
+
harness = new Harness();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
harness.teardown();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('restores through scrollToOffset when the key changes while mounted', () => {
|
|
212
|
+
const list = new FakeList();
|
|
213
|
+
const content = unseenContent();
|
|
214
|
+
|
|
215
|
+
harness.show({ list, content, subKey: 'posts' });
|
|
216
|
+
act(() => {
|
|
217
|
+
frames.flushOneFrame();
|
|
218
|
+
});
|
|
219
|
+
harness.scroll(2400);
|
|
220
|
+
|
|
221
|
+
// Swap to another tab of the same screen and back.
|
|
222
|
+
harness.show({ list, content, subKey: 'media' });
|
|
223
|
+
act(() => {
|
|
224
|
+
frames.flushOneFrame();
|
|
225
|
+
});
|
|
226
|
+
harness.show({ list, content, subKey: 'posts' });
|
|
227
|
+
act(() => {
|
|
228
|
+
frames.flushOneFrame();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(list.lastScroll).toBe(2400);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('sends a never-seen key back to the top rather than leaving the last one in place', () => {
|
|
235
|
+
const list = new FakeList();
|
|
236
|
+
const content = unseenContent();
|
|
237
|
+
|
|
238
|
+
harness.show({ list, content, subKey: 'posts' });
|
|
239
|
+
act(() => {
|
|
240
|
+
frames.flushOneFrame();
|
|
241
|
+
});
|
|
242
|
+
harness.scroll(2400);
|
|
243
|
+
|
|
244
|
+
harness.show({ list, content, subKey: 'media' });
|
|
245
|
+
act(() => {
|
|
246
|
+
frames.flushOneFrame();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(list.lastScroll).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('scrolls nothing when a push/pop re-focuses the same screen', () => {
|
|
253
|
+
// The behaviour native-stack already provides. Re-applying an offset here
|
|
254
|
+
// would at best be a no-op and at worst fight a list that legitimately
|
|
255
|
+
// moved, so the hook must stay out of it entirely.
|
|
256
|
+
const list = new FakeList();
|
|
257
|
+
const content = unseenContent();
|
|
258
|
+
|
|
259
|
+
harness.show({ list, content });
|
|
260
|
+
act(() => {
|
|
261
|
+
frames.flushOneFrame();
|
|
262
|
+
});
|
|
263
|
+
harness.scroll(1800);
|
|
264
|
+
const scrollsBeforeNavigation = list.scrolls.length;
|
|
265
|
+
|
|
266
|
+
// Push a screen on top (this one blurs but stays mounted), then pop back.
|
|
267
|
+
harness.show({ list, content, focused: false });
|
|
268
|
+
act(() => {
|
|
269
|
+
frames.flushOneFrame();
|
|
270
|
+
});
|
|
271
|
+
harness.show({ list, content, focused: true });
|
|
272
|
+
act(() => {
|
|
273
|
+
frames.flushOneFrame();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(list.scrolls.length).toBe(scrollsBeforeNavigation);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('restores after a genuine remount of the same screen', () => {
|
|
280
|
+
const list = new FakeList();
|
|
281
|
+
const content = unseenContent();
|
|
282
|
+
|
|
283
|
+
harness.show({ list, content });
|
|
284
|
+
act(() => {
|
|
285
|
+
frames.flushOneFrame();
|
|
286
|
+
});
|
|
287
|
+
harness.scroll(3100);
|
|
288
|
+
|
|
289
|
+
harness.unmountScreen();
|
|
290
|
+
harness.show({ list, content });
|
|
291
|
+
act(() => {
|
|
292
|
+
frames.flushOneFrame();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(list.lastScroll).toBe(3100);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('resets when the route name is the same but the params differ', () => {
|
|
299
|
+
const list = new FakeList();
|
|
300
|
+
const cats = 'search?"q":"cats"';
|
|
301
|
+
const dogs = 'search?"q":"dogs"';
|
|
302
|
+
|
|
303
|
+
harness.show({ list, content: cats });
|
|
304
|
+
act(() => {
|
|
305
|
+
frames.flushOneFrame();
|
|
306
|
+
});
|
|
307
|
+
harness.scroll(2000);
|
|
308
|
+
|
|
309
|
+
harness.show({ list, content: dogs });
|
|
310
|
+
act(() => {
|
|
311
|
+
frames.flushOneFrame();
|
|
312
|
+
});
|
|
313
|
+
expect(list.lastScroll).toBe(0);
|
|
314
|
+
|
|
315
|
+
harness.show({ list, content: cats });
|
|
316
|
+
act(() => {
|
|
317
|
+
frames.flushOneFrame();
|
|
318
|
+
});
|
|
319
|
+
expect(list.lastScroll).toBe(2000);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('restores content already seen, whatever entry it is reached through', () => {
|
|
323
|
+
// The same rule as on web: an offset belongs to WHAT the user was looking
|
|
324
|
+
// at, so a tab press or an in-app link back to it restores.
|
|
325
|
+
const list = new FakeList();
|
|
326
|
+
const post = 'p/[id]?"id":"42"';
|
|
327
|
+
const elsewhere = unseenContent();
|
|
328
|
+
|
|
329
|
+
harness.show({ list, content: post });
|
|
330
|
+
act(() => {
|
|
331
|
+
frames.flushOneFrame();
|
|
332
|
+
});
|
|
333
|
+
harness.scroll(1500);
|
|
334
|
+
|
|
335
|
+
harness.show({ list, content: elsewhere });
|
|
336
|
+
act(() => {
|
|
337
|
+
frames.flushOneFrame();
|
|
338
|
+
});
|
|
339
|
+
expect(list.lastScroll).toBe(0);
|
|
340
|
+
|
|
341
|
+
harness.show({ list, content: post });
|
|
342
|
+
act(() => {
|
|
343
|
+
frames.flushOneFrame();
|
|
344
|
+
});
|
|
345
|
+
expect(list.lastScroll).toBe(1500);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('waits for the caller to report its rows before restoring', () => {
|
|
349
|
+
// `enabled` is the native equivalent of the web path's re-apply loop: there
|
|
350
|
+
// is no scroll event to retry from, so the caller gates on its own content.
|
|
351
|
+
const list = new FakeList();
|
|
352
|
+
const content = unseenContent();
|
|
353
|
+
|
|
354
|
+
harness.show({ list, content });
|
|
355
|
+
act(() => {
|
|
356
|
+
frames.flushOneFrame();
|
|
357
|
+
});
|
|
358
|
+
harness.scroll(2600);
|
|
359
|
+
harness.unmountScreen();
|
|
360
|
+
|
|
361
|
+
// Remount with no rows yet: nothing scheduled, nothing scrolled.
|
|
362
|
+
harness.show({ list, content, enabled: false });
|
|
363
|
+
expect(frames.pending).toBe(0);
|
|
364
|
+
const scrollsWhileEmpty = list.scrolls.length;
|
|
365
|
+
|
|
366
|
+
// Rows arrive.
|
|
367
|
+
harness.show({ list, content, enabled: true });
|
|
368
|
+
act(() => {
|
|
369
|
+
frames.flushOneFrame();
|
|
370
|
+
});
|
|
371
|
+
expect(list.scrolls.length).toBe(scrollsWhileEmpty + 1);
|
|
372
|
+
expect(list.lastScroll).toBe(2600);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('is completely inert while disabled — no save, no restore, no reset', () => {
|
|
376
|
+
const list = new FakeList();
|
|
377
|
+
const content = unseenContent();
|
|
378
|
+
|
|
379
|
+
harness.show({ list, content, enabled: false });
|
|
380
|
+
act(() => {
|
|
381
|
+
frames.flushOneFrame();
|
|
382
|
+
});
|
|
383
|
+
harness.scroll(4200);
|
|
384
|
+
expect(list.scrolls).toEqual([]);
|
|
385
|
+
|
|
386
|
+
// Nothing was recorded either, which the enabled render proves by resetting
|
|
387
|
+
// to the top instead of restoring 4200.
|
|
388
|
+
harness.show({ list, content, enabled: true });
|
|
389
|
+
act(() => {
|
|
390
|
+
frames.flushOneFrame();
|
|
391
|
+
});
|
|
392
|
+
expect(list.scrolls).toEqual([0]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('is inert when the adapter cannot identify the screen', () => {
|
|
396
|
+
const list = new FakeList();
|
|
397
|
+
|
|
398
|
+
harness.show({ list, content: null });
|
|
399
|
+
act(() => {
|
|
400
|
+
frames.flushOneFrame();
|
|
401
|
+
});
|
|
402
|
+
harness.scroll(900);
|
|
403
|
+
|
|
404
|
+
expect(list.scrolls).toEqual([]);
|
|
405
|
+
expect(frames.pending).toBe(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('ignores relayout noise the outgoing content emits under a new key', () => {
|
|
409
|
+
// A list re-keyed to new data fires onScroll(0) as it settles. Recording
|
|
410
|
+
// that against the incoming key would erase whatever the incoming key had
|
|
411
|
+
// saved, so the binding stays deaf until the effect's own write lands.
|
|
412
|
+
const list = new FakeList();
|
|
413
|
+
const content = unseenContent();
|
|
414
|
+
|
|
415
|
+
harness.show({ list, content, subKey: 'posts' });
|
|
416
|
+
act(() => {
|
|
417
|
+
frames.flushOneFrame();
|
|
418
|
+
});
|
|
419
|
+
harness.scroll(2400);
|
|
420
|
+
|
|
421
|
+
harness.show({ list, content, subKey: 'media' });
|
|
422
|
+
act(() => {
|
|
423
|
+
frames.flushOneFrame();
|
|
424
|
+
});
|
|
425
|
+
harness.scroll(700);
|
|
426
|
+
|
|
427
|
+
// Re-key back to 'posts'; the list settles at 0 BEFORE the restore frame.
|
|
428
|
+
harness.show({ list, content, subKey: 'posts' });
|
|
429
|
+
harness.scroll(0);
|
|
430
|
+
act(() => {
|
|
431
|
+
frames.flushOneFrame();
|
|
432
|
+
});
|
|
433
|
+
expect(list.lastScroll).toBe(2400);
|
|
434
|
+
|
|
435
|
+
// ...and the spurious 0 did not overwrite what 'media' had saved.
|
|
436
|
+
harness.show({ list, content, subKey: 'media' });
|
|
437
|
+
act(() => {
|
|
438
|
+
frames.flushOneFrame();
|
|
439
|
+
});
|
|
440
|
+
expect(list.lastScroll).toBe(700);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('throws outside a provider, the same as on web', () => {
|
|
444
|
+
const consoleError = jest
|
|
445
|
+
.spyOn(console, 'error')
|
|
446
|
+
.mockImplementation(() => undefined);
|
|
447
|
+
try {
|
|
448
|
+
const list = new FakeList();
|
|
449
|
+
function Bare(): ReactNode {
|
|
450
|
+
const ref = useRef(list);
|
|
451
|
+
useScrollRestoration(ref);
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
expect(() => {
|
|
455
|
+
act(() => {
|
|
456
|
+
harness.root.render(createElement(Bare));
|
|
457
|
+
});
|
|
458
|
+
}).toThrow(
|
|
459
|
+
/useScrollRestoration must be used within a <ScrollRestorationProvider>/,
|
|
460
|
+
);
|
|
461
|
+
} finally {
|
|
462
|
+
consoleError.mockRestore();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -1,36 +1,60 @@
|
|
|
1
1
|
import { ScrollOffsetStore, deriveScrollKey } from '../scroll/store';
|
|
2
2
|
|
|
3
3
|
describe('deriveScrollKey', () => {
|
|
4
|
-
it('returns null when there is no
|
|
5
|
-
expect(deriveScrollKey(
|
|
6
|
-
expect(deriveScrollKey(
|
|
4
|
+
it('returns null when there is no content to anchor against', () => {
|
|
5
|
+
expect(deriveScrollKey(null)).toBeNull();
|
|
6
|
+
expect(deriveScrollKey(null, 'feed')).toBeNull();
|
|
7
|
+
expect(deriveScrollKey('', 'feed')).toBeNull();
|
|
7
8
|
});
|
|
8
9
|
|
|
9
|
-
it('
|
|
10
|
-
expect(deriveScrollKey('
|
|
10
|
+
it('keys on the content alone when there is no sub-key', () => {
|
|
11
|
+
expect(deriveScrollKey('/@alice')).toBe('/@alice\0');
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
it('
|
|
14
|
-
|
|
14
|
+
it('separates two different contents', () => {
|
|
15
|
+
// The sharpest case is a query-only change, because that is where
|
|
16
|
+
// expo-router RECYCLES one route object: `search?q=cats` -> `search?q=dogs`
|
|
17
|
+
// keeps a single route key and swaps only params, so without the content id
|
|
18
|
+
// the two searches would share an offset.
|
|
19
|
+
expect(deriveScrollKey('search?"q":"cats"')).not.toBe(
|
|
20
|
+
deriveScrollKey('search?"q":"dogs"'),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('separates scrollables that share one content', () => {
|
|
25
|
+
const posts = deriveScrollKey('/@alice', 'posts');
|
|
26
|
+
const media = deriveScrollKey('/@alice', 'media');
|
|
27
|
+
expect(posts).not.toBe(media);
|
|
15
28
|
});
|
|
16
29
|
|
|
17
|
-
it('
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
expect(
|
|
30
|
+
it('gives ONE key to one content, whatever entry it was reached through', () => {
|
|
31
|
+
// The deliberate trade: an offset belongs to what the user was looking at,
|
|
32
|
+
// not to the history slot. That is what makes a tab press restore, and it
|
|
33
|
+
// is also why the same content in two live entries shares an offset.
|
|
34
|
+
expect(deriveScrollKey('index?')).toBe(deriveScrollKey('index?'));
|
|
22
35
|
});
|
|
23
36
|
|
|
24
|
-
it('
|
|
25
|
-
expect(deriveScrollKey('
|
|
37
|
+
it('composes a fixed two-field key so no two identities collapse', () => {
|
|
38
|
+
expect(deriveScrollKey('a')).toBe('a\0');
|
|
39
|
+
expect(deriveScrollKey('a', 'b')).toBe('a\0b');
|
|
40
|
+
expect(deriveScrollKey('a', 'b')).not.toBe(deriveScrollKey('a\0b'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('treats an empty sub-key the same as no sub-key', () => {
|
|
44
|
+
expect(deriveScrollKey('a', '')).toBe(deriveScrollKey('a'));
|
|
26
45
|
});
|
|
27
46
|
});
|
|
28
47
|
|
|
29
48
|
describe('ScrollOffsetStore', () => {
|
|
30
|
-
it('
|
|
49
|
+
it('distinguishes an unseen key from one deliberately saved at the top', () => {
|
|
50
|
+
// This is the whole reason callers ask `has` rather than `read() > 0`: both
|
|
51
|
+
// read 0, and only one of them must be reset to the top.
|
|
31
52
|
const store = new ScrollOffsetStore();
|
|
32
|
-
|
|
33
|
-
expect(store.
|
|
53
|
+
store.save('seen', 0);
|
|
54
|
+
expect(store.read('seen')).toBe(0);
|
|
55
|
+
expect(store.has('seen')).toBe(true);
|
|
56
|
+
expect(store.read('unseen')).toBe(0);
|
|
57
|
+
expect(store.has('unseen')).toBe(false);
|
|
34
58
|
});
|
|
35
59
|
|
|
36
60
|
it('saves and reads an offset back', () => {
|