@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.
Files changed (105) hide show
  1. package/README.md +3 -3
  2. package/lib/commonjs/provider/index.js +11 -7
  3. package/lib/commonjs/provider/index.js.map +1 -1
  4. package/lib/commonjs/scroll/context.js +96 -0
  5. package/lib/commonjs/scroll/context.js.map +1 -0
  6. package/lib/commonjs/scroll/expo-router/index.js +133 -0
  7. package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
  8. package/lib/commonjs/scroll/index.js +85 -21
  9. package/lib/commonjs/scroll/index.js.map +1 -1
  10. package/lib/commonjs/scroll/index.web.js +184 -118
  11. package/lib/commonjs/scroll/index.web.js.map +1 -1
  12. package/lib/commonjs/scroll/scrollable.native.js +58 -0
  13. package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
  14. package/lib/commonjs/scroll/store.js +47 -25
  15. package/lib/commonjs/scroll/store.js.map +1 -1
  16. package/lib/commonjs/theme/color-policy.js +11 -9
  17. package/lib/commonjs/theme/color-policy.js.map +1 -1
  18. package/lib/module/provider/index.js +10 -6
  19. package/lib/module/provider/index.js.map +1 -1
  20. package/lib/module/scroll/context.js +90 -0
  21. package/lib/module/scroll/context.js.map +1 -0
  22. package/lib/module/scroll/expo-router/index.js +128 -0
  23. package/lib/module/scroll/expo-router/index.js.map +1 -0
  24. package/lib/module/scroll/index.js +80 -21
  25. package/lib/module/scroll/index.js.map +1 -1
  26. package/lib/module/scroll/index.web.js +181 -119
  27. package/lib/module/scroll/index.web.js.map +1 -1
  28. package/lib/module/scroll/scrollable.native.js +54 -0
  29. package/lib/module/scroll/scrollable.native.js.map +1 -0
  30. package/lib/module/scroll/store.js +47 -25
  31. package/lib/module/scroll/store.js.map +1 -1
  32. package/lib/module/theme/color-policy.js +11 -9
  33. package/lib/module/theme/color-policy.js.map +1 -1
  34. package/lib/typescript/commonjs/provider/index.d.ts +7 -5
  35. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
  37. package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
  38. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
  39. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
  41. package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
  43. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
  45. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
  47. package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
  49. package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  51. package/lib/typescript/module/provider/index.d.ts +7 -5
  52. package/lib/typescript/module/provider/index.d.ts.map +1 -1
  53. package/lib/typescript/module/scroll/context.d.ts +14 -0
  54. package/lib/typescript/module/scroll/context.d.ts.map +1 -0
  55. package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
  56. package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
  57. package/lib/typescript/module/scroll/index.d.ts +14 -23
  58. package/lib/typescript/module/scroll/index.d.ts.map +1 -1
  59. package/lib/typescript/module/scroll/index.web.d.ts +17 -19
  60. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  61. package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
  62. package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
  63. package/lib/typescript/module/scroll/store.d.ts +41 -22
  64. package/lib/typescript/module/scroll/store.d.ts.map +1 -1
  65. package/lib/typescript/module/scroll/types.d.ts +82 -12
  66. package/lib/typescript/module/scroll/types.d.ts.map +1 -1
  67. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  68. package/package.json +15 -1
  69. package/src/__tests__/BloomProvider.web.test.tsx +8 -15
  70. package/src/__tests__/optional-peer-imports.test.ts +2 -2
  71. package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
  72. package/src/__tests__/scroll-native.test.tsx +465 -0
  73. package/src/__tests__/scroll-store.test.ts +41 -17
  74. package/src/__tests__/scroll-web.test.tsx +547 -111
  75. package/src/provider/index.tsx +10 -7
  76. package/src/scroll/context.tsx +107 -0
  77. package/src/scroll/expo-router/index.ts +140 -0
  78. package/src/scroll/index.ts +92 -25
  79. package/src/scroll/index.web.tsx +219 -162
  80. package/src/scroll/scrollable.native.ts +64 -0
  81. package/src/scroll/store.ts +47 -25
  82. package/src/scroll/types.ts +82 -12
  83. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +27 -27
  84. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +55 -55
  85. package/src/theme/__tests__/policy-legibility.test.ts +21 -1
  86. package/src/theme/color-policy.ts +13 -9
  87. package/lib/commonjs/provider/scroll-provider.js +0 -13
  88. package/lib/commonjs/provider/scroll-provider.js.map +0 -1
  89. package/lib/commonjs/provider/scroll-provider.web.js +0 -13
  90. package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
  91. package/lib/module/provider/scroll-provider.js +0 -16
  92. package/lib/module/provider/scroll-provider.js.map +0 -1
  93. package/lib/module/provider/scroll-provider.web.js +0 -5
  94. package/lib/module/provider/scroll-provider.web.js.map +0 -1
  95. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
  96. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
  97. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
  98. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
  99. package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
  100. package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
  101. package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
  102. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
  103. package/src/__tests__/scroll-native.test.ts +0 -25
  104. package/src/provider/scroll-provider.ts +0 -13
  105. 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 route key', () => {
5
- expect(deriveScrollKey(undefined)).toBeNull();
6
- expect(deriveScrollKey(undefined, 'feed')).toBeNull();
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('returns the route key verbatim when no sub-key is given', () => {
10
- expect(deriveScrollKey('Home-abc')).toBe('Home-abc');
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('treats an empty sub-key the same as no sub-key', () => {
14
- expect(deriveScrollKey('Home-abc', '')).toBe('Home-abc');
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('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');
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('does not collide across routes that share a sub-key', () => {
25
- expect(deriveScrollKey('A', 'feed')).not.toBe(deriveScrollKey('B', 'feed'));
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('reads 0 for an unseen key so callers can restore unconditionally', () => {
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
- expect(store.read('missing')).toBe(0);
33
- expect(store.has('missing')).toBe(false);
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', () => {