@tinvar/react 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// <InfiniteScroll>'s browser half: when to load the next or the previous
|
|
4
|
+
// page of a Scroll, how, and what the address bar says while the reader
|
|
5
|
+
// scrolls through them (ADR 0020).
|
|
6
|
+
//
|
|
7
|
+
// tinvar-spec.md: "`<InfiniteScroll data="users">` over a `scroll()` field:
|
|
8
|
+
// both directions, URL sync of the page param, `reverse` for chat,
|
|
9
|
+
// `manual`/`manualAfter`, works in any scroll container, `reset` on filter
|
|
10
|
+
// change. Each page streams as server components."
|
|
11
|
+
//
|
|
12
|
+
// Built on what is already there. A page is loaded as a background partial
|
|
13
|
+
// reload of the list's segment at the page's URL, with `preserveUrl`: the
|
|
14
|
+
// server runs the page's whole pipeline -- every check and loader -- and
|
|
15
|
+
// renders the segment, the list's items among it, as server components;
|
|
16
|
+
// <MergeSlot> adds them to those shown. Background, so it never cancels the
|
|
17
|
+
// reader's own navigation, a navigation cancels it, and none starts while
|
|
18
|
+
// one is pending. What starts a load automatically is the deferred
|
|
19
|
+
// boundaries' `visible` trigger, on an edge of the list. A filter that
|
|
20
|
+
// changes the list is a visit with `reset` naming it: the same partial
|
|
21
|
+
// reload, marked to replace.
|
|
22
|
+
//
|
|
23
|
+
// Which pages are loaded is state here, read from each response: the page
|
|
24
|
+
// it carried and the pages either side. A partial reload keeps this
|
|
25
|
+
// component, so the range grows; a reset or a whole page starts it again.
|
|
26
|
+
//
|
|
27
|
+
// A CursorScroll's pages have no numbers (ADR 0120). Each is read at a
|
|
28
|
+
// cursor -- the one before it handed out as `next`, or the one after it as
|
|
29
|
+
// `previous` -- so it is placed by that: after the last page shown, before
|
|
30
|
+
// the first, or, read again at its own cursor, where it already is. The
|
|
31
|
+
// page that arrived whole is place 0, and the places count out from it.
|
|
32
|
+
// One read at a cursor no page shown leads to -- a cursor that expired and
|
|
33
|
+
// was answered with the newest page -- cannot be placed among them, and
|
|
34
|
+
// the list starts again from it. <MergeSlot> asks here where each goes.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createContext,
|
|
38
|
+
createElement,
|
|
39
|
+
useCallback,
|
|
40
|
+
useContext,
|
|
41
|
+
useEffect,
|
|
42
|
+
useLayoutEffect,
|
|
43
|
+
useMemo,
|
|
44
|
+
useRef,
|
|
45
|
+
useState,
|
|
46
|
+
} from 'react';
|
|
47
|
+
import { onVisible } from './deferred-triggers.js';
|
|
48
|
+
import { useOptionalRouter } from './link.js';
|
|
49
|
+
import { SegmentContext } from './segment.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a Scroll page says about itself, without its items. A cursor page's
|
|
53
|
+
* `cursor` is the one it was read at, null for the first; its `previous`
|
|
54
|
+
* and `next` are cursors, and its `page` is 0 until it is placed.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {{
|
|
57
|
+
* id: string,
|
|
58
|
+
* page: number,
|
|
59
|
+
* cursor?: string | null,
|
|
60
|
+
* previous: number | string | null,
|
|
61
|
+
* next: number | string | null,
|
|
62
|
+
* total: number | null,
|
|
63
|
+
* perPage: number,
|
|
64
|
+
* pageName: string,
|
|
65
|
+
* reset: boolean,
|
|
66
|
+
* }} ScrollMeta
|
|
67
|
+
*
|
|
68
|
+
* @typedef {{ page: number, previous: number | string | null, next: number | string | null }} Edge
|
|
69
|
+
*
|
|
70
|
+
* @typedef {{
|
|
71
|
+
* id: string,
|
|
72
|
+
* reverse: boolean,
|
|
73
|
+
* loading: 'next' | 'previous' | null,
|
|
74
|
+
* manual: boolean,
|
|
75
|
+
* hasNext: boolean,
|
|
76
|
+
* hasPrevious: boolean,
|
|
77
|
+
* first: number,
|
|
78
|
+
* last: number,
|
|
79
|
+
* total: number | null,
|
|
80
|
+
* perPage: number,
|
|
81
|
+
* loadNext: () => Promise<boolean>,
|
|
82
|
+
* loadPrevious: () => Promise<boolean>,
|
|
83
|
+
* epoch: number,
|
|
84
|
+
* place: (cursor: string | null) => number | undefined,
|
|
85
|
+
* }} InfiniteScrollState
|
|
86
|
+
*
|
|
87
|
+
* `epoch` counts the times the list started again, and `place` says where
|
|
88
|
+
* a cursor page read at a cursor goes: what <MergeSlot> reads for a page
|
|
89
|
+
* with no number.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
export const InfiniteScrollContext = createContext(/** @type {InfiniteScrollState | null} */ (null));
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The nearest <InfiniteScroll>'s state and its two loads, for a client
|
|
96
|
+
* component of the app's -- a "Load more" button, a count of what is shown.
|
|
97
|
+
*
|
|
98
|
+
* @returns {InfiniteScrollState}
|
|
99
|
+
*/
|
|
100
|
+
export function useInfiniteScroll() {
|
|
101
|
+
const state = useContext(InfiniteScrollContext);
|
|
102
|
+
if (state === null) throw new Error('[tinvar] useInfiniteScroll() needs an <InfiniteScroll> above it.');
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {ScrollMeta} meta @param {number} [page] @returns {Edge} */
|
|
107
|
+
const edgeOf = (meta, page = meta.page) => ({ page, previous: meta.previous, next: meta.next });
|
|
108
|
+
|
|
109
|
+
/** What a cursor page's place is kept under: its cursor, or '' for the first page's none. */
|
|
110
|
+
const placeKey = (/** @type {string | null | undefined} */ cursor) => cursor ?? '';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where a cursor page goes among those shown: where it already is, read
|
|
114
|
+
* again at its own cursor; after the last, read at its `next`; before the
|
|
115
|
+
* first, read at its `previous`. Undefined for one none of them leads to.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ first: Edge, last: Edge, places: ReadonlyMap<string, number> }} range
|
|
118
|
+
* @param {string | null} cursor
|
|
119
|
+
* @returns {number | undefined}
|
|
120
|
+
*/
|
|
121
|
+
export function placeOf(range, cursor) {
|
|
122
|
+
const known = range.places.get(placeKey(cursor));
|
|
123
|
+
if (known !== undefined) return known;
|
|
124
|
+
if (cursor === null) return undefined;
|
|
125
|
+
if (cursor === range.last.next) return range.last.page + 1;
|
|
126
|
+
if (cursor === range.first.previous) return range.first.page - 1;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The range after one more page arrives.
|
|
132
|
+
*
|
|
133
|
+
* @param {{ first: Edge, last: Edge }} range
|
|
134
|
+
* @param {Edge} edge
|
|
135
|
+
* @returns {{ first: Edge, last: Edge }}
|
|
136
|
+
*/
|
|
137
|
+
export function extendRange(range, edge) {
|
|
138
|
+
return {
|
|
139
|
+
first: edge.page <= range.first.page ? edge : range.first,
|
|
140
|
+
last: edge.page >= range.last.page ? edge : range.last,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The nearest ancestor that scrolls vertically, or null for the document.
|
|
146
|
+
*
|
|
147
|
+
* @param {Element | null} element
|
|
148
|
+
* @returns {Element | null}
|
|
149
|
+
*/
|
|
150
|
+
export function scrollContainerOf(element) {
|
|
151
|
+
for (let node = element?.parentElement ?? null; node !== null; node = node.parentElement) {
|
|
152
|
+
if (node === node.ownerDocument.body || node === node.ownerDocument.documentElement) return null;
|
|
153
|
+
const overflow = node.ownerDocument.defaultView?.getComputedStyle(node).overflowY;
|
|
154
|
+
if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') return node;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How many pages either side of the one the reader was at are loaded again
|
|
161
|
+
* when they come back to it: as many as were loaded, up to this. A reader
|
|
162
|
+
* who had scrolled through fifty pages is not made to wait for fifty
|
|
163
|
+
* requests; those further off load as the edges come into view, as ever.
|
|
164
|
+
*/
|
|
165
|
+
export const RESTORE_PAGES = 5;
|
|
166
|
+
|
|
167
|
+
/** How long the reader is held at the item they were at while what is above it settles, in milliseconds. */
|
|
168
|
+
const SETTLE_MS = 1000;
|
|
169
|
+
|
|
170
|
+
/** How many times, and how far apart, a page the reader had is asked for before it is given up on. */
|
|
171
|
+
const RESTORE_TRIES = 20;
|
|
172
|
+
const RESTORE_RETRY_MS = 100;
|
|
173
|
+
|
|
174
|
+
/** Input that means the reader is moving the list, and holding them should stop. */
|
|
175
|
+
const USER_SCROLL_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @typedef {{ page: number, element: Element, key: string | null, index: number, cursor: string | null }} Item
|
|
179
|
+
* @typedef {import('./router.js').ScrollAnchor} ScrollAnchor
|
|
180
|
+
*
|
|
181
|
+
* An item's `cursor` is its cursor page's, as the address bar names it: null
|
|
182
|
+
* for a numbered page's, and for the first cursor page's, read at none.
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The list's items in the DOM, each with its page, in document order: every
|
|
187
|
+
* element after a page's marker up to the next one. Each with the key it
|
|
188
|
+
* was rendered under, from the marker, when the page drew one element per
|
|
189
|
+
* item and the two can be paired.
|
|
190
|
+
*
|
|
191
|
+
* @param {Element} root
|
|
192
|
+
* @param {string} id
|
|
193
|
+
* @returns {Item[]}
|
|
194
|
+
*/
|
|
195
|
+
export function itemsOf(root, id) {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const marker of root.querySelectorAll('template[data-tinvar-merge]')) {
|
|
198
|
+
if (marker.getAttribute('data-tinvar-merge') !== id) continue;
|
|
199
|
+
const page = Number(marker.getAttribute('data-page'));
|
|
200
|
+
const cursor = marker.getAttribute('data-cursor') || null;
|
|
201
|
+
const elements = [];
|
|
202
|
+
for (let node = marker.nextElementSibling; node !== null; node = node.nextElementSibling) {
|
|
203
|
+
if (node.matches('template[data-tinvar-merge]')) break;
|
|
204
|
+
elements.push(node);
|
|
205
|
+
}
|
|
206
|
+
const keys = keysOf(marker);
|
|
207
|
+
const paired = keys !== null && keys.length === elements.length;
|
|
208
|
+
elements.forEach((element, index) =>
|
|
209
|
+
out.push({ page, element, key: paired ? /** @type {string} */ (keys[index]) : null, index, cursor }),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The item keys a page marker lists, or null where it lists none it can be
|
|
217
|
+
* read for.
|
|
218
|
+
*
|
|
219
|
+
* @param {Element} marker
|
|
220
|
+
* @returns {string[] | null}
|
|
221
|
+
*/
|
|
222
|
+
function keysOf(marker) {
|
|
223
|
+
try {
|
|
224
|
+
const keys = JSON.parse(marker.getAttribute('data-keys') ?? 'null');
|
|
225
|
+
return Array.isArray(keys) && keys.every((key) => typeof key === 'string') ? keys : null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The top of what the reader sees of a list: its scroll container's, or the window's. */
|
|
232
|
+
function topOf(/** @type {Element | null} */ container) {
|
|
233
|
+
return container === null ? 0 : container.getBoundingClientRect().top;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The first item the reader can see -- the page they are reading -- or
|
|
238
|
+
* null when none is in view.
|
|
239
|
+
*
|
|
240
|
+
* @param {Item[]} items
|
|
241
|
+
* @param {Element | null} container
|
|
242
|
+
* @returns {(Item & { top: number }) | null}
|
|
243
|
+
*/
|
|
244
|
+
function firstVisible(items, container) {
|
|
245
|
+
const top = topOf(container);
|
|
246
|
+
const bottom = container === null ? globalThis.innerHeight : container.getBoundingClientRect().bottom;
|
|
247
|
+
for (const item of items) {
|
|
248
|
+
const rect = item.element.getBoundingClientRect();
|
|
249
|
+
if (rect.bottom > top && rect.top < bottom) return { ...item, top: rect.top };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Where the reader is in a list: the first item they can see, how far down
|
|
256
|
+
* the window or the container its top is, and the pages loaded. Null when
|
|
257
|
+
* no item is in view.
|
|
258
|
+
*
|
|
259
|
+
* A cursor list's places count from the page that arrived whole, and that
|
|
260
|
+
* is the page the address bar names when the reader comes back: so they
|
|
261
|
+
* are kept counted from it -- `base`, the place of the page the bar names
|
|
262
|
+
* -- and the page that arrives then is 0 again.
|
|
263
|
+
*
|
|
264
|
+
* @param {Element} root
|
|
265
|
+
* @param {string} id
|
|
266
|
+
* @param {Element | null} container
|
|
267
|
+
* @param {{ first: { page: number }, last: { page: number } }} range
|
|
268
|
+
* @param {((visible: Item) => number) | null} [base] the place the bar's page is at, for a cursor list
|
|
269
|
+
* @returns {ScrollAnchor | null}
|
|
270
|
+
*/
|
|
271
|
+
export function anchorOf(root, id, container, range, base = null) {
|
|
272
|
+
const visible = firstVisible(itemsOf(root, id), container);
|
|
273
|
+
if (visible === null) return null;
|
|
274
|
+
const from = base === null ? 0 : base(visible);
|
|
275
|
+
return {
|
|
276
|
+
page: visible.page - from,
|
|
277
|
+
key: visible.key,
|
|
278
|
+
index: visible.index,
|
|
279
|
+
top: visible.top - topOf(container),
|
|
280
|
+
first: range.first.page - from,
|
|
281
|
+
last: range.last.page - from,
|
|
282
|
+
window: container === null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Scrolls the list so the item an anchor names is where it was: found by
|
|
288
|
+
* its key, or failing that by its place on its page.
|
|
289
|
+
*
|
|
290
|
+
* @param {Element} root
|
|
291
|
+
* @param {string} id
|
|
292
|
+
* @param {Element | null} container
|
|
293
|
+
* @param {ScrollAnchor} anchor
|
|
294
|
+
* @returns {boolean} whether the item is there to scroll to
|
|
295
|
+
*/
|
|
296
|
+
export function scrollToAnchor(root, id, container, anchor) {
|
|
297
|
+
const items = itemsOf(root, id);
|
|
298
|
+
const item =
|
|
299
|
+
(anchor.key === null ? undefined : items.find((one) => one.key === anchor.key)) ??
|
|
300
|
+
items.find((one) => one.page === anchor.page && one.index === anchor.index);
|
|
301
|
+
if (item === undefined) return false;
|
|
302
|
+
|
|
303
|
+
const moved = item.element.getBoundingClientRect().top - topOf(container) - anchor.top;
|
|
304
|
+
if (Math.abs(moved) >= 1) {
|
|
305
|
+
if (container === null) globalThis.scrollBy(0, moved);
|
|
306
|
+
else container.scrollTop += moved;
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function noop() {}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Puts the reader at an anchor's item, and keeps them there for a moment
|
|
315
|
+
* while what is above the list settles -- a stream still arriving, a font
|
|
316
|
+
* -- unless they take over the scroll themselves first.
|
|
317
|
+
*
|
|
318
|
+
* @param {Element} root
|
|
319
|
+
* @param {string} id
|
|
320
|
+
* @param {Element | null} container
|
|
321
|
+
* @param {ScrollAnchor} anchor
|
|
322
|
+
* @returns {() => void} stops holding
|
|
323
|
+
*/
|
|
324
|
+
function holdAt(root, id, container, anchor) {
|
|
325
|
+
const started = Date.now();
|
|
326
|
+
let frame = 0;
|
|
327
|
+
const stop = () => {
|
|
328
|
+
cancelAnimationFrame(frame);
|
|
329
|
+
for (const type of USER_SCROLL_EVENTS) globalThis.removeEventListener(type, stop, true);
|
|
330
|
+
};
|
|
331
|
+
const hold = () => {
|
|
332
|
+
if (!scrollToAnchor(root, id, container, anchor) || Date.now() - started > SETTLE_MS) return stop();
|
|
333
|
+
frame = requestAnimationFrame(hold);
|
|
334
|
+
};
|
|
335
|
+
for (const type of USER_SCROLL_EVENTS)
|
|
336
|
+
globalThis.addEventListener(type, stop, { capture: true, passive: true });
|
|
337
|
+
hold();
|
|
338
|
+
return stop;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @param {{
|
|
343
|
+
* field: ScrollMeta,
|
|
344
|
+
* buffer?: number,
|
|
345
|
+
* manual?: boolean,
|
|
346
|
+
* manualAfter?: number,
|
|
347
|
+
* reverse?: boolean,
|
|
348
|
+
* preserveUrl?: boolean,
|
|
349
|
+
* previous?: unknown,
|
|
350
|
+
* next?: unknown,
|
|
351
|
+
* children?: unknown,
|
|
352
|
+
* }} props
|
|
353
|
+
*/
|
|
354
|
+
export function InfiniteScrollRoot({
|
|
355
|
+
field,
|
|
356
|
+
buffer = 0,
|
|
357
|
+
manual = false,
|
|
358
|
+
manualAfter = 0,
|
|
359
|
+
reverse = false,
|
|
360
|
+
preserveUrl = false,
|
|
361
|
+
previous = null,
|
|
362
|
+
next = null,
|
|
363
|
+
children,
|
|
364
|
+
}) {
|
|
365
|
+
const router = /** @type {import('./router.js').Router | null} */ (useOptionalRouter());
|
|
366
|
+
const generation = useContext(SegmentContext)?.generation ?? 0;
|
|
367
|
+
|
|
368
|
+
const rootRef = useRef(/** @type {HTMLDivElement | null} */ (null));
|
|
369
|
+
const startRef = useRef(/** @type {HTMLDivElement | null} */ (null));
|
|
370
|
+
const endRef = useRef(/** @type {HTMLDivElement | null} */ (null));
|
|
371
|
+
|
|
372
|
+
// Pages loaded by scrolling, for `manualAfter`; and every page loaded,
|
|
373
|
+
// which re-arms the triggers.
|
|
374
|
+
const [automatic, setAutomatic] = useState(0);
|
|
375
|
+
const [settled, setSettled] = useState(0);
|
|
376
|
+
|
|
377
|
+
// The pages loaded, from what each response said. Derived as it renders,
|
|
378
|
+
// so the range never commits behind the items <MergeSlot> shows. `epoch`
|
|
379
|
+
// counts the times it started again -- a reset, a whole page, a cursor
|
|
380
|
+
// page that cannot be placed -- which also starts the count of automatic
|
|
381
|
+
// loads again. `places` are where each cursor page went, by its cursor.
|
|
382
|
+
const byCursor = field.cursor !== undefined;
|
|
383
|
+
const [range, setRange] = useState(() => ({
|
|
384
|
+
field,
|
|
385
|
+
generation,
|
|
386
|
+
epoch: 0,
|
|
387
|
+
first: edgeOf(field),
|
|
388
|
+
last: edgeOf(field),
|
|
389
|
+
places: /** @type {ReadonlyMap<string, number>} */ (
|
|
390
|
+
new Map(byCursor ? [[placeKey(field.cursor), 0]] : [])
|
|
391
|
+
),
|
|
392
|
+
}));
|
|
393
|
+
let current = range;
|
|
394
|
+
if (range.field !== field || range.generation !== generation) {
|
|
395
|
+
const placed = byCursor ? placeOf(range, field.cursor ?? null) : field.page;
|
|
396
|
+
const fresh = field.reset || range.generation !== generation || placed === undefined;
|
|
397
|
+
if (fresh) {
|
|
398
|
+
current = {
|
|
399
|
+
field,
|
|
400
|
+
generation,
|
|
401
|
+
epoch: range.epoch + 1,
|
|
402
|
+
first: edgeOf(field, byCursor ? 0 : field.page),
|
|
403
|
+
last: edgeOf(field, byCursor ? 0 : field.page),
|
|
404
|
+
places: new Map(byCursor ? [[placeKey(field.cursor), 0]] : []),
|
|
405
|
+
};
|
|
406
|
+
} else {
|
|
407
|
+
const edge = edgeOf(field, placed);
|
|
408
|
+
current = {
|
|
409
|
+
field,
|
|
410
|
+
generation,
|
|
411
|
+
epoch: range.epoch,
|
|
412
|
+
...extendRange(range, edge),
|
|
413
|
+
places: byCursor ? new Map(range.places).set(placeKey(field.cursor), placed) : range.places,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
setRange(current);
|
|
417
|
+
if (fresh && automatic !== 0) setAutomatic(0);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const [loading, setLoading] = useState(/** @type {'next' | 'previous' | null} */ (null));
|
|
421
|
+
const busy = useRef(/** @type {'next' | 'previous' | null} */ (null));
|
|
422
|
+
|
|
423
|
+
const switched = manual || (manualAfter > 0 && automatic >= manualAfter);
|
|
424
|
+
|
|
425
|
+
// Where the reader was when they left the entry a back, a forward or a
|
|
426
|
+
// reload has brought them to, while the pages they had are loaded again
|
|
427
|
+
// and they are put back at the item they were at. Nothing loads on its
|
|
428
|
+
// own meanwhile, the address bar stays, and nothing is recorded: the list
|
|
429
|
+
// passes through places the reader never was.
|
|
430
|
+
const [restoring, setRestoring] = useState(/** @type {ScrollAnchor | null} */ (null));
|
|
431
|
+
const restoringRef = useRef(/** @type {ScrollAnchor | null} */ (null));
|
|
432
|
+
|
|
433
|
+
// The range as last committed, for what runs across several loads.
|
|
434
|
+
const rangeRef = useRef(current);
|
|
435
|
+
useLayoutEffect(() => {
|
|
436
|
+
rangeRef.current = current;
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Loads one page beyond an edge: a background partial reload of the
|
|
441
|
+
* list's segment at that page's URL, merged into the page on screen.
|
|
442
|
+
*
|
|
443
|
+
* @param {'next' | 'previous'} direction
|
|
444
|
+
* @param {boolean} [auto]
|
|
445
|
+
*/
|
|
446
|
+
const load = useCallback(
|
|
447
|
+
async (direction, auto = false) => {
|
|
448
|
+
const target = direction === 'next' ? current.last.next : current.first.previous;
|
|
449
|
+
if (router === null || target === null || busy.current !== null) return false;
|
|
450
|
+
|
|
451
|
+
const url = new URL(globalThis.location.href);
|
|
452
|
+
url.searchParams.set(field.pageName, String(target));
|
|
453
|
+
|
|
454
|
+
// What goes in above what the reader sees -- the previous page, or in
|
|
455
|
+
// a chat the next -- would push it down. The first item in view is
|
|
456
|
+
// held where it was.
|
|
457
|
+
const above = reverse ? direction === 'next' : direction === 'previous';
|
|
458
|
+
const container = scrollContainerOf(rootRef.current);
|
|
459
|
+
const anchor =
|
|
460
|
+
above && rootRef.current ? firstVisible(itemsOf(rootRef.current, field.id), container) : null;
|
|
461
|
+
|
|
462
|
+
busy.current = direction;
|
|
463
|
+
setLoading(direction);
|
|
464
|
+
try {
|
|
465
|
+
const loaded = await router.visit(url.href, {
|
|
466
|
+
only: [field.id],
|
|
467
|
+
background: true,
|
|
468
|
+
preserveUrl: true,
|
|
469
|
+
preserveScroll: true,
|
|
470
|
+
});
|
|
471
|
+
if (!loaded) return false;
|
|
472
|
+
|
|
473
|
+
if (anchor !== null && anchor.element.isConnected) {
|
|
474
|
+
const moved = anchor.element.getBoundingClientRect().top - anchor.top;
|
|
475
|
+
if (moved !== 0) (container ?? globalThis).scrollBy(0, moved);
|
|
476
|
+
}
|
|
477
|
+
// Only a page that landed re-arms the triggers. One that did not --
|
|
478
|
+
// a navigation pending, the network down -- is tried again when the
|
|
479
|
+
// edge next comes into view, or when asked: re-armed at once, it
|
|
480
|
+
// asked again every frame for as long as the reason lasted.
|
|
481
|
+
if (auto) setAutomatic((n) => n + 1);
|
|
482
|
+
setSettled((n) => n + 1);
|
|
483
|
+
return true;
|
|
484
|
+
} finally {
|
|
485
|
+
busy.current = null;
|
|
486
|
+
setLoading(null);
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
[router, current, field.pageName, field.id, reverse],
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const loadNext = useCallback(() => load('next'), [load]);
|
|
493
|
+
const loadPrevious = useCallback(() => load('previous'), [load]);
|
|
494
|
+
|
|
495
|
+
const loadRef = useRef(load);
|
|
496
|
+
useLayoutEffect(() => {
|
|
497
|
+
loadRef.current = load;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Where the reader was, on the entry a back, a forward or a reload
|
|
501
|
+
// arrived at: taken as the list mounts, or starts again for a whole page,
|
|
502
|
+
// and the reader put at that item at once if its page is the one the
|
|
503
|
+
// address bar named -- as it is when the bar followed them. A raw scroll
|
|
504
|
+
// position cannot do this: the page arrives holding that one page, and
|
|
505
|
+
// the same offset lands a page or more from where they were. Before the
|
|
506
|
+
// chat's own start at the bottom, which it replaces.
|
|
507
|
+
useLayoutEffect(() => {
|
|
508
|
+
if (router === null || typeof router.takeScrollAnchor !== 'function' || !rootRef.current) return;
|
|
509
|
+
// With the container it puts the reader back in: should that be a
|
|
510
|
+
// scroll region, the router leaves its raw position to the list.
|
|
511
|
+
const container = scrollContainerOf(rootRef.current);
|
|
512
|
+
const anchor = router.takeScrollAnchor(field.id, container);
|
|
513
|
+
if (anchor === undefined) return;
|
|
514
|
+
restoringRef.current = anchor;
|
|
515
|
+
setRestoring(anchor);
|
|
516
|
+
scrollToAnchor(rootRef.current, field.id, container, anchor);
|
|
517
|
+
}, [router, field.id, current.epoch]);
|
|
518
|
+
|
|
519
|
+
// Then the pages the reader had loaded, those above first -- each held
|
|
520
|
+
// in place as it goes in -- up to RESTORE_PAGES either side, and the
|
|
521
|
+
// reader put back at the item once they are in, and held there while
|
|
522
|
+
// what is above the list settles.
|
|
523
|
+
const holding = useRef(noop);
|
|
524
|
+
useEffect(() => () => holding.current(), []);
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (restoring === null) return undefined;
|
|
527
|
+
const epoch = rangeRef.current.epoch;
|
|
528
|
+
const first = Math.max(restoring.first, restoring.page - RESTORE_PAGES);
|
|
529
|
+
const last = Math.min(restoring.last, restoring.page + RESTORE_PAGES);
|
|
530
|
+
let stopped = false;
|
|
531
|
+
// A filter that starts the list again meanwhile ends it: the pages the
|
|
532
|
+
// reader had are not this list's.
|
|
533
|
+
const going = () => !stopped && rangeRef.current.epoch === epoch;
|
|
534
|
+
holding.current();
|
|
535
|
+
|
|
536
|
+
// A page is not loaded while a navigation is pending, and the back that
|
|
537
|
+
// brought the reader here is one until it has finished mounting this
|
|
538
|
+
// list: so each is asked for again a moment later, for a while.
|
|
539
|
+
const step = async (/** @type {'next' | 'previous'} */ direction) => {
|
|
540
|
+
for (let tries = 0; tries < RESTORE_TRIES && going(); tries++) {
|
|
541
|
+
if (await loadRef.current(direction)) return true;
|
|
542
|
+
await new Promise((resolve) => setTimeout(resolve, RESTORE_RETRY_MS));
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
void (async () => {
|
|
548
|
+
while (going() && rangeRef.current.first.page > first && rangeRef.current.first.previous !== null) {
|
|
549
|
+
if (!(await step('previous'))) break;
|
|
550
|
+
}
|
|
551
|
+
while (going() && rangeRef.current.last.page < last && rangeRef.current.last.next !== null) {
|
|
552
|
+
if (!(await step('next'))) break;
|
|
553
|
+
}
|
|
554
|
+
if (stopped) return;
|
|
555
|
+
if (going() && rootRef.current) {
|
|
556
|
+
holding.current = holdAt(rootRef.current, field.id, scrollContainerOf(rootRef.current), restoring);
|
|
557
|
+
}
|
|
558
|
+
restoringRef.current = null;
|
|
559
|
+
setRestoring(null);
|
|
560
|
+
})();
|
|
561
|
+
|
|
562
|
+
return () => {
|
|
563
|
+
stopped = true;
|
|
564
|
+
};
|
|
565
|
+
}, [restoring, field.id]);
|
|
566
|
+
|
|
567
|
+
// The two edges' triggers: `visible`, re-installed after every load, so
|
|
568
|
+
// an edge still in view once a page lands -- a short page, a tall
|
|
569
|
+
// screen -- loads the next at once rather than waiting to be scrolled
|
|
570
|
+
// away from and back to.
|
|
571
|
+
useEffect(() => {
|
|
572
|
+
if (router === null || switched || restoring !== null) return undefined;
|
|
573
|
+
const container = scrollContainerOf(rootRef.current);
|
|
574
|
+
const options = { buffer, always: true, root: container };
|
|
575
|
+
const teardowns = [];
|
|
576
|
+
|
|
577
|
+
const top = reverse ? 'next' : 'previous';
|
|
578
|
+
const bottom = reverse ? 'previous' : 'next';
|
|
579
|
+
const has = (/** @type {'next' | 'previous'} */ direction) =>
|
|
580
|
+
(direction === 'next' ? current.last.next : current.first.previous) !== null;
|
|
581
|
+
// A restore taken since this was installed has the list to itself.
|
|
582
|
+
const auto = (/** @type {'next' | 'previous'} */ direction) => () => {
|
|
583
|
+
if (restoringRef.current === null) void load(direction, true);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
if (startRef.current && has(top)) teardowns.push(onVisible(startRef.current, auto(top), options));
|
|
587
|
+
if (endRef.current && has(bottom)) teardowns.push(onVisible(endRef.current, auto(bottom), options));
|
|
588
|
+
|
|
589
|
+
return () => {
|
|
590
|
+
for (const teardown of teardowns) teardown();
|
|
591
|
+
};
|
|
592
|
+
}, [router, switched, restoring, buffer, reverse, current, load, settled]);
|
|
593
|
+
|
|
594
|
+
// A chat starts at its newest message, at the bottom, and starts there
|
|
595
|
+
// again when a filter resets it.
|
|
596
|
+
useLayoutEffect(() => {
|
|
597
|
+
if (!reverse || !rootRef.current || restoringRef.current !== null) return;
|
|
598
|
+
const container = scrollContainerOf(rootRef.current);
|
|
599
|
+
if (container === null)
|
|
600
|
+
globalThis.scrollTo(0, rootRef.current.ownerDocument.documentElement.scrollHeight);
|
|
601
|
+
else container.scrollTop = container.scrollHeight;
|
|
602
|
+
}, [reverse, current.epoch]);
|
|
603
|
+
|
|
604
|
+
// Where the reader is, on the page's history entry, as they scroll and as
|
|
605
|
+
// pages land: what a back, a forward or a reload puts them back by.
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
if (router === null || typeof router.saveScrollAnchor !== 'function' || !rootRef.current) {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
const root = rootRef.current;
|
|
611
|
+
const container = scrollContainerOf(root);
|
|
612
|
+
const target = container ?? globalThis;
|
|
613
|
+
let frame = 0;
|
|
614
|
+
|
|
615
|
+
// A cursor list counts from the page the bar names: the reader's own,
|
|
616
|
+
// which URL sync puts there, or -- the bar left alone -- the page that
|
|
617
|
+
// arrived whole.
|
|
618
|
+
const base = !byCursor
|
|
619
|
+
? null
|
|
620
|
+
: (/** @type {Item} */ visible) =>
|
|
621
|
+
preserveUrl
|
|
622
|
+
? (rangeRef.current.places.get(
|
|
623
|
+
placeKey(new URL(globalThis.location.href).searchParams.get(field.pageName)),
|
|
624
|
+
) ?? 0)
|
|
625
|
+
: visible.page;
|
|
626
|
+
const save = () => {
|
|
627
|
+
frame = 0;
|
|
628
|
+
if (restoringRef.current !== null) return;
|
|
629
|
+
const anchor = anchorOf(root, field.id, container, rangeRef.current, base);
|
|
630
|
+
if (anchor !== null) router.saveScrollAnchor(field.id, anchor);
|
|
631
|
+
};
|
|
632
|
+
const onScroll = () => {
|
|
633
|
+
if (frame === 0) frame = requestAnimationFrame(save);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
637
|
+
onScroll();
|
|
638
|
+
return () => {
|
|
639
|
+
target.removeEventListener('scroll', onScroll);
|
|
640
|
+
if (frame !== 0) cancelAnimationFrame(frame);
|
|
641
|
+
};
|
|
642
|
+
}, [router, field.id, field.pageName, byCursor, preserveUrl, settled, restoring]);
|
|
643
|
+
|
|
644
|
+
// URL sync: the address bar names the page the reader is in, as they
|
|
645
|
+
// scroll, without a request or a new entry.
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
if (router === null || preserveUrl || !rootRef.current) return undefined;
|
|
648
|
+
const root = rootRef.current;
|
|
649
|
+
const container = scrollContainerOf(root);
|
|
650
|
+
const target = container ?? globalThis;
|
|
651
|
+
let frame = 0;
|
|
652
|
+
|
|
653
|
+
const sync = () => {
|
|
654
|
+
frame = 0;
|
|
655
|
+
if (restoringRef.current !== null) return;
|
|
656
|
+
const visible = firstVisible(itemsOf(root, field.id), container);
|
|
657
|
+
if (visible === null) return;
|
|
658
|
+
const url = new URL(globalThis.location.href);
|
|
659
|
+
const named = url.searchParams.get(field.pageName);
|
|
660
|
+
// A cursor page is named by the cursor it was read at -- the first by
|
|
661
|
+
// none -- and a numbered one by its number, the first by none.
|
|
662
|
+
const wanted = byCursor ? visible.cursor : visible.page === 1 ? null : String(visible.page);
|
|
663
|
+
if (named === wanted) return;
|
|
664
|
+
if (wanted === null) url.searchParams.delete(field.pageName);
|
|
665
|
+
else url.searchParams.set(field.pageName, wanted);
|
|
666
|
+
router.replaceUrl(url.href);
|
|
667
|
+
};
|
|
668
|
+
const onScroll = () => {
|
|
669
|
+
if (frame === 0) frame = requestAnimationFrame(sync);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
673
|
+
return () => {
|
|
674
|
+
target.removeEventListener('scroll', onScroll);
|
|
675
|
+
if (frame !== 0) cancelAnimationFrame(frame);
|
|
676
|
+
};
|
|
677
|
+
}, [router, preserveUrl, byCursor, field.id, field.pageName]);
|
|
678
|
+
|
|
679
|
+
const state = useMemo(
|
|
680
|
+
() => ({
|
|
681
|
+
id: field.id,
|
|
682
|
+
reverse,
|
|
683
|
+
loading,
|
|
684
|
+
manual: switched,
|
|
685
|
+
hasNext: current.last.next !== null,
|
|
686
|
+
hasPrevious: current.first.previous !== null,
|
|
687
|
+
first: current.first.page,
|
|
688
|
+
last: current.last.page,
|
|
689
|
+
total: field.total,
|
|
690
|
+
perPage: field.perPage,
|
|
691
|
+
loadNext,
|
|
692
|
+
loadPrevious,
|
|
693
|
+
epoch: current.epoch,
|
|
694
|
+
place: (/** @type {string | null} */ cursor) => current.places.get(placeKey(cursor)),
|
|
695
|
+
}),
|
|
696
|
+
[field.id, field.total, field.perPage, reverse, loading, switched, current, loadNext, loadPrevious],
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const sentinel = (/** @type {typeof startRef} */ ref, /** @type {string} */ edge) =>
|
|
700
|
+
createElement('div', { ref, 'data-tinvar-scroll-edge': edge, 'aria-hidden': true, style: { height: 0 } });
|
|
701
|
+
|
|
702
|
+
return createElement(
|
|
703
|
+
InfiniteScrollContext.Provider,
|
|
704
|
+
{ value: state },
|
|
705
|
+
createElement(
|
|
706
|
+
'div',
|
|
707
|
+
{ ref: rootRef, 'data-tinvar-infinite-scroll': field.id },
|
|
708
|
+
sentinel(startRef, 'start'),
|
|
709
|
+
reverse ? next : previous,
|
|
710
|
+
children,
|
|
711
|
+
reverse ? previous : next,
|
|
712
|
+
sentinel(endRef, 'end'),
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
}
|