@tinvar/react 0.0.0-stage → 0.4.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. 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
+ }