cursedbelt 5.22.0 → 5.22.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/dist/react/hooks/useScrollMemory.d.ts +3 -1
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +66 -47
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/package.json +1 -1
- package/src/react/hooks/useScrollMemory.spec.tsx +38 -0
- package/src/react/hooks/useScrollMemory.ts +82 -47
|
@@ -30,7 +30,9 @@ import { type RefObject } from 'react';
|
|
|
30
30
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
31
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
32
|
*
|
|
33
|
-
* Mount it ONCE per scroller
|
|
33
|
+
* Mount it ONCE per scroller: in the app shell keyed on the route when the screens swap under one
|
|
34
|
+
* scroller (the window, a shell `ScrollArea`), or in the list screen itself when each screen owns
|
|
35
|
+
* its own `ScrollArea` — mounting is then the arrival and unmounting the leave.
|
|
34
36
|
*/
|
|
35
37
|
export declare function useScrollMemory(location: string, options?: ScrollMemoryOptions): void;
|
|
36
38
|
/** What scrolls: the window, or an element — by ref or by a getter read when the screen arrives. */
|
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,IAAI,CA0BzF;AAoED,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"}
|
|
@@ -30,68 +30,87 @@ import { useLayoutEffect, useRef } from 'react';
|
|
|
30
30
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
31
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
32
|
*
|
|
33
|
-
* Mount it ONCE per scroller
|
|
33
|
+
* Mount it ONCE per scroller: in the app shell keyed on the route when the screens swap under one
|
|
34
|
+
* scroller (the window, a shell `ScrollArea`), or in the list screen itself when each screen owns
|
|
35
|
+
* its own `ScrollArea` — mounting is then the arrival and unmounting the leave.
|
|
34
36
|
*/
|
|
35
37
|
export function useScrollMemory(location, options = {}) {
|
|
36
38
|
const { enabled = true, deadlineMs = RESTORE_DEADLINE_MS } = options;
|
|
37
|
-
|
|
38
|
-
// every render — which would file and restore on every keystroke.
|
|
39
|
-
const scrollerRef = useRef(options.scroller);
|
|
40
|
-
scrollerRef.current = options.scroller;
|
|
39
|
+
const spec = options.scroller ?? 'window';
|
|
41
40
|
const last = useRef(null);
|
|
41
|
+
const session = useRef(null);
|
|
42
|
+
// 🔴 Checked EVERY commit, not on a dependency list: the scroller can mount after the hook — a
|
|
43
|
+
// list behind a loading spinner has no ScrollArea on its first render — and an effect keyed on
|
|
44
|
+
// the location alone would find nothing, return, and never look again, so that whole visit
|
|
45
|
+
// would go unremembered. A session ends and the next begins only when the place, the switch or
|
|
46
|
+
// the scroller ELEMENT actually changes; every other commit is one comparison.
|
|
42
47
|
useLayoutEffect(() => {
|
|
43
|
-
|
|
48
|
+
const scroller = enabled ? resolveScroller(spec) : null;
|
|
49
|
+
const current = session.current;
|
|
50
|
+
if (current && scroller && current.location === location && current.target === scroller.events)
|
|
44
51
|
return;
|
|
45
|
-
|
|
46
|
-
if (!scroller)
|
|
52
|
+
if (!current && !scroller)
|
|
47
53
|
return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
current?.end();
|
|
55
|
+
session.current = scroller ? begin(scroller, location, last, deadlineMs) : null;
|
|
56
|
+
});
|
|
57
|
+
useLayoutEffect(() => () => {
|
|
58
|
+
session.current?.end();
|
|
59
|
+
session.current = null;
|
|
60
|
+
}, []);
|
|
61
|
+
}
|
|
62
|
+
/** Arrive on the current history entry with this scroller: restore or start at the top, then track. */
|
|
63
|
+
function begin(scroller, location, last, deadlineMs) {
|
|
64
|
+
if (scroller.isWindow && 'scrollRestoration' in window.history) {
|
|
65
|
+
// Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
|
|
66
|
+
// React has swapped it — and the restore below would have to fight it.
|
|
67
|
+
window.history.scrollRestoration = 'manual';
|
|
68
|
+
}
|
|
69
|
+
const id = entryId();
|
|
70
|
+
if (!id)
|
|
71
|
+
return null;
|
|
72
|
+
// Same entry, different location: the URL was replaced in place. It is the same place.
|
|
73
|
+
const replacedInPlace = last.current?.id === id && last.current.location !== location;
|
|
74
|
+
last.current = { id, location };
|
|
75
|
+
// What gets filed when this entry is left. While a restore is pending it is the TARGET, not
|
|
76
|
+
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
77
|
+
// not overwrite a deep position with a shallow one.
|
|
78
|
+
let offset = scroller.get();
|
|
79
|
+
let cancelRestore = null;
|
|
80
|
+
if (!replacedInPlace) {
|
|
81
|
+
const target = recalled(id);
|
|
82
|
+
if (target > 0) {
|
|
83
|
+
offset = target;
|
|
84
|
+
cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
85
|
+
cancelRestore = null;
|
|
86
|
+
if (outcome !== 'reached')
|
|
87
|
+
offset = scroller.get();
|
|
88
|
+
});
|
|
52
89
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
// What gets filed when this entry is left. While a restore is pending it is the TARGET, not
|
|
60
|
-
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
61
|
-
// not overwrite a deep position with a shallow one.
|
|
62
|
-
let offset = scroller.get();
|
|
63
|
-
let cancelRestore = null;
|
|
64
|
-
if (!replacedInPlace) {
|
|
65
|
-
const target = recalled(id);
|
|
66
|
-
if (target > 0) {
|
|
67
|
-
offset = target;
|
|
68
|
-
cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
69
|
-
cancelRestore = null;
|
|
70
|
-
if (outcome !== 'reached')
|
|
71
|
-
offset = scroller.get();
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
scroller.set(0);
|
|
76
|
-
offset = 0;
|
|
77
|
-
}
|
|
90
|
+
else {
|
|
91
|
+
scroller.set(0);
|
|
92
|
+
offset = 0;
|
|
78
93
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
}
|
|
95
|
+
const onScroll = () => {
|
|
96
|
+
if (!cancelRestore)
|
|
97
|
+
offset = scroller.get();
|
|
98
|
+
};
|
|
99
|
+
const onHide = () => file(id, offset);
|
|
100
|
+
scroller.events.addEventListener('scroll', onScroll, { passive: true });
|
|
101
|
+
window.addEventListener('pagehide', onHide);
|
|
102
|
+
return {
|
|
103
|
+
location,
|
|
104
|
+
target: scroller.events,
|
|
105
|
+
end() {
|
|
87
106
|
const pending = cancelRestore;
|
|
88
107
|
cancelRestore = null;
|
|
89
108
|
pending?.();
|
|
90
109
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
91
110
|
window.removeEventListener('pagehide', onHide);
|
|
92
111
|
file(id, offset);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
95
114
|
}
|
|
96
115
|
const RESTORE_DEADLINE_MS = 3_000;
|
|
97
116
|
const ENTRY_KEY = 'cbScrollEntry';
|
|
@@ -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.22.
|
|
3
|
+
"version": "5.22.1",
|
|
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.",
|
|
@@ -224,4 +224,42 @@ describe('useScrollMemory', () => {
|
|
|
224
224
|
render(<Shell at='/' el={s.el} />);
|
|
225
225
|
expect(s.el.scrollTop).toBe(12_000);
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
test('a scroller that mounts AFTER the hook — a list behind a loading spinner — is still remembered', () => {
|
|
229
|
+
const s = fakeScroller(20_000);
|
|
230
|
+
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
231
|
+
s.scrollTo(8_000);
|
|
232
|
+
const list = { state: window.history.state, path: '/' };
|
|
233
|
+
push('/item/7');
|
|
234
|
+
rerender(<Shell at='/item/7' el={s.el} />);
|
|
235
|
+
|
|
236
|
+
back(list);
|
|
237
|
+
const Late = ({ ready }: { ready: boolean }) => {
|
|
238
|
+
useScrollMemory('/', { scroller: () => (ready ? s.el : null) });
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
241
|
+
cleanup();
|
|
242
|
+
const late = render(<Late ready={false} />);
|
|
243
|
+
late.rerender(<Late ready />);
|
|
244
|
+
expect(s.el.scrollTop).toBe(8_000);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a list screen that owns its scroller files on unmount and restores on remount', () => {
|
|
248
|
+
const s = fakeScroller(20_000);
|
|
249
|
+
const List = () => {
|
|
250
|
+
useScrollMemory('/people', { scroller: () => s.el });
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
window.history.replaceState(null, '', '/people');
|
|
254
|
+
const first = render(<List />);
|
|
255
|
+
s.scrollTo(5_500);
|
|
256
|
+
const list = { state: window.history.state, path: '/people' };
|
|
257
|
+
push('/person/1'); // the app navigates; the list unmounts
|
|
258
|
+
first.unmount();
|
|
259
|
+
s.el.scrollTop = 0; // a new screen's scroller, as far as this list knows
|
|
260
|
+
|
|
261
|
+
back(list);
|
|
262
|
+
render(<List />);
|
|
263
|
+
expect(s.el.scrollTop).toBe(5_500);
|
|
264
|
+
});
|
|
227
265
|
});
|
|
@@ -31,67 +31,102 @@ import { type RefObject, useLayoutEffect, useRef } from 'react';
|
|
|
31
31
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
32
32
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
33
33
|
*
|
|
34
|
-
* Mount it ONCE per scroller
|
|
34
|
+
* Mount it ONCE per scroller: in the app shell keyed on the route when the screens swap under one
|
|
35
|
+
* scroller (the window, a shell `ScrollArea`), or in the list screen itself when each screen owns
|
|
36
|
+
* its own `ScrollArea` — mounting is then the arrival and unmounting the leave.
|
|
35
37
|
*/
|
|
36
38
|
export function useScrollMemory(location: string, options: ScrollMemoryOptions = {}): void {
|
|
37
39
|
const { enabled = true, deadlineMs = RESTORE_DEADLINE_MS } = options;
|
|
38
|
-
|
|
39
|
-
// every render — which would file and restore on every keystroke.
|
|
40
|
-
const scrollerRef = useRef(options.scroller);
|
|
41
|
-
scrollerRef.current = options.scroller;
|
|
40
|
+
const spec = options.scroller ?? 'window';
|
|
42
41
|
const last = useRef<{ id: string; location: string } | null>(null);
|
|
42
|
+
const session = useRef<Session | null>(null);
|
|
43
43
|
|
|
44
|
+
// 🔴 Checked EVERY commit, not on a dependency list: the scroller can mount after the hook — a
|
|
45
|
+
// list behind a loading spinner has no ScrollArea on its first render — and an effect keyed on
|
|
46
|
+
// the location alone would find nothing, return, and never look again, so that whole visit
|
|
47
|
+
// would go unremembered. A session ends and the next begins only when the place, the switch or
|
|
48
|
+
// the scroller ELEMENT actually changes; every other commit is one comparison.
|
|
44
49
|
useLayoutEffect(() => {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
50
|
+
const scroller = enabled ? resolveScroller(spec) : null;
|
|
51
|
+
const current = session.current;
|
|
52
|
+
if (current && scroller && current.location === location && current.target === scroller.events) return;
|
|
53
|
+
if (!current && !scroller) return;
|
|
54
|
+
current?.end();
|
|
55
|
+
session.current = scroller ? begin(scroller, location, last, deadlineMs) : null;
|
|
56
|
+
});
|
|
57
|
+
useLayoutEffect(
|
|
58
|
+
() => () => {
|
|
59
|
+
session.current?.end();
|
|
60
|
+
session.current = null;
|
|
61
|
+
},
|
|
62
|
+
[],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Session {
|
|
67
|
+
location: string;
|
|
68
|
+
target: EventTarget;
|
|
69
|
+
/** Stop tracking and file the offset under this entry. */
|
|
70
|
+
end(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Arrive on the current history entry with this scroller: restore or start at the top, then track. */
|
|
74
|
+
function begin(
|
|
75
|
+
scroller: Scroller,
|
|
76
|
+
location: string,
|
|
77
|
+
last: { current: { id: string; location: string } | null },
|
|
78
|
+
deadlineMs: number,
|
|
79
|
+
): Session | null {
|
|
80
|
+
if (scroller.isWindow && 'scrollRestoration' in window.history) {
|
|
81
|
+
// Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
|
|
82
|
+
// React has swapped it — and the restore below would have to fight it.
|
|
83
|
+
window.history.scrollRestoration = 'manual';
|
|
84
|
+
}
|
|
85
|
+
const id = entryId();
|
|
86
|
+
if (!id) return null;
|
|
87
|
+
|
|
88
|
+
// Same entry, different location: the URL was replaced in place. It is the same place.
|
|
89
|
+
const replacedInPlace = last.current?.id === id && last.current.location !== location;
|
|
90
|
+
last.current = { id, location };
|
|
91
|
+
|
|
92
|
+
// What gets filed when this entry is left. While a restore is pending it is the TARGET, not
|
|
93
|
+
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
94
|
+
// not overwrite a deep position with a shallow one.
|
|
95
|
+
let offset = scroller.get();
|
|
96
|
+
let cancelRestore: (() => void) | null = null;
|
|
97
|
+
|
|
98
|
+
if (!replacedInPlace) {
|
|
99
|
+
const target = recalled(id);
|
|
100
|
+
if (target > 0) {
|
|
101
|
+
offset = target;
|
|
102
|
+
cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
103
|
+
cancelRestore = null;
|
|
104
|
+
if (outcome !== 'reached') offset = scroller.get();
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
scroller.set(0);
|
|
108
|
+
offset = 0;
|
|
78
109
|
}
|
|
110
|
+
}
|
|
79
111
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
const onScroll = () => {
|
|
113
|
+
if (!cancelRestore) offset = scroller.get();
|
|
114
|
+
};
|
|
115
|
+
const onHide = () => file(id, offset);
|
|
116
|
+
scroller.events.addEventListener('scroll', onScroll, { passive: true });
|
|
117
|
+
window.addEventListener('pagehide', onHide);
|
|
118
|
+
return {
|
|
119
|
+
location,
|
|
120
|
+
target: scroller.events,
|
|
121
|
+
end() {
|
|
87
122
|
const pending = cancelRestore;
|
|
88
123
|
cancelRestore = null;
|
|
89
124
|
pending?.();
|
|
90
125
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
91
126
|
window.removeEventListener('pagehide', onHide);
|
|
92
127
|
file(id, offset);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
/** What scrolls: the window, or an element — by ref or by a getter read when the screen arrives. */
|