@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.
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,646 @@
1
+ 'use client';
2
+
3
+ // The browser half of <Deferred>: a client component that renders the
4
+ // fallback until its trigger fires, then the fragment, through Suspense.
5
+ //
6
+ // The boundary used to be a custom element the server emitted and an
7
+ // imperative runtime found, attached and mounted into from outside React.
8
+ // That made every lifecycle question the runtime's to answer by hand --
9
+ // attaching on each navigation, tearing down on each page left, keeping a
10
+ // boundary whose element React reused, telling a re-issued token from new
11
+ // content -- and each answer had defects. As a component, React answers
12
+ // them: a boundary mounts with its page, unmounts with it, and is kept
13
+ // across re-renders like any other component.
14
+ //
15
+ // Turning the fragment's RSC stream into elements needs the app's own
16
+ // client bundle -- the module instance that hydrated the page -- so this
17
+ // module cannot import a decoder. The app supplies one through
18
+ // <FragmentProvider>.
19
+
20
+ import {
21
+ Component,
22
+ Suspense,
23
+ createContext,
24
+ createElement,
25
+ startTransition,
26
+ use,
27
+ useContext,
28
+ useEffect,
29
+ useMemo,
30
+ useRef,
31
+ useState,
32
+ useSyncExternalStore,
33
+ } from 'react';
34
+ import { FragmentError, FragmentHandle, serverFailure, tokenIdentity } from './fragment-client.js';
35
+ import { installTrigger } from './deferred-triggers.js';
36
+ import { AT_REST as NOT_REVALIDATING, onRevalidation, revalidationState } from './deferred-revalidation.js';
37
+ import { FragmentGroups } from './deferred-groups.js';
38
+ import { revokeSession } from './router.js';
39
+ import { reloadUnlessLoading } from './document-load.js';
40
+ import { useOptionalRouter } from './link.js';
41
+ import { subscribeLive } from './live.js';
42
+
43
+ /** A revalidation's load has been asked for, and not started yet. */
44
+ const AWAITED = Symbol('awaiting a revalidation load');
45
+
46
+ /** Dispatched on a boundary's element to load it; see loadDeferred(). */
47
+ export const LOAD_EVENT = 'tinvar:load';
48
+
49
+ const FragmentContext = createContext(null);
50
+
51
+ /** What useDeferred() answers outside any boundary: nothing is happening. */
52
+ const AT_REST = Object.freeze({ fetching: false, reloading: false, rescued: null, retry: () => {} });
53
+
54
+ const DeferredContext = createContext(AT_REST);
55
+
56
+ /**
57
+ * The state of the <Deferred> boundary a client component is rendered in --
58
+ * its fallback, its content, or its `fetching` or `rescue` slot.
59
+ *
60
+ * A hook rather than a render prop. <Deferred> is a server component, and a
61
+ * function cannot cross from the server to the client tree, so a slot that
62
+ * needs to know what its boundary is doing asks for it here. It is the
63
+ * `{ fetching }` Inertia's <WhenVisible> hands its children.
64
+ *
65
+ * fetching a load is in flight, from the trigger until what it brought
66
+ * is on screen: the first load, an `always` refetch, a retry.
67
+ * The element carries `data-fetching` meanwhile.
68
+ * reloading the content on screen is about to be replaced: an action is
69
+ * revalidating the page, or the boundary is fetching the fresh
70
+ * content it brought back. Content dims by it; the boundary's
71
+ * element carries `data-reloading` meanwhile, for styles that
72
+ * would rather select on that.
73
+ * rescued `{ digest }` once the server's render threw and the boundary
74
+ * shows its `rescue` slot; the digest is what the failure was
75
+ * logged under, when the server said.
76
+ * retry loads the boundary again -- the way forward a rescue slot
77
+ * offers.
78
+ *
79
+ * @returns {{
80
+ * fetching: boolean,
81
+ * reloading: boolean,
82
+ * rescued: { digest: string | null } | null,
83
+ * retry: () => void,
84
+ * }}
85
+ */
86
+ export function useDeferred() {
87
+ return useContext(DeferredContext);
88
+ }
89
+
90
+ /**
91
+ * Gives every <Deferred> below it what it needs to load.
92
+ *
93
+ * `hardNavigate` is the full page load a 401 calls for, injectable as the
94
+ * router's is.
95
+ *
96
+ * @param {{
97
+ * decode: (stream: ReadableStream<Uint8Array>) => Promise<unknown>,
98
+ * fetch?: typeof fetch,
99
+ * onError?: (error: unknown, element: Element) => void,
100
+ * hardNavigate?: (url: string) => void,
101
+ * children?: unknown,
102
+ * }} props
103
+ */
104
+ export function FragmentProvider({ decode, fetch, onError, hardNavigate, children }) {
105
+ // The page's <Deferred group>s, kept for the provider's life: a request in
106
+ // flight belongs to the group, not to whichever render started it. The
107
+ // decoder is read when a group's request is made, as a boundary reads it.
108
+ const decoder = useRef(decode);
109
+ decoder.current = decode;
110
+
111
+ // The once-shared values the page holds, claimed by their holds as a
112
+ // navigation claims them, so a deferred component reading one is handed
113
+ // it as held (ADR 0074). Read from the router when each request is made:
114
+ // the page on screen, and its session, may have changed since.
115
+ const router = useOptionalRouter();
116
+ const routed = useRef(router);
117
+ routed.current = router;
118
+ const claims = useRef(() => {
119
+ const current = /** @type {{ sharedClaims?: () => Record<string, string> } | null} */ (routed.current);
120
+ return typeof current?.sharedClaims === 'function' ? current.sharedClaims() : {};
121
+ }).current;
122
+
123
+ const [groups] = useState(() => new FragmentGroups(() => decoder.current, claims));
124
+
125
+ const value = useMemo(
126
+ () => ({ decode, fetch, onError, hardNavigate, groups, claims }),
127
+ [decode, fetch, onError, hardNavigate, groups, claims],
128
+ );
129
+ return createElement(FragmentContext.Provider, { value }, children);
130
+ }
131
+
132
+ /**
133
+ * Loads a `load="manual"` boundary -- or any boundary, early.
134
+ *
135
+ * @param {string | Element} target the boundary's `id`, or its element
136
+ */
137
+ export function loadDeferred(target) {
138
+ const element = typeof target === 'string' ? globalThis.document?.getElementById(target) : target;
139
+ element?.dispatchEvent(new CustomEvent(LOAD_EVENT));
140
+ }
141
+
142
+ /**
143
+ * `rescue` is what the boundary shows once the server's render of its
144
+ * fragment throws -- "the client boundary renders its `rescue` slot instead
145
+ * of failing the page" (tinvar-spec.md, "Rescue and reloading"). Without
146
+ * one, such a boundary keeps its fallback and retries, as any failed load
147
+ * does. With one, it stops: the server has answered and reported it, and
148
+ * running a render that throws again on a timer only logs it again. The
149
+ * slot's own way forward is useDeferred().retry.
150
+ *
151
+ * `fetching` is shown beside the content while the boundary fetches it
152
+ * again -- an `always` refetch, a revalidation -- since the content stays up
153
+ * meanwhile and the fallback is for a boundary with nothing to show yet.
154
+ *
155
+ * Boundaries sharing a `group` load together: whichever is triggered first
156
+ * loads every one that has something to load, in one request (ADR 0018).
157
+ *
158
+ * @param {{
159
+ * token: string,
160
+ * load?: 'visible' | 'idle' | 'interaction' | 'manual',
161
+ * buffer?: number,
162
+ * always?: boolean,
163
+ * group?: string,
164
+ * id?: string,
165
+ * fallback?: unknown,
166
+ * fetching?: unknown,
167
+ * rescue?: unknown,
168
+ * }} props
169
+ */
170
+ export function DeferredBoundary({
171
+ token,
172
+ load = 'visible',
173
+ buffer,
174
+ always = false,
175
+ group,
176
+ id,
177
+ fallback = null,
178
+ fetching: refreshing = null,
179
+ rescue,
180
+ }) {
181
+ const context = useContext(FragmentContext);
182
+ const host = useRef(null);
183
+
184
+ // The provider's callbacks, read when they are needed rather than
185
+ // subscribed to: an app passing an inline onError or fetch re-ran the
186
+ // effect on every render, aborting the load in flight and refetching.
187
+ const provided = useRef(context);
188
+ provided.current = context;
189
+
190
+ // What the token is FOR, without when it was issued. Every render mints a
191
+ // fresh token, so a poll or a reload hands a kept boundary a new one for
192
+ // the same content: that keeps the handle -- its backoff, its rotation --
193
+ // and what is on screen. Different claims are a different boundary.
194
+ //
195
+ // In a ref, not a memo: React may discard a memo, and this one owns state
196
+ // -- the backoff, the rotated token, whether the content has loaded.
197
+ const identity = tokenIdentity(token);
198
+ const entry = useRef(null);
199
+ if (entry.current === null || entry.current.identity !== identity) {
200
+ entry.current = {
201
+ identity,
202
+ handle: new FragmentHandle(token, context?.fetch),
203
+ loaded: false,
204
+ pending: null,
205
+ last: null,
206
+ // The revalidation this boundary has caught up with, and the load it
207
+ // started to catch up -- reloading until that load is on screen.
208
+ revalidated: revalidationState().generation,
209
+ // The quiet refresh this boundary has answered: see refreshDeferred().
210
+ refreshed: revalidationState().quiet,
211
+ // The next repeat load is a quiet refresh: not a refetch to read
212
+ // reloading for. Set by the refresh, cleared by the load it asks for.
213
+ quiet: false,
214
+ refetch: null,
215
+ // Showing its rescue slot: a group request another boundary starts
216
+ // leaves it be, since only its own slot's retry asks again.
217
+ rescued: false,
218
+ };
219
+ }
220
+
221
+ // Server renders read AT_REST: nothing is revalidated before hydration.
222
+ const revalidation = useSyncExternalStore(onRevalidation, revalidationState, () => NOT_REVALIDATING);
223
+
224
+ // Starts a load past the "loaded once" rule, for a revalidation; set by
225
+ // the effect that owns the trigger, which owns the request's lifetime.
226
+ const refresh = useRef(null);
227
+
228
+ /** @type {[{ identity: string | null, result: Promise<{ node?: unknown }> } | null, Function]} */
229
+ const [shown, setShown] = useState(null);
230
+
231
+ // The load in flight, set as it starts -- urgently, where `shown` is set
232
+ // in a transition -- so the boundary says it is fetching at once, and
233
+ // stops saying so when that same load has settled and is the one on
234
+ // screen.
235
+ /** @type {[{ identity: string | null, result: Promise<unknown>, settled: boolean } | null, Function]} */
236
+ const [started, setStarted] = useState(null);
237
+
238
+ // Bumped after a failure to install the trigger again: `visible` and
239
+ // `interaction` fire once and tear down, so without it a failed boundary
240
+ // never retried.
241
+ const [armed, setArmed] = useState(0);
242
+
243
+ // Set once the server's render threw and the `rescue` slot is up; what
244
+ // useDeferred() hands that slot.
245
+ const [rescued, setRescued] = useState(null);
246
+
247
+ // The live token the content on screen came with: what its render read,
248
+ // which the live module listens for changes to (see the effect below).
249
+ const [live, setLive] = useState(null);
250
+
251
+ // Read by the load when it fails, not subscribed to, as the provider's
252
+ // callbacks are: a slot given or taken away must not abort a load.
253
+ const rescuable = useRef(false);
254
+ rescuable.current = rescue !== undefined;
255
+
256
+ useEffect(() => {
257
+ const { handle } = entry.current;
258
+ if (handle.token === token) return;
259
+
260
+ const gaveUp = handle.terminal;
261
+ handle.adopt(token);
262
+
263
+ // A handle that had given up is not re-armed after its last failure, so
264
+ // its trigger was gone: the fresh token adopt() clears the way for was
265
+ // never sent, and the boundary stayed on its fallback until a reload.
266
+ if (gaveUp) setArmed((n) => n + 1);
267
+ }, [token]);
268
+
269
+ const hasProvider = context !== null;
270
+ const groups = group ? (context?.groups ?? null) : null;
271
+
272
+ // What the trigger's effect knows how to do, for the group to call: it
273
+ // owns the load's lifetime, the group decides when it runs.
274
+ const loader = useRef(null);
275
+
276
+ // Joined before the trigger is installed, so a trigger that fires as it
277
+ // is installed already has a group to ask.
278
+ const member = useRef(null);
279
+ useEffect(() => {
280
+ if (groups === null) return undefined;
281
+ const joined = {
282
+ get handle() {
283
+ return entry.current.handle;
284
+ },
285
+ ready: (again, asked) => loader.current?.ready(again, asked) ?? false,
286
+ start: (again, loading) => void loader.current?.begin(again, loading),
287
+ skip: (again) => {
288
+ if (!again) return;
289
+ // Behind a load in flight, a revalidation waits for it rather than
290
+ // being dropped: see the catch-up below.
291
+ if (entry.current.refetch === AWAITED && entry.current.pending === null) entry.current.refetch = null;
292
+ // A quiet refresh its group did not start is spent all the same, or
293
+ // the next revalidation's load would be taken for a quiet one.
294
+ entry.current.quiet = false;
295
+ },
296
+ };
297
+ member.current = joined;
298
+ const leave = groups.join(group, joined);
299
+ return () => {
300
+ member.current = null;
301
+ leave();
302
+ };
303
+ }, [groups, group, identity]);
304
+
305
+ useEffect(() => {
306
+ const element = host.current;
307
+ const boundary = entry.current;
308
+ if (!element) return undefined;
309
+
310
+ if (!hasProvider) {
311
+ console.error('[tinvar:client] A <Deferred> boundary needs a <FragmentProvider> above it to load.');
312
+ return undefined;
313
+ }
314
+
315
+ const aborter = new AbortController();
316
+
317
+ /**
318
+ * @param {boolean} again load even what has loaded, for a revalidation
319
+ * @param {boolean} [asked] triggered itself, rather than by its group
320
+ */
321
+ const ready = (again, asked = true) => {
322
+ // One load at a time. The handle shares its request, so a second
323
+ // trigger while one is in flight decoded the SAME stream again --
324
+ // "ReadableStream is locked" -- and the boundary fell back.
325
+ if (boundary.pending || boundary.handle.terminal) return false;
326
+ if (boundary.rescued && !asked) return false;
327
+
328
+ // `always` boundaries refetch on each trigger; the rest load once.
329
+ return !boundary.loaded || always || again;
330
+ };
331
+
332
+ /**
333
+ * @param {boolean} again
334
+ * @param {Promise<unknown>} [loading] its share of a group's request
335
+ */
336
+ const begin = (again, loading) => {
337
+ const quiet = again && boundary.quiet;
338
+ if (again) boundary.quiet = false;
339
+ if (!ready(again)) {
340
+ // A load in flight was asked for before the revalidation, and may
341
+ // bring back what the page held before the action -- so the
342
+ // revalidation stays awaited, and is started once that load settles.
343
+ // Dropping it left the pre-action content up for good.
344
+ if (again && boundary.refetch === AWAITED && boundary.pending === null) boundary.refetch = null;
345
+ return null;
346
+ }
347
+
348
+ const { decode, onError, hardNavigate, claims } = provided.current ?? {};
349
+
350
+ // Decoded by the handle, so a stream that cannot be decoded counts
351
+ // against its backoff and limit like any other failed load.
352
+ const result = (
353
+ loading ?? boundary.handle.load({ signal: aborter.signal, decode, headers: claims?.() ?? {} })
354
+ )
355
+ .then(
356
+ (node) => {
357
+ boundary.loaded = true;
358
+ boundary.last = { node };
359
+ boundary.rescued = false;
360
+ setRescued(null);
361
+ setLive(boundary.handle.liveToken);
362
+ return boundary.last;
363
+ },
364
+ (error) => {
365
+ // Unmounted -- the page moved on -- is not a failure.
366
+ if (aborter.signal.aborted) return boundary.last ?? {};
367
+
368
+ // The session is gone, and "session revocation, which the server
369
+ // signals with a 401", is cleared "with a full page load"
370
+ // (tinvar-spec.md, "Client-held data"). Reported as one more
371
+ // failed boundary, it left the protected page on screen. Before
372
+ // onError, so nothing the app's handler does can stop it.
373
+ if (error instanceof FragmentError && error.status === 401) {
374
+ // Wiped first: the reload alone left remembered drafts in the
375
+ // tab's storage for the next page to read back.
376
+ revokeSession();
377
+ // Loaded again -- unless a document load has begun: an action
378
+ // that ended the session sent the browser on, and this
379
+ // boundary fetched meanwhile. It is left to arrive
380
+ // (document-load.js).
381
+ reloadUnlessLoading(hardNavigate ?? ((u) => globalThis.location.assign(u)));
382
+ }
383
+
384
+ // A failed boundary must not take the page with it. It reports,
385
+ // keeps what it last showed -- an `always` refetch that fails
386
+ // leaves the loaded content up -- and is armed to try again
387
+ // unless the handle has given up. The handle's backoff paces it.
388
+ onError?.(error, element);
389
+
390
+ // The server's render threw, and there is nothing loaded to keep
391
+ // up: the rescue slot, and no retry on a timer -- see above.
392
+ const failure = rescuable.current && boundary.last === null ? serverFailure(error) : null;
393
+ if (failure !== null) {
394
+ boundary.rescued = true;
395
+ setRescued(failure);
396
+ return { rescued: failure };
397
+ }
398
+
399
+ if (!boundary.handle.terminal) setArmed((n) => n + 1);
400
+ return boundary.last ?? {};
401
+ },
402
+ )
403
+ .finally(() => {
404
+ if (boundary.pending === result) boundary.pending = null;
405
+ setStarted((load) => (load?.result === result ? { ...load, settled: true } : load));
406
+ });
407
+
408
+ boundary.pending = result;
409
+ // An awaited revalidation is answered by whichever load starts next,
410
+ // quiet or not: it was asked for after the action, so what it brings
411
+ // back is fresh. A quiet refresh and a revalidation gathered into one
412
+ // group request start a single load; taken for the quiet one, it left
413
+ // the revalidation awaited, and the boundary reloading until yet
414
+ // another load caught it up.
415
+ if (boundary.refetch === AWAITED || (again && !quiet)) boundary.refetch = result;
416
+ setStarted({ identity: boundary.identity, result, settled: false });
417
+
418
+ // A transition, so an `always` refetch keeps the current content on
419
+ // screen until the new content is ready, instead of flashing back to
420
+ // the fallback.
421
+ startTransition(() => setShown({ identity: boundary.identity, result }));
422
+ return result;
423
+ };
424
+
425
+ loader.current = { ready, begin };
426
+
427
+ // A member of a group asks its group, which loads every member that is
428
+ // ready, this one included; otherwise it loads on its own.
429
+ /** @param {boolean} again */
430
+ const ask = (again) => {
431
+ if (groups !== null && member.current !== null) groups.request(group, member.current, again);
432
+ else void begin(again);
433
+ };
434
+ const fire = () => ask(false);
435
+ refresh.current = () => ask(true);
436
+
437
+ const teardown = installTrigger(load, element, fire, { buffer, always });
438
+ element.addEventListener(LOAD_EVENT, fire);
439
+
440
+ return () => {
441
+ teardown();
442
+ element.removeEventListener(LOAD_EVENT, fire);
443
+ aborter.abort();
444
+ refresh.current = null;
445
+ loader.current = null;
446
+ // Abandoned, not pending: Strict Mode's remount -- or the effect
447
+ // re-running -- must be able to start its own.
448
+ boundary.pending = null;
449
+ };
450
+ }, [identity, load, buffer, always, armed, hasProvider, groups, group]);
451
+
452
+ // An action revalidated the page: what this boundary shows may be out of
453
+ // date, and the re-rendered page handed it a re-issued token for the same
454
+ // content, which it keeps -- so nothing else would fetch it again. One
455
+ // still on its fallback has nothing stale, and loads fresh on its own --
456
+ // unless its first load is already in flight, asked for before the action
457
+ // and as stale as any other.
458
+ useEffect(() => {
459
+ const boundary = entry.current;
460
+ if (boundary.revalidated === revalidation.generation) return;
461
+ boundary.revalidated = revalidation.generation;
462
+ if (refresh.current === null) return;
463
+ if (boundary.last === null && boundary.pending === null) return;
464
+
465
+ // Asked for, not yet started -- a group starts it on its next task --
466
+ // and reloading meanwhile; begin() replaces this with the load, leaves
467
+ // it behind a load already in flight, or clears it if it cannot start
468
+ // one.
469
+ boundary.refetch = AWAITED;
470
+ refresh.current();
471
+ }, [revalidation.generation]);
472
+
473
+ // A poll asked loaded boundaries to fetch again, quietly: the same load a
474
+ // revalidation starts, without `refetch` set, so nothing reads reloading
475
+ // and nothing dims -- the content is swapped when the new one is ready.
476
+ useEffect(() => {
477
+ const boundary = entry.current;
478
+ if (boundary.refreshed === revalidation.quiet) return;
479
+ boundary.refreshed = revalidation.quiet;
480
+ if (boundary.last === null || refresh.current === null) return;
481
+ boundary.quiet = true;
482
+ refresh.current();
483
+ }, [revalidation.quiet]);
484
+
485
+ // Something the content on screen read has changed, the server says --
486
+ // written in another tab, or by someone else (live.js). Fetched again as a
487
+ // poll's refresh is: quietly, nothing marked reloading, the content
488
+ // swapped once the new is ready, and paced by the handle's backoff. Each
489
+ // load brings the token for what it read, so the subscription follows it.
490
+ useEffect(() => {
491
+ if (live === null) return undefined;
492
+ return subscribeLive(live, () => {
493
+ const boundary = entry.current;
494
+ if (boundary.last === null || refresh.current === null) return;
495
+ boundary.quiet = true;
496
+ refresh.current();
497
+ });
498
+ }, [live]);
499
+
500
+ // A revalidation still awaited with nothing in flight: the load it waited
501
+ // behind has settled, so it starts now. After every commit, not in that
502
+ // load's own callbacks: a failed load re-arms the trigger, and a load
503
+ // started before that re-run would be aborted by it.
504
+ useEffect(() => {
505
+ const boundary = entry.current;
506
+ if (boundary.refetch !== AWAITED || boundary.pending !== null) return;
507
+ // A first load that failed: nothing stale is up, and its own trigger or
508
+ // retry loads fresh.
509
+ if (boundary.last === null) boundary.refetch = null;
510
+ else refresh.current?.();
511
+ });
512
+
513
+ const current = shown !== null && shown.identity === identity ? shown.result : null;
514
+
515
+ // Reloading from the moment an action starts revalidating the page until
516
+ // the content it brought back is on screen: while the action runs, in the
517
+ // render that learns it has settled (before the effect above has started
518
+ // the load), and while that load is in flight. Read from the COMMITTED
519
+ // result -- a transition keeps the old one until the new content renders,
520
+ // so the stale content stays marked for exactly as long as it is up.
521
+ const boundary = entry.current;
522
+ const reloading =
523
+ boundary.last !== null &&
524
+ (revalidation.active ||
525
+ revalidation.generation !== boundary.revalidated ||
526
+ (boundary.refetch !== null && current !== boundary.refetch));
527
+
528
+ // Done with once it is on screen, so a later `always` refetch is not
529
+ // mistaken for it.
530
+ useEffect(() => {
531
+ if (boundary.refetch !== null && current === boundary.refetch) boundary.refetch = null;
532
+ });
533
+
534
+ // From the trigger until what the load brought is on screen. Both halves
535
+ // are needed: a first load's transition commits at once, with Suspense's
536
+ // fallback up, so being committed is not being shown; and a refetch's
537
+ // settles before its transition commits the new content, so being settled
538
+ // is not either.
539
+ const fetching =
540
+ started !== null && started.identity === identity && (!started.settled || current !== started.result);
541
+
542
+ // A fragment that decoded but throws as it renders -- an error row below
543
+ // its root -- has nothing worth keeping: it is not loaded, so a retry or
544
+ // a trigger fetches it again.
545
+ const caught = (error) => {
546
+ provided.current?.onError?.(error, host.current);
547
+ const failure = rescuable.current ? serverFailure(error) : null;
548
+ if (failure === null) return;
549
+ entry.current.loaded = false;
550
+ entry.current.last = null;
551
+ entry.current.rescued = true;
552
+ setRescued(failure);
553
+ };
554
+
555
+ const state = useMemo(
556
+ () => ({
557
+ fetching,
558
+ reloading,
559
+ rescued,
560
+ retry: () => {
561
+ // Its own ask, which is what a rescued boundary waits for.
562
+ entry.current.rescued = false;
563
+ host.current?.dispatchEvent(new CustomEvent(LOAD_EVENT));
564
+ },
565
+ }),
566
+ [fetching, reloading, rescued],
567
+ );
568
+
569
+ return createElement(
570
+ 'tinvar-deferred',
571
+ {
572
+ ref: host,
573
+ id,
574
+ 'data-load': load,
575
+ 'data-fetching': fetching ? '' : undefined,
576
+ 'data-rescued': rescued === null ? undefined : '',
577
+ // The style hook, and what assistive technology is told: this region
578
+ // is being replaced, so wait for it rather than read it out now.
579
+ 'data-reloading': reloading ? '' : undefined,
580
+ 'aria-busy': reloading ? 'true' : undefined,
581
+ },
582
+ createElement(
583
+ DeferredContext.Provider,
584
+ { value: state },
585
+ current === null
586
+ ? fallback
587
+ : createElement(
588
+ Contained,
589
+ { result: current, fallback, rescue, onError: caught },
590
+ createElement(
591
+ Suspense,
592
+ { fallback },
593
+ createElement(Resolved, { result: current, fallback, rescue }),
594
+ ),
595
+ ),
596
+ // Only beside content: with nothing loaded yet, the fallback is what
597
+ // says a load is under way.
598
+ fetching && boundary.last !== null ? refreshing : null,
599
+ ),
600
+ );
601
+ }
602
+
603
+ /**
604
+ * Keeps a fragment's failure to the fragment.
605
+ *
606
+ * Promise rejections were already caught, but the decoded tree throws while
607
+ * React RENDERS it -- a component in the fragment that throws, an error row
608
+ * in the stream -- and that went to the nearest error boundary above, which
609
+ * is usually the page's. One failed boundary took the page with it.
610
+ *
611
+ * An error row is the server's render throwing, so it shows the boundary's
612
+ * `rescue` slot where there is one, and its fallback otherwise.
613
+ */
614
+ class Contained extends Component {
615
+ constructor(props) {
616
+ super(props);
617
+ this.state = { failed: false, rescued: false, result: props.result };
618
+ }
619
+
620
+ // A new load gets a fresh chance: a refetch that renders after one that
621
+ // failed shows its content again. Reset here rather than by remounting,
622
+ // which would drop back to the fallback and lose the transition that
623
+ // keeps the old content up while the new loads.
624
+ static getDerivedStateFromProps(props, state) {
625
+ return props.result === state.result ? null : { failed: false, rescued: false, result: props.result };
626
+ }
627
+
628
+ static getDerivedStateFromError(error) {
629
+ return { failed: true, rescued: serverFailure(error) !== null };
630
+ }
631
+
632
+ componentDidCatch(error) {
633
+ this.props.onError(error);
634
+ }
635
+
636
+ render() {
637
+ if (!this.state.failed) return this.props.children;
638
+ return this.state.rescued && this.props.rescue !== undefined ? this.props.rescue : this.props.fallback;
639
+ }
640
+ }
641
+
642
+ function Resolved({ result, fallback, rescue }) {
643
+ const outcome = use(result);
644
+ if ('node' in outcome) return outcome.node;
645
+ return outcome.rescued && rescue !== undefined ? rescue : fallback;
646
+ }
@@ -0,0 +1,24 @@
1
+ /** The boundaries of one <Deferred group>, fetched together. See ADR 0018. */
2
+
3
+ import type { FragmentHandle } from './fragment-client';
4
+
5
+ /** FragmentEndpoint::MAX_GROUP: a longer group is sent in requests of at most this many. */
6
+ export declare const MAX_GROUP: 16;
7
+
8
+ export interface GroupMember {
9
+ handle: FragmentHandle;
10
+ /** Whether it would load now; `asked` says whether it was triggered itself. */
11
+ ready(again: boolean, asked: boolean): boolean;
12
+ /** Runs its load: its share of the group's request, or its own when it is the only one ready. */
13
+ start(again: boolean, load?: Promise<unknown>): void;
14
+ /** It asked and was not ready. */
15
+ skip(again: boolean): void;
16
+ }
17
+
18
+ export declare class FragmentGroups {
19
+ constructor(decode: () => ((stream: ReadableStream<Uint8Array>) => unknown) | undefined);
20
+ join(name: string, member: GroupMember): () => void;
21
+ /** How many groups have members: one that has none is forgotten. */
22
+ readonly size: number;
23
+ request(name: string, member: GroupMember, again: boolean): void;
24
+ }