cursedbelt 5.55.1 → 5.55.2
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 +5 -0
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +48 -23
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/package.json +1 -1
- package/src/react/hooks/useScrollMemory.spec.tsx +46 -0
- package/src/react/hooks/useScrollMemory.ts +47 -20
|
@@ -27,6 +27,11 @@ import { type RefObject } from 'react';
|
|
|
27
27
|
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
28
28
|
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
29
29
|
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
30
|
+
* - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
|
|
31
|
+
* the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
|
|
32
|
+
* and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
|
|
33
|
+
* late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
|
|
34
|
+
* that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
|
|
30
35
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
36
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
37
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,IAAI,CA0BzF;AAwED,oGAAoG;AACpG,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,CAAC;AAEzG,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmDD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIzD"}
|
|
@@ -27,6 +27,11 @@ import { useLayoutEffect, useRef } from 'react';
|
|
|
27
27
|
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
28
28
|
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
29
29
|
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
30
|
+
* - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
|
|
31
|
+
* the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
|
|
32
|
+
* and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
|
|
33
|
+
* late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
|
|
34
|
+
* that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
|
|
30
35
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
36
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
37
|
*
|
|
@@ -76,13 +81,17 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
76
81
|
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
77
82
|
// not overwrite a deep position with a shallow one.
|
|
78
83
|
let offset = scroller.get();
|
|
79
|
-
|
|
84
|
+
/** True until the restore has landed or given up — while it is, `offset` stays the target. */
|
|
85
|
+
let restoring = false;
|
|
86
|
+
/** Stops the restore, or the hold after it, reporting nothing. */
|
|
87
|
+
let stopRestore = null;
|
|
80
88
|
if (!replacedInPlace) {
|
|
81
89
|
const target = recalled(id);
|
|
82
90
|
if (target > 0) {
|
|
83
91
|
offset = target;
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
restoring = true;
|
|
93
|
+
stopRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
94
|
+
restoring = false;
|
|
86
95
|
if (outcome !== 'reached')
|
|
87
96
|
offset = scroller.get();
|
|
88
97
|
});
|
|
@@ -93,7 +102,7 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
const onScroll = () => {
|
|
96
|
-
if (!
|
|
105
|
+
if (!restoring)
|
|
97
106
|
offset = scroller.get();
|
|
98
107
|
};
|
|
99
108
|
const onHide = () => file(id, offset);
|
|
@@ -103,9 +112,9 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
103
112
|
location,
|
|
104
113
|
target: scroller.events,
|
|
105
114
|
end() {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
restoring = false;
|
|
116
|
+
stopRestore?.();
|
|
117
|
+
stopRestore = null;
|
|
109
118
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
110
119
|
window.removeEventListener('pagehide', onHide);
|
|
111
120
|
file(id, offset);
|
|
@@ -113,6 +122,12 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
113
122
|
};
|
|
114
123
|
}
|
|
115
124
|
const RESTORE_DEADLINE_MS = 3_000;
|
|
125
|
+
/**
|
|
126
|
+
* How long a landed restore holds its target against a scroll the viewer did not make. Long
|
|
127
|
+
* enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
|
|
128
|
+
* load); short enough that an app's own jump a moment later is not undone.
|
|
129
|
+
*/
|
|
130
|
+
const HOLD_MS = 1_500;
|
|
116
131
|
const ENTRY_KEY = 'cbScrollEntry';
|
|
117
132
|
const STORE_KEY = 'cursedbelt:scroll-memory';
|
|
118
133
|
/** Entries kept in `sessionStorage` — a tab's history, not a cache; the oldest go first. */
|
|
@@ -195,39 +210,51 @@ function file(id, y) {
|
|
|
195
210
|
// Private mode, quota, blocked storage: memory still covers Back within this page load.
|
|
196
211
|
}
|
|
197
212
|
}
|
|
198
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Scroll to `target` once there is room for it, then HOLD it for {@link HOLD_MS}; returns a
|
|
215
|
+
* cancel that reports nothing. `settled` is called once, when it lands or gives up.
|
|
216
|
+
*/
|
|
199
217
|
function restore(scroller, target, deadlineMs, settled) {
|
|
200
218
|
const deadline = performance.now() + deadlineMs;
|
|
201
219
|
let frame = 0;
|
|
202
|
-
let
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
done = true;
|
|
220
|
+
let holdTimer;
|
|
221
|
+
let phase = 'waiting';
|
|
222
|
+
const finish = () => {
|
|
223
|
+
phase = 'done';
|
|
207
224
|
cancelAnimationFrame(frame);
|
|
225
|
+
clearTimeout(holdTimer);
|
|
208
226
|
for (const type of INPUT_EVENTS)
|
|
209
227
|
window.removeEventListener(type, onInput);
|
|
210
|
-
|
|
228
|
+
scroller.events.removeEventListener('scroll', onStray);
|
|
211
229
|
};
|
|
212
230
|
const onInput = () => {
|
|
213
|
-
|
|
231
|
+
const was = phase;
|
|
232
|
+
finish();
|
|
233
|
+
if (was === 'waiting')
|
|
214
234
|
settled('input');
|
|
215
235
|
};
|
|
236
|
+
// Landed, and something that is not the viewer moved it: put it back.
|
|
237
|
+
const onStray = () => {
|
|
238
|
+
if (phase === 'holding' && Math.abs(scroller.get() - target) > 1)
|
|
239
|
+
scroller.set(target);
|
|
240
|
+
};
|
|
216
241
|
const attempt = () => {
|
|
217
|
-
if (
|
|
242
|
+
if (phase !== 'waiting')
|
|
218
243
|
return;
|
|
219
244
|
const room = scroller.room();
|
|
220
245
|
if (room >= target) {
|
|
221
246
|
scroller.set(target);
|
|
222
|
-
|
|
223
|
-
|
|
247
|
+
phase = 'holding';
|
|
248
|
+
scroller.events.addEventListener('scroll', onStray, { passive: true });
|
|
249
|
+
holdTimer = setTimeout(finish, HOLD_MS);
|
|
250
|
+
settled('reached');
|
|
224
251
|
return;
|
|
225
252
|
}
|
|
226
253
|
// Short of it: go as far as the page allows — that is what asks an infinite list for more.
|
|
227
254
|
scroller.set(Math.max(0, room));
|
|
228
255
|
if (performance.now() >= deadline) {
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
finish();
|
|
257
|
+
settled('deadline');
|
|
231
258
|
}
|
|
232
259
|
else
|
|
233
260
|
frame = requestAnimationFrame(attempt);
|
|
@@ -235,8 +262,6 @@ function restore(scroller, target, deadlineMs, settled) {
|
|
|
235
262
|
for (const type of INPUT_EVENTS)
|
|
236
263
|
window.addEventListener(type, onInput, { passive: true });
|
|
237
264
|
attempt();
|
|
238
|
-
return
|
|
239
|
-
stop();
|
|
240
|
-
};
|
|
265
|
+
return finish;
|
|
241
266
|
}
|
|
242
267
|
//# sourceMappingURL=useScrollMemory.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.55.
|
|
3
|
+
"version": "5.55.2",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -3,6 +3,9 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
|
3
3
|
import { StrictMode } from 'react';
|
|
4
4
|
import { useScrollMemory } from './useScrollMemory.js';
|
|
5
5
|
|
|
6
|
+
/** `HOLD_MS` in the hook, and a margin — written out so this spec stands without the hold. */
|
|
7
|
+
const PAST_THE_HOLD_MS = 1_600;
|
|
8
|
+
|
|
6
9
|
afterEach(cleanup);
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -118,6 +121,49 @@ describe('useScrollMemory', () => {
|
|
|
118
121
|
expect(s.el.scrollTop).toBe(500);
|
|
119
122
|
});
|
|
120
123
|
|
|
124
|
+
/**
|
|
125
|
+
* 🔴 forge task 2784: music's phone list came back from Lyrics on 12,343px and, 800ms later,
|
|
126
|
+
* sat 153px deeper — moved by a write that was not the viewer's. The hold puts it back.
|
|
127
|
+
*/
|
|
128
|
+
test('a late scroll the viewer did not make, just after the restore lands, is put back', () => {
|
|
129
|
+
const s = fakeScroller(20_000);
|
|
130
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
131
|
+
s.scrollTo(12_343);
|
|
132
|
+
const list = { state: window.history.state, path: '/' };
|
|
133
|
+
push('/lyrics');
|
|
134
|
+
rerender(<Shell at='/lyrics' el={s.el} />);
|
|
135
|
+
|
|
136
|
+
back(list);
|
|
137
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
138
|
+
expect(s.el.scrollTop).toBe(12_343);
|
|
139
|
+
s.scrollTo(12_496); // the late move: no touch, wheel, key or pointer behind it
|
|
140
|
+
expect(s.el.scrollTop).toBe(12_343);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the hold yields to the viewer, and ends on its own', async () => {
|
|
144
|
+
const s = fakeScroller(20_000);
|
|
145
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
146
|
+
s.scrollTo(9_000);
|
|
147
|
+
const list = { state: window.history.state, path: '/' };
|
|
148
|
+
push('/item/7');
|
|
149
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
150
|
+
|
|
151
|
+
back(list);
|
|
152
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
153
|
+
fireEvent.touchStart(window);
|
|
154
|
+
s.scrollTo(8_000); // the viewer's own scroll, inside the hold
|
|
155
|
+
expect(s.el.scrollTop).toBe(8_000);
|
|
156
|
+
|
|
157
|
+
push('/item/8');
|
|
158
|
+
rerender(<Shell at='/item/8' el={s.el} />);
|
|
159
|
+
back(list);
|
|
160
|
+
rerender(<Shell at='/' el={s.el} />);
|
|
161
|
+
expect(s.el.scrollTop).toBe(8_000);
|
|
162
|
+
await act(() => new Promise((r) => setTimeout(r, PAST_THE_HOLD_MS)));
|
|
163
|
+
s.scrollTo(3_000); // an app's own jump, after the hold — not undone
|
|
164
|
+
expect(s.el.scrollTop).toBe(3_000);
|
|
165
|
+
});
|
|
166
|
+
|
|
121
167
|
test('leaving mid-restore files the TARGET, not the shallow offset it had reached', async () => {
|
|
122
168
|
const s = fakeScroller(20_000);
|
|
123
169
|
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
@@ -28,6 +28,11 @@ import { type RefObject, useLayoutEffect, useRef } from 'react';
|
|
|
28
28
|
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
29
29
|
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
30
30
|
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
31
|
+
* - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
|
|
32
|
+
* the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
|
|
33
|
+
* and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
|
|
34
|
+
* late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
|
|
35
|
+
* that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
|
|
31
36
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
32
37
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
33
38
|
*
|
|
@@ -93,14 +98,18 @@ function begin(
|
|
|
93
98
|
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
94
99
|
// not overwrite a deep position with a shallow one.
|
|
95
100
|
let offset = scroller.get();
|
|
96
|
-
|
|
101
|
+
/** True until the restore has landed or given up — while it is, `offset` stays the target. */
|
|
102
|
+
let restoring = false;
|
|
103
|
+
/** Stops the restore, or the hold after it, reporting nothing. */
|
|
104
|
+
let stopRestore: (() => void) | null = null;
|
|
97
105
|
|
|
98
106
|
if (!replacedInPlace) {
|
|
99
107
|
const target = recalled(id);
|
|
100
108
|
if (target > 0) {
|
|
101
109
|
offset = target;
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
restoring = true;
|
|
111
|
+
stopRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
112
|
+
restoring = false;
|
|
104
113
|
if (outcome !== 'reached') offset = scroller.get();
|
|
105
114
|
});
|
|
106
115
|
} else {
|
|
@@ -110,7 +119,7 @@ function begin(
|
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
const onScroll = () => {
|
|
113
|
-
if (!
|
|
122
|
+
if (!restoring) offset = scroller.get();
|
|
114
123
|
};
|
|
115
124
|
const onHide = () => file(id, offset);
|
|
116
125
|
scroller.events.addEventListener('scroll', onScroll, { passive: true });
|
|
@@ -119,9 +128,9 @@ function begin(
|
|
|
119
128
|
location,
|
|
120
129
|
target: scroller.events,
|
|
121
130
|
end() {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
restoring = false;
|
|
132
|
+
stopRestore?.();
|
|
133
|
+
stopRestore = null;
|
|
125
134
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
126
135
|
window.removeEventListener('pagehide', onHide);
|
|
127
136
|
file(id, offset);
|
|
@@ -150,6 +159,12 @@ export interface ScrollMemoryOptions {
|
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
const RESTORE_DEADLINE_MS = 3_000;
|
|
162
|
+
/**
|
|
163
|
+
* How long a landed restore holds its target against a scroll the viewer did not make. Long
|
|
164
|
+
* enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
|
|
165
|
+
* load); short enough that an app's own jump a moment later is not undone.
|
|
166
|
+
*/
|
|
167
|
+
const HOLD_MS = 1_500;
|
|
153
168
|
const ENTRY_KEY = 'cbScrollEntry';
|
|
154
169
|
const STORE_KEY = 'cursedbelt:scroll-memory';
|
|
155
170
|
/** Entries kept in `sessionStorage` — a tab's history, not a cache; the oldest go first. */
|
|
@@ -246,7 +261,10 @@ function file(id: string, y: number): void {
|
|
|
246
261
|
|
|
247
262
|
type RestoreOutcome = 'reached' | 'input' | 'deadline';
|
|
248
263
|
|
|
249
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* Scroll to `target` once there is room for it, then HOLD it for {@link HOLD_MS}; returns a
|
|
266
|
+
* cancel that reports nothing. `settled` is called once, when it lands or gives up.
|
|
267
|
+
*/
|
|
250
268
|
function restore(
|
|
251
269
|
scroller: Scroller,
|
|
252
270
|
target: number,
|
|
@@ -255,37 +273,46 @@ function restore(
|
|
|
255
273
|
): () => void {
|
|
256
274
|
const deadline = performance.now() + deadlineMs;
|
|
257
275
|
let frame = 0;
|
|
258
|
-
let
|
|
276
|
+
let holdTimer: ReturnType<typeof setTimeout> | undefined;
|
|
277
|
+
let phase: 'waiting' | 'holding' | 'done' = 'waiting';
|
|
259
278
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
done = true;
|
|
279
|
+
const finish = () => {
|
|
280
|
+
phase = 'done';
|
|
263
281
|
cancelAnimationFrame(frame);
|
|
282
|
+
clearTimeout(holdTimer);
|
|
264
283
|
for (const type of INPUT_EVENTS) window.removeEventListener(type, onInput);
|
|
265
|
-
|
|
284
|
+
scroller.events.removeEventListener('scroll', onStray);
|
|
266
285
|
};
|
|
267
286
|
const onInput = () => {
|
|
268
|
-
|
|
287
|
+
const was = phase;
|
|
288
|
+
finish();
|
|
289
|
+
if (was === 'waiting') settled('input');
|
|
290
|
+
};
|
|
291
|
+
// Landed, and something that is not the viewer moved it: put it back.
|
|
292
|
+
const onStray = () => {
|
|
293
|
+
if (phase === 'holding' && Math.abs(scroller.get() - target) > 1) scroller.set(target);
|
|
269
294
|
};
|
|
270
295
|
|
|
271
296
|
const attempt = () => {
|
|
272
|
-
if (
|
|
297
|
+
if (phase !== 'waiting') return;
|
|
273
298
|
const room = scroller.room();
|
|
274
299
|
if (room >= target) {
|
|
275
300
|
scroller.set(target);
|
|
276
|
-
|
|
301
|
+
phase = 'holding';
|
|
302
|
+
scroller.events.addEventListener('scroll', onStray, { passive: true });
|
|
303
|
+
holdTimer = setTimeout(finish, HOLD_MS);
|
|
304
|
+
settled('reached');
|
|
277
305
|
return;
|
|
278
306
|
}
|
|
279
307
|
// Short of it: go as far as the page allows — that is what asks an infinite list for more.
|
|
280
308
|
scroller.set(Math.max(0, room));
|
|
281
309
|
if (performance.now() >= deadline) {
|
|
282
|
-
|
|
310
|
+
finish();
|
|
311
|
+
settled('deadline');
|
|
283
312
|
} else frame = requestAnimationFrame(attempt);
|
|
284
313
|
};
|
|
285
314
|
|
|
286
315
|
for (const type of INPUT_EVENTS) window.addEventListener(type, onInput, { passive: true });
|
|
287
316
|
attempt();
|
|
288
|
-
return
|
|
289
|
-
stop();
|
|
290
|
-
};
|
|
317
|
+
return finish;
|
|
291
318
|
}
|