cursedbelt 5.20.0 → 5.22.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/dist/react/hooks/useScrollMemory.d.ts +54 -0
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -0
- package/dist/react/hooks/useScrollMemory.js +212 -0
- package/dist/react/hooks/useScrollMemory.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/usePagedList.d.ts +24 -2
- package/dist/react/usePagedList.d.ts.map +1 -1
- package/dist/react/usePagedList.js +16 -3
- package/dist/react/usePagedList.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/core.css +2 -2
- package/package.json +2 -2
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +30 -0
- package/scripts/guardrailsEnforce.ts +39 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +227 -0
- package/src/react/hooks/useScrollMemory.ts +245 -0
- package/src/react/index.ts +5 -0
- package/src/react/usePagedList.spec.tsx +78 -0
- package/src/react/usePagedList.ts +35 -3
- package/src/styles-areas/core.css +2 -2
|
@@ -1417,6 +1417,36 @@ describe('no-raw-nav', () => {
|
|
|
1417
1417
|
});
|
|
1418
1418
|
});
|
|
1419
1419
|
|
|
1420
|
+
describe('no-hand-rolled-scroll-memory', () => {
|
|
1421
|
+
test('🔴 flags taking restoration off the browser — the first line of flix\'s hand-rolled copy', () => {
|
|
1422
|
+
expect(
|
|
1423
|
+
check('no-hand-rolled-scroll-memory', `if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";`),
|
|
1424
|
+
).toHaveLength(1);
|
|
1425
|
+
expect(check('no-hand-rolled-scroll-memory', `history.scrollRestoration='manual'`)).toHaveLength(1);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
test('passes reading it, comparing it, prose, and the belt\'s own hook', () => {
|
|
1429
|
+
expect(check('no-hand-rolled-scroll-memory', `const was = history.scrollRestoration;`)).toHaveLength(0);
|
|
1430
|
+
expect(check('no-hand-rolled-scroll-memory', `if (history.scrollRestoration === "auto") log();`)).toHaveLength(0);
|
|
1431
|
+
expect(check('no-hand-rolled-scroll-memory', `// history.scrollRestoration = "manual" fights the hook`)).toHaveLength(0);
|
|
1432
|
+
expect(
|
|
1433
|
+
RULES['no-hand-rolled-scroll-memory']?.check(
|
|
1434
|
+
'src/react/hooks/useScrollMemory.ts',
|
|
1435
|
+
`window.history.scrollRestoration = 'manual';`,
|
|
1436
|
+
),
|
|
1437
|
+
).toHaveLength(0);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
test('passes one with a stated reason in the block above', () => {
|
|
1441
|
+
expect(
|
|
1442
|
+
check(
|
|
1443
|
+
'no-hand-rolled-scroll-memory',
|
|
1444
|
+
`// guardrails-ignore no-hand-rolled-scroll-memory: a canvas app with no page to scroll\nhistory.scrollRestoration = "manual";`,
|
|
1445
|
+
),
|
|
1446
|
+
).toHaveLength(0);
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1420
1450
|
describe('no-raw-lazy', () => {
|
|
1421
1451
|
test('🔴 flags React.lazy — the Player chunk flix 404ed on from a pre-deploy tab', () => {
|
|
1422
1452
|
expect(
|
|
@@ -122,6 +122,9 @@
|
|
|
122
122
|
// no-raw-popover — owner, 2026-09-27: no hand-rolled `{open && <div className="absolute …">}`
|
|
123
123
|
// popup; they never close when you tap away. Use `Popover` (`unstyled` for your own look)
|
|
124
124
|
// or `DropdownMenu`. See the rule's own block below.
|
|
125
|
+
|
|
126
|
+
// no-hand-rolled-scroll-memory — 2026-09-28, flix: Back landed at the top of a long grid. An
|
|
127
|
+
// app setting `history.scrollRestoration` is hand-rolling what `useScrollMemory` does.
|
|
125
128
|
//
|
|
126
129
|
// no-fast-tooltip — owner, 2026-09-08: a hover-only hint must REST before it opens.
|
|
127
130
|
// Flags a hover delay written as a numeric literal below 500ms (`delayDuration={120}`,
|
|
@@ -1922,6 +1925,41 @@ const noRawLazy: Rule = {
|
|
|
1922
1925
|
},
|
|
1923
1926
|
};
|
|
1924
1927
|
|
|
1928
|
+
// ── no-hand-rolled-scroll-memory ────────────────────────────────────────────
|
|
1929
|
+
// 2026-09-28, a household viewer on flix, from a phone: Back from a film landed at the top of a
|
|
1930
|
+
// grid they had scrolled 30,000px down — *"my thumb is exhausted and I've just looked at 2 movies
|
|
1931
|
+
// starting with 'a'"*. flix fixed it by hand that morning, and the same bug was in roms, family and
|
|
1932
|
+
// music, each of which would have grown its own copy. `useScrollMemory` (cursedbelt/react) is the
|
|
1933
|
+
// one copy: per history entry, any router, the window or a ScrollArea.
|
|
1934
|
+
//
|
|
1935
|
+
// The signal is exact: the first line of any hand-rolled scroll memory takes restoration off the
|
|
1936
|
+
// browser — `history.scrollRestoration = "manual"` — because the browser's own restore fights
|
|
1937
|
+
// it. The belt's `hooks/useScrollMemory.ts` is that line's one legitimate home, and specs are exempt.
|
|
1938
|
+
const SCROLL_RESTORATION_RE = /\bscrollRestoration\s*=(?!=)/g;
|
|
1939
|
+
const SCROLL_MEMORY_IGNORE_RE = /guardrails-ignore\s+no-hand-rolled-scroll-memory\s*:/;
|
|
1940
|
+
|
|
1941
|
+
const noHandRolledScrollMemory: Rule = {
|
|
1942
|
+
name: "no-hand-rolled-scroll-memory",
|
|
1943
|
+
description:
|
|
1944
|
+
"Setting `history.scrollRestoration` is the first line of a hand-rolled scroll memory. Use `useScrollMemory` from `cursedbelt/react`: it keeps each history entry's offset for the window or a ScrollArea, waits for a list to grow back, and yields to the viewer's own scrolling.",
|
|
1945
|
+
check(filePath, source) {
|
|
1946
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1947
|
+
if (/(^|\/)src\/react\/hooks\/useScrollMemory\.ts$/.test(normalized)) return [];
|
|
1948
|
+
if (isTestSource(normalized)) return [];
|
|
1949
|
+
const scan = blankNonMarkupText(source).split("\n");
|
|
1950
|
+
const raws = source.split("\n");
|
|
1951
|
+
const out: Violation[] = [];
|
|
1952
|
+
for (const [i, line] of scan.entries()) {
|
|
1953
|
+
SCROLL_RESTORATION_RE.lastIndex = 0;
|
|
1954
|
+
if (!SCROLL_RESTORATION_RE.test(line)) continue;
|
|
1955
|
+
const raw = raws[i] ?? "";
|
|
1956
|
+
if (SCROLL_MEMORY_IGNORE_RE.test(raw) || SCROLL_MEMORY_IGNORE_RE.test(blockAbove(raws, i + 1))) continue;
|
|
1957
|
+
out.push({ file: filePath, line: i + 1, snippet: raw.trim().slice(0, 160) });
|
|
1958
|
+
}
|
|
1959
|
+
return out;
|
|
1960
|
+
},
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1925
1963
|
// ── no-raw-popover ──────────────────────────────────────────────────────────
|
|
1926
1964
|
// Owner report, 2026-09-27, on an iPhone: "many popup menu places don't shut when you
|
|
1927
1965
|
// click off of them". flix's subtitle menu, music's folder menu — each a hand-rolled
|
|
@@ -2126,6 +2164,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
2126
2164
|
[noRawNav.name]: noRawNav,
|
|
2127
2165
|
[noRawLazy.name]: noRawLazy,
|
|
2128
2166
|
[noRawPopover.name]: noRawPopover,
|
|
2167
|
+
[noHandRolledScrollMemory.name]: noHandRolledScrollMemory,
|
|
2129
2168
|
};
|
|
2130
2169
|
|
|
2131
2170
|
/**
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { StrictMode } from 'react';
|
|
4
|
+
import { useScrollMemory } from './useScrollMemory.js';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* happy-dom lays nothing out, so the scroller is a fake with the three numbers the hook reads —
|
|
10
|
+
* which is the right level anyway: the hook's whole job is WHEN to write them.
|
|
11
|
+
*/
|
|
12
|
+
function fakeScroller(height: number, viewport = 800) {
|
|
13
|
+
const el = document.createElement('div');
|
|
14
|
+
let top = 0;
|
|
15
|
+
let scrollHeight = height;
|
|
16
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
17
|
+
configurable: true,
|
|
18
|
+
get: () => top,
|
|
19
|
+
set: (y: number) => {
|
|
20
|
+
top = Math.max(0, Math.min(y, scrollHeight - viewport));
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => scrollHeight });
|
|
24
|
+
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => viewport });
|
|
25
|
+
document.body.append(el);
|
|
26
|
+
return {
|
|
27
|
+
el,
|
|
28
|
+
/** The viewer scrolls: the offset moves and the element says so. */
|
|
29
|
+
scrollTo(y: number) {
|
|
30
|
+
el.scrollTop = y;
|
|
31
|
+
fireEvent.scroll(el);
|
|
32
|
+
},
|
|
33
|
+
/** The content changes height; like a browser, a shrink clamps the offset (no event yet). */
|
|
34
|
+
grow(to: number) {
|
|
35
|
+
scrollHeight = to;
|
|
36
|
+
top = Math.max(0, Math.min(top, to - viewport));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Shell({ at, el, enabled }: { at: string; el: HTMLElement; enabled?: boolean }) {
|
|
42
|
+
useScrollMemory(at, { scroller: () => el, enabled });
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A forward navigation: a new entry, as every app's `navigate` makes one. */
|
|
47
|
+
function push(path: string) {
|
|
48
|
+
window.history.pushState({}, '', path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const frames = (n: number) =>
|
|
52
|
+
act(async () => {
|
|
53
|
+
for (let i = 0; i < n; i++) await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Back, as the hook sees it: the entry that was current before comes back WITH ITS STATE.
|
|
58
|
+
* happy-dom's `history.back()` does not restore `state`, so the state is put back by hand.
|
|
59
|
+
*/
|
|
60
|
+
function back(to: { state: unknown; path: string }) {
|
|
61
|
+
window.history.replaceState(to.state, '', to.path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
window.history.replaceState(null, '', '/');
|
|
66
|
+
window.sessionStorage.clear();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('useScrollMemory', () => {
|
|
70
|
+
test('Back returns to the offset the list was left at; the detail opened at the top', () => {
|
|
71
|
+
const s = fakeScroller(20_000);
|
|
72
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
73
|
+
s.scrollTo(9_000);
|
|
74
|
+
const list = { state: window.history.state, path: '/' };
|
|
75
|
+
|
|
76
|
+
push('/item/7');
|
|
77
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
78
|
+
expect(s.el.scrollTop).toBe(0); // a fresh arrival — not 9,000px down the item's page
|
|
79
|
+
|
|
80
|
+
back(list);
|
|
81
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
82
|
+
expect(s.el.scrollTop).toBe(9_000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a list that is still growing is waited for, and lands once it can hold the offset', async () => {
|
|
86
|
+
const s = fakeScroller(20_000);
|
|
87
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
88
|
+
s.scrollTo(15_000);
|
|
89
|
+
const list = { state: window.history.state, path: '/' };
|
|
90
|
+
push('/item/7');
|
|
91
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
92
|
+
|
|
93
|
+
s.grow(3_000); // only the first page is back yet
|
|
94
|
+
back(list);
|
|
95
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
96
|
+
expect(s.el.scrollTop).toBe(2_200); // as far as it goes — which asks the list for more
|
|
97
|
+
await frames(2);
|
|
98
|
+
s.grow(20_000);
|
|
99
|
+
await frames(2);
|
|
100
|
+
expect(s.el.scrollTop).toBe(15_000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the viewer taking the page back cancels a restore that is still waiting', async () => {
|
|
104
|
+
const s = fakeScroller(20_000);
|
|
105
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
106
|
+
s.scrollTo(15_000);
|
|
107
|
+
const list = { state: window.history.state, path: '/' };
|
|
108
|
+
push('/item/7');
|
|
109
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
110
|
+
|
|
111
|
+
s.grow(3_000);
|
|
112
|
+
back(list);
|
|
113
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
114
|
+
fireEvent.touchStart(window);
|
|
115
|
+
s.scrollTo(500);
|
|
116
|
+
s.grow(20_000);
|
|
117
|
+
await frames(3);
|
|
118
|
+
expect(s.el.scrollTop).toBe(500);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('leaving mid-restore files the TARGET, not the shallow offset it had reached', async () => {
|
|
122
|
+
const s = fakeScroller(20_000);
|
|
123
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
124
|
+
s.scrollTo(15_000);
|
|
125
|
+
const list = { state: window.history.state, path: '/' };
|
|
126
|
+
push('/item/7');
|
|
127
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
128
|
+
const item = { state: window.history.state, path: '/item/7' };
|
|
129
|
+
|
|
130
|
+
s.grow(3_000);
|
|
131
|
+
back(list);
|
|
132
|
+
rerender(<Shell at='/' el={s.el} />); // restore pending at 2,200
|
|
133
|
+
window.history.replaceState(item.state, '', item.path); // Forward, before it landed
|
|
134
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
135
|
+
|
|
136
|
+
s.grow(20_000);
|
|
137
|
+
back(list);
|
|
138
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
139
|
+
expect(s.el.scrollTop).toBe(15_000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a URL replaced in place (a filter) is the same entry, and its scroll is left alone', async () => {
|
|
143
|
+
const s = fakeScroller(20_000);
|
|
144
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
145
|
+
s.scrollTo(4_000);
|
|
146
|
+
// The filter narrows the list and the browser clamps the offset; the new results then
|
|
147
|
+
// stream in. Treated as an arrival, the old 4,000 would be "restored" into a different list.
|
|
148
|
+
s.grow(1_500);
|
|
149
|
+
window.history.replaceState(window.history.state, '', '/?genre=drama');
|
|
150
|
+
rerender(<Shell at='/?genre=drama' el={s.el} />);
|
|
151
|
+
s.grow(20_000);
|
|
152
|
+
await frames(3);
|
|
153
|
+
expect(s.el.scrollTop).toBe(700);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('two trips into the same list keep two positions — memory is per entry, not per URL', () => {
|
|
157
|
+
const s = fakeScroller(20_000);
|
|
158
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
159
|
+
s.scrollTo(1_000);
|
|
160
|
+
const first = { state: window.history.state, path: '/' };
|
|
161
|
+
push('/'); // the same list, arrived at again
|
|
162
|
+
rerender(<Shell at='/?again' el={s.el} />);
|
|
163
|
+
expect(s.el.scrollTop).toBe(0);
|
|
164
|
+
s.scrollTo(6_000);
|
|
165
|
+
const second = { state: window.history.state, path: '/' };
|
|
166
|
+
|
|
167
|
+
back(first);
|
|
168
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
169
|
+
expect(s.el.scrollTop).toBe(1_000);
|
|
170
|
+
back(second);
|
|
171
|
+
rerender(<Shell at='/?again' el={s.el} />);
|
|
172
|
+
expect(s.el.scrollTop).toBe(6_000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('enabled: false leaves the offset beneath a full-screen surface alone', () => {
|
|
176
|
+
const s = fakeScroller(20_000);
|
|
177
|
+
const { rerender } = render(<Shell at='/film/1' el={s.el} />);
|
|
178
|
+
s.scrollTo(700);
|
|
179
|
+
const film = { state: window.history.state, path: '/film/1' };
|
|
180
|
+
push('/watch/1');
|
|
181
|
+
rerender(<Shell at='/watch/1' el={s.el} enabled={false} />);
|
|
182
|
+
expect(s.el.scrollTop).toBe(700);
|
|
183
|
+
back(film);
|
|
184
|
+
rerender(<Shell at='/film/1' el={s.el} />);
|
|
185
|
+
expect(s.el.scrollTop).toBe(700);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the app's own history.state keys survive the entry id being minted into it", () => {
|
|
189
|
+
window.history.replaceState({ appDepth: 3 }, '');
|
|
190
|
+
const s = fakeScroller(2_000);
|
|
191
|
+
render(<Shell at='/' el={s.el} />);
|
|
192
|
+
expect((window.history.state as { appDepth: number }).appDepth).toBe(3);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a reload comes back scrolled: the offset is in sessionStorage by pagehide', () => {
|
|
196
|
+
const s = fakeScroller(20_000);
|
|
197
|
+
render(<Shell at='/' el={s.el} />);
|
|
198
|
+
s.scrollTo(3_300);
|
|
199
|
+
window.dispatchEvent(new Event('pagehide'));
|
|
200
|
+
const stored = JSON.parse(window.sessionStorage.getItem('cursedbelt:scroll-memory') ?? '{}');
|
|
201
|
+
const id = (window.history.state as { cbScrollEntry: string }).cbScrollEntry;
|
|
202
|
+
expect(stored[id]).toBe(3_300);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("StrictMode's rehearsal unmount does not file a deep entry as the top", () => {
|
|
206
|
+
const s = fakeScroller(20_000);
|
|
207
|
+
const { rerender, unmount } = render(<Shell at='/' el={s.el} />);
|
|
208
|
+
s.scrollTo(12_000);
|
|
209
|
+
const list = { state: window.history.state, path: '/' };
|
|
210
|
+
push('/item/7');
|
|
211
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
212
|
+
unmount();
|
|
213
|
+
|
|
214
|
+
s.grow(3_000);
|
|
215
|
+
back(list);
|
|
216
|
+
render(
|
|
217
|
+
<StrictMode>
|
|
218
|
+
<Shell at='/' el={s.el} />
|
|
219
|
+
</StrictMode>,
|
|
220
|
+
);
|
|
221
|
+
s.grow(20_000);
|
|
222
|
+
back(list);
|
|
223
|
+
cleanup();
|
|
224
|
+
render(<Shell at='/' el={s.el} />);
|
|
225
|
+
expect(s.el.scrollTop).toBe(12_000);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Back returns you to where you were on the page, not to the top of it.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 **Why this exists.** A household viewer on flix, 2026-09-28, from a phone: *"when I click
|
|
7
|
+
* on one to read the synopsis when I page back it was … back at the top again. my thumb is
|
|
8
|
+
* exhausted and I've just looked at 2 movies starting with 'a'"*. Every app here swaps screens
|
|
9
|
+
* in place under ONE scroller — the window, or a `ScrollArea` — so the browser's own
|
|
10
|
+
* restoration has nothing to restore into: the list it would scroll is not there yet when it
|
|
11
|
+
* tries, the page is one screen tall, and the offset clamps to zero. flix fixed it by hand
|
|
12
|
+
* (`scrollMemory.ts`, recording at its `navigate`); this is that fix for every app, and it
|
|
13
|
+
* needs no seam in the app's router at all.
|
|
14
|
+
*
|
|
15
|
+
* **How.** Each history entry gets an id in `history.state` the first time a screen arrives on
|
|
16
|
+
* it. While the entry is current, the scroller's offset is tracked; when the entry is left (the
|
|
17
|
+
* `location` changes) or the page is hidden (a reload, a tab put away), the offset is filed
|
|
18
|
+
* under that id — in memory and in `sessionStorage`, so a reload comes back scrolled. Arriving
|
|
19
|
+
* on an entry that has an offset puts it back once the page is tall enough to hold it; arriving
|
|
20
|
+
* on one that has none (a fresh push) starts at the top — without that, an item opened from
|
|
21
|
+
* deep in a list opens partway down its own page.
|
|
22
|
+
*
|
|
23
|
+
* - **Per ENTRY, not per URL**: two trips into the same list keep two positions, and a forward
|
|
24
|
+
* move to a place you have been before starts at its top.
|
|
25
|
+
* - **The list may still be growing.** A virtualized grid sizes itself a frame or two late and
|
|
26
|
+
* a paged one may hold only its first page, so the restore retries each frame, scrolling as
|
|
27
|
+
* far as the page allows meanwhile — which is what asks an infinite list for its next page —
|
|
28
|
+
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
29
|
+
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
30
|
+
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
31
|
+
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
32
|
+
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
33
|
+
*
|
|
34
|
+
* Mount it ONCE per scroller, above the screens that swap — the app shell, keyed on the route.
|
|
35
|
+
*/
|
|
36
|
+
export function useScrollMemory(location: string, options: ScrollMemoryOptions = {}): void {
|
|
37
|
+
const { enabled = true, deadlineMs = RESTORE_DEADLINE_MS } = options;
|
|
38
|
+
// The scroller is read through a ref so an inline `() => el` does not re-run the effect
|
|
39
|
+
// every render — which would file and restore on every keystroke.
|
|
40
|
+
const scrollerRef = useRef(options.scroller);
|
|
41
|
+
scrollerRef.current = options.scroller;
|
|
42
|
+
const last = useRef<{ id: string; location: string } | null>(null);
|
|
43
|
+
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
if (!enabled) return;
|
|
46
|
+
const scroller = resolveScroller(scrollerRef.current ?? 'window');
|
|
47
|
+
if (!scroller) return;
|
|
48
|
+
if (scroller.isWindow && 'scrollRestoration' in window.history) {
|
|
49
|
+
// Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
|
|
50
|
+
// React has swapped it — and the restore below would have to fight it.
|
|
51
|
+
window.history.scrollRestoration = 'manual';
|
|
52
|
+
}
|
|
53
|
+
const id = entryId();
|
|
54
|
+
if (!id) return;
|
|
55
|
+
|
|
56
|
+
// Same entry, different location: the URL was replaced in place. It is the same place.
|
|
57
|
+
const replacedInPlace = last.current?.id === id && last.current.location !== location;
|
|
58
|
+
last.current = { id, location };
|
|
59
|
+
|
|
60
|
+
// What gets filed when this entry is left. While a restore is pending it is the TARGET, not
|
|
61
|
+
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
62
|
+
// not overwrite a deep position with a shallow one.
|
|
63
|
+
let offset = scroller.get();
|
|
64
|
+
let cancelRestore: (() => void) | null = null;
|
|
65
|
+
|
|
66
|
+
if (!replacedInPlace) {
|
|
67
|
+
const target = recalled(id);
|
|
68
|
+
if (target > 0) {
|
|
69
|
+
offset = target;
|
|
70
|
+
cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
71
|
+
cancelRestore = null;
|
|
72
|
+
if (outcome !== 'reached') offset = scroller.get();
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
scroller.set(0);
|
|
76
|
+
offset = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const onScroll = () => {
|
|
81
|
+
if (!cancelRestore) offset = scroller.get();
|
|
82
|
+
};
|
|
83
|
+
const onHide = () => file(id, offset);
|
|
84
|
+
scroller.events.addEventListener('scroll', onScroll, { passive: true });
|
|
85
|
+
window.addEventListener('pagehide', onHide);
|
|
86
|
+
return () => {
|
|
87
|
+
const pending = cancelRestore;
|
|
88
|
+
cancelRestore = null;
|
|
89
|
+
pending?.();
|
|
90
|
+
scroller.events.removeEventListener('scroll', onScroll);
|
|
91
|
+
window.removeEventListener('pagehide', onHide);
|
|
92
|
+
file(id, offset);
|
|
93
|
+
};
|
|
94
|
+
}, [location, enabled, deadlineMs]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What scrolls: the window, or an element — by ref or by a getter read when the screen arrives. */
|
|
98
|
+
export type ScrollMemoryScroller = 'window' | RefObject<HTMLElement | null> | (() => HTMLElement | null);
|
|
99
|
+
|
|
100
|
+
export interface ScrollMemoryOptions {
|
|
101
|
+
/** The one scroller the screens swap under. Default `'window'`. */
|
|
102
|
+
scroller?: ScrollMemoryScroller;
|
|
103
|
+
/**
|
|
104
|
+
* `false` leaves this location's scroll entirely alone — no restore, no reset to the top, and
|
|
105
|
+
* nothing filed. For a fixed full-screen surface (a player) over a page whose offset must
|
|
106
|
+
* survive underneath it. Default `true`.
|
|
107
|
+
*/
|
|
108
|
+
enabled?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* How long a restore waits for the page to grow tall enough before settling as close as it
|
|
111
|
+
* got. Default 3000ms — a reload, where only the first page of a list exists, needs a few
|
|
112
|
+
* pages' round trips.
|
|
113
|
+
*/
|
|
114
|
+
deadlineMs?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const RESTORE_DEADLINE_MS = 3_000;
|
|
118
|
+
const ENTRY_KEY = 'cbScrollEntry';
|
|
119
|
+
const STORE_KEY = 'cursedbelt:scroll-memory';
|
|
120
|
+
/** Entries kept in `sessionStorage` — a tab's history, not a cache; the oldest go first. */
|
|
121
|
+
const KEEP = 100;
|
|
122
|
+
|
|
123
|
+
/** What counts as the viewer taking the page back. `scroll` is NOT one — the restore causes those. */
|
|
124
|
+
const INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown'] as const;
|
|
125
|
+
|
|
126
|
+
interface Scroller {
|
|
127
|
+
isWindow: boolean;
|
|
128
|
+
events: EventTarget;
|
|
129
|
+
get(): number;
|
|
130
|
+
set(y: number): void;
|
|
131
|
+
/** How far it can scroll right now. */
|
|
132
|
+
room(): number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveScroller(spec: ScrollMemoryScroller): Scroller | null {
|
|
136
|
+
if (spec === 'window') {
|
|
137
|
+
return {
|
|
138
|
+
isWindow: true,
|
|
139
|
+
events: window,
|
|
140
|
+
get: () => Math.round(window.scrollY),
|
|
141
|
+
set: (y) => window.scrollTo(0, y),
|
|
142
|
+
room: () => document.documentElement.scrollHeight - window.innerHeight,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const el = typeof spec === 'function' ? spec() : spec.current;
|
|
146
|
+
if (!el) return null;
|
|
147
|
+
return {
|
|
148
|
+
isWindow: false,
|
|
149
|
+
events: el,
|
|
150
|
+
get: () => Math.round(el.scrollTop),
|
|
151
|
+
set: (y) => {
|
|
152
|
+
el.scrollTop = y;
|
|
153
|
+
},
|
|
154
|
+
room: () => el.scrollHeight - el.clientHeight,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null;
|
|
159
|
+
|
|
160
|
+
/** This entry's id, minted into `history.state` (merged, never replacing an app's own keys). */
|
|
161
|
+
function entryId(): string | null {
|
|
162
|
+
const state: unknown = window.history.state;
|
|
163
|
+
if (state !== null && state !== undefined && !isRecord(state)) return null; // not ours to reshape
|
|
164
|
+
const existing = state?.[ENTRY_KEY];
|
|
165
|
+
if (typeof existing === 'string') return existing;
|
|
166
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
167
|
+
window.history.replaceState({ ...(state ?? {}), [ENTRY_KEY]: id }, '');
|
|
168
|
+
return id;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const memory = new Map<string, number>();
|
|
172
|
+
|
|
173
|
+
function stored(): Record<string, number> {
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(window.sessionStorage.getItem(STORE_KEY) ?? '{}');
|
|
176
|
+
return isRecord(parsed) ? (parsed as Record<string, number>) : {};
|
|
177
|
+
} catch {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function recalled(id: string): number {
|
|
183
|
+
const y = memory.get(id) ?? stored()[id];
|
|
184
|
+
return typeof y === 'number' && y > 0 ? y : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function file(id: string, y: number): void {
|
|
188
|
+
memory.set(id, y);
|
|
189
|
+
try {
|
|
190
|
+
const all = stored();
|
|
191
|
+
delete all[id]; // re-inserted last, so insertion order is recency
|
|
192
|
+
all[id] = y;
|
|
193
|
+
const ids = Object.keys(all);
|
|
194
|
+
for (const old of ids.slice(0, Math.max(0, ids.length - KEEP))) delete all[old];
|
|
195
|
+
window.sessionStorage.setItem(STORE_KEY, JSON.stringify(all));
|
|
196
|
+
} catch {
|
|
197
|
+
// Private mode, quota, blocked storage: memory still covers Back within this page load.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type RestoreOutcome = 'reached' | 'input' | 'deadline';
|
|
202
|
+
|
|
203
|
+
/** Scroll to `target` once there is room for it; returns a cancel that reports nothing. */
|
|
204
|
+
function restore(
|
|
205
|
+
scroller: Scroller,
|
|
206
|
+
target: number,
|
|
207
|
+
deadlineMs: number,
|
|
208
|
+
settled: (outcome: RestoreOutcome) => void,
|
|
209
|
+
): () => void {
|
|
210
|
+
const deadline = performance.now() + deadlineMs;
|
|
211
|
+
let frame = 0;
|
|
212
|
+
let done = false;
|
|
213
|
+
|
|
214
|
+
const stop = () => {
|
|
215
|
+
if (done) return false;
|
|
216
|
+
done = true;
|
|
217
|
+
cancelAnimationFrame(frame);
|
|
218
|
+
for (const type of INPUT_EVENTS) window.removeEventListener(type, onInput);
|
|
219
|
+
return true;
|
|
220
|
+
};
|
|
221
|
+
const onInput = () => {
|
|
222
|
+
if (stop()) settled('input');
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const attempt = () => {
|
|
226
|
+
if (done) return;
|
|
227
|
+
const room = scroller.room();
|
|
228
|
+
if (room >= target) {
|
|
229
|
+
scroller.set(target);
|
|
230
|
+
if (stop()) settled('reached');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Short of it: go as far as the page allows — that is what asks an infinite list for more.
|
|
234
|
+
scroller.set(Math.max(0, room));
|
|
235
|
+
if (performance.now() >= deadline) {
|
|
236
|
+
if (stop()) settled('deadline');
|
|
237
|
+
} else frame = requestAnimationFrame(attempt);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
for (const type of INPUT_EVENTS) window.addEventListener(type, onInput, { passive: true });
|
|
241
|
+
attempt();
|
|
242
|
+
return () => {
|
|
243
|
+
stop();
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -100,6 +100,11 @@ export {
|
|
|
100
100
|
scrollFadeMask,
|
|
101
101
|
useScrollEdges,
|
|
102
102
|
} from './hooks/useScrollEdges.js';
|
|
103
|
+
export {
|
|
104
|
+
type ScrollMemoryOptions,
|
|
105
|
+
type ScrollMemoryScroller,
|
|
106
|
+
useScrollMemory,
|
|
107
|
+
} from './hooks/useScrollMemory.js';
|
|
103
108
|
export {
|
|
104
109
|
AmbientBackdrop,
|
|
105
110
|
type AmbientBackdropProps,
|
|
@@ -54,4 +54,82 @@ describe('usePagedList', () => {
|
|
|
54
54
|
expect(result.current.items).toEqual([]);
|
|
55
55
|
expect(calls).toBe(0);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
test('total is the newest page\'s count, and undefined before any page', async () => {
|
|
59
|
+
const fetchPage = pagedFetcher({
|
|
60
|
+
'@first': { items: ['a'], nextCursor: 'c1', total: 3 },
|
|
61
|
+
c1: { items: ['b', 'c'], total: 2 },
|
|
62
|
+
});
|
|
63
|
+
const { result } = renderHook(() => usePagedList({ queryKey: ['total'], fetchPage }), {
|
|
64
|
+
wrapper: makeWrapper(),
|
|
65
|
+
});
|
|
66
|
+
expect(result.current.total).toBeUndefined();
|
|
67
|
+
await waitFor(() => expect(result.current.total).toBe(3));
|
|
68
|
+
act(() => result.current.loadMore());
|
|
69
|
+
await waitFor(() => expect(result.current.total).toBe(2));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('coming back to a list holds every page in its FIRST render, and fetches nothing', async () => {
|
|
73
|
+
let fetches = 0;
|
|
74
|
+
const pages = pagedFetcher({
|
|
75
|
+
'@first': { items: ['a', 'b'], nextCursor: 'c1' },
|
|
76
|
+
c1: { items: ['c', 'd'] },
|
|
77
|
+
});
|
|
78
|
+
const fetchPage = (cursor: string | undefined) => {
|
|
79
|
+
fetches++;
|
|
80
|
+
return pages(cursor);
|
|
81
|
+
};
|
|
82
|
+
const wrapper = makeWrapper();
|
|
83
|
+
const opts = { queryKey: ['back'], fetchPage, staleTime: 60_000 };
|
|
84
|
+
const first = renderHook(() => usePagedList(opts), { wrapper });
|
|
85
|
+
await waitFor(() => expect(first.result.current.items).toHaveLength(2));
|
|
86
|
+
act(() => first.result.current.loadMore());
|
|
87
|
+
await waitFor(() => expect(first.result.current.items).toHaveLength(4));
|
|
88
|
+
first.unmount();
|
|
89
|
+
|
|
90
|
+
const seen: number[] = [];
|
|
91
|
+
renderHook(
|
|
92
|
+
() => {
|
|
93
|
+
const list = usePagedList(opts);
|
|
94
|
+
seen.push(list.items.length);
|
|
95
|
+
return list;
|
|
96
|
+
},
|
|
97
|
+
{ wrapper },
|
|
98
|
+
);
|
|
99
|
+
expect(seen[0]).toBe(4);
|
|
100
|
+
expect(fetches).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('items is the same array across renders until a page changes', async () => {
|
|
104
|
+
const fetchPage = pagedFetcher({ '@first': { items: ['a', 'b'] } });
|
|
105
|
+
const { result, rerender } = renderHook(() => usePagedList({ queryKey: ['stable'], fetchPage }), {
|
|
106
|
+
wrapper: makeWrapper(),
|
|
107
|
+
});
|
|
108
|
+
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
|
109
|
+
const before = result.current.items;
|
|
110
|
+
rerender();
|
|
111
|
+
expect(result.current.items).toBe(before);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('keepPreviousItems holds the old list on screen while a new query loads', async () => {
|
|
115
|
+
let release: () => void = () => {};
|
|
116
|
+
const fetchPage = (key: string) => (cursor: string | undefined) =>
|
|
117
|
+
key === 'b'
|
|
118
|
+
? new Promise<PagedPage<string>>((r) => {
|
|
119
|
+
release = () => r({ items: ['b1'] });
|
|
120
|
+
})
|
|
121
|
+
: pagedFetcher({ '@first': { items: ['a1', 'a2'] } })(cursor);
|
|
122
|
+
const { result, rerender } = renderHook(
|
|
123
|
+
({ q }: { q: string }) =>
|
|
124
|
+
usePagedList({ queryKey: ['keep', q], fetchPage: fetchPage(q), keepPreviousItems: true }),
|
|
125
|
+
{ wrapper: makeWrapper(), initialProps: { q: 'a' } },
|
|
126
|
+
);
|
|
127
|
+
await waitFor(() => expect(result.current.items).toEqual(['a1', 'a2']));
|
|
128
|
+
rerender({ q: 'b' });
|
|
129
|
+
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
|
130
|
+
expect(result.current.items).toEqual(['a1', 'a2']);
|
|
131
|
+
expect(result.current.isLoading).toBe(false);
|
|
132
|
+
act(() => release());
|
|
133
|
+
await waitFor(() => expect(result.current.items).toEqual(['b1']));
|
|
134
|
+
});
|
|
57
135
|
});
|