@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,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
|
+
}
|