@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
package/src/page-root.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// The page an app's browser entry shows, and the router mount that changes it.
|
|
2
|
+
//
|
|
3
|
+
// The router never imports React (router.js), so turning a payload into the
|
|
4
|
+
// page on screen is the app's: decode the stream with its own client bundle,
|
|
5
|
+
// set it as the root's state, and tell the router once React has COMMITTED
|
|
6
|
+
// it, since the router scrolls and pushes history straight after. Every app
|
|
7
|
+
// needs the same root for that, and the hand-written one an entry carried
|
|
8
|
+
// had a defect no app should have to rediscover: a visit aborted while its
|
|
9
|
+
// transition was pending -- cancelled, or superseded by the next click --
|
|
10
|
+
// still committed when its data arrived. Its page showed under the old URL
|
|
11
|
+
// and the old page's route token, and the scroll recorded for the old entry
|
|
12
|
+
// was the aborted page's.
|
|
13
|
+
//
|
|
14
|
+
// A partial reload's payload is not a page but the segments it recomputed
|
|
15
|
+
// (ADR 0015). They are merged into what the root shows, and handed to each
|
|
16
|
+
// <Segment> slot through context, over the page they arrived for.
|
|
17
|
+
//
|
|
18
|
+
// Mounting is two steps (ADR 0045): prepare() decodes the payload, and the
|
|
19
|
+
// commit it returns puts the page on screen. The router runs a view
|
|
20
|
+
// transition around the commit alone, so the browser holds the old page
|
|
21
|
+
// only while React swaps it, not while the payload is read -- nor while a
|
|
22
|
+
// client component's chunk the payload names is still loading, which
|
|
23
|
+
// prepare() waits for (client-modules.js); and a page
|
|
24
|
+
// that is not to keep its state -- preserveState: false, a visit's default
|
|
25
|
+
// -- is committed fresh: the components of the page segment, or of the
|
|
26
|
+
// segments a partial reload carries, mount again rather than being
|
|
27
|
+
// reconciled with the page they replace.
|
|
28
|
+
//
|
|
29
|
+
// An instant visit (ADR 0092) shows, as soon as it is asked for, the page's
|
|
30
|
+
// layouts as they are on screen with a layout's loading boundary below
|
|
31
|
+
// them: instant() turns it on, ahead of the request, and the page the visit
|
|
32
|
+
// fetches turns it off by replacing it. Nothing of the next page is drawn
|
|
33
|
+
// before its payload arrives; what shows meanwhile is what the browser
|
|
34
|
+
// already holds.
|
|
35
|
+
//
|
|
36
|
+
// This module is browser-only, like the router; it imports React for the
|
|
37
|
+
// root component and nothing from the server.
|
|
38
|
+
|
|
39
|
+
import { createElement, startTransition, useLayoutEffect, useMemo, useState } from 'react';
|
|
40
|
+
import { clientModulesSettled, watchClientModules } from './client-modules.js';
|
|
41
|
+
import { createLayoutPropsStore } from './layout-props.js';
|
|
42
|
+
import { SegmentContext } from './segment.js';
|
|
43
|
+
|
|
44
|
+
/** The segment the page itself renders as; see compose-route.js. */
|
|
45
|
+
const PAGE_SEGMENT = 'page';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A page as the root holds it: the tree, and what partial reloads have put
|
|
49
|
+
* in its segments since it arrived whole.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {{
|
|
52
|
+
* root: unknown,
|
|
53
|
+
* segments?: Readonly<Record<string, unknown>>,
|
|
54
|
+
* generation?: number,
|
|
55
|
+
* keys?: Readonly<Record<string, number>>,
|
|
56
|
+
* committed?: () => void,
|
|
57
|
+
* waiting?: readonly (() => void)[],
|
|
58
|
+
* from?: Update,
|
|
59
|
+
* instant?: { under: string, token: object },
|
|
60
|
+
* dropBefore?: number,
|
|
61
|
+
* }} Shown
|
|
62
|
+
*
|
|
63
|
+
* `waiting` is every update this one was applied over that has not reached
|
|
64
|
+
* the screen yet, by the callback that tells its mount it has; `committed`
|
|
65
|
+
* calls them all.
|
|
66
|
+
*
|
|
67
|
+
* `generation` counts the whole pages shown. A merged list (<MergeSlot>)
|
|
68
|
+
* adds a partial reload's items to those it holds, but a whole page starts
|
|
69
|
+
* it again -- even the same page, where React keeps the component and its
|
|
70
|
+
* state -- so it compares this, not its props, to tell one from the other.
|
|
71
|
+
*
|
|
72
|
+
* `keys` counts, by segment, the times each was committed fresh: a
|
|
73
|
+
* <Segment> renders its content under its count, so a new one mounts it
|
|
74
|
+
* again.
|
|
75
|
+
*
|
|
76
|
+
* `instant` is the instant visit showing: the layout whose loading boundary
|
|
77
|
+
* stands in for what is below it, and the token of the visit that asked.
|
|
78
|
+
*
|
|
79
|
+
* `dropBefore` is, on a whole page, the last change to the layout props
|
|
80
|
+
* that page did not set: what the page before it set, dropped once it is
|
|
81
|
+
* on screen (layout-props.js).
|
|
82
|
+
*
|
|
83
|
+
* An update a mount asks for: a whole page, or segments to merge into the
|
|
84
|
+
* page shown when it is applied; `fresh` when what it replaces is not to
|
|
85
|
+
* keep its state.
|
|
86
|
+
*
|
|
87
|
+
* @typedef {{
|
|
88
|
+
* root?: unknown,
|
|
89
|
+
* partial?: Readonly<Record<string, unknown>>,
|
|
90
|
+
* fresh?: boolean,
|
|
91
|
+
* committed: () => void,
|
|
92
|
+
* mark?: number,
|
|
93
|
+
* }} Update
|
|
94
|
+
*
|
|
95
|
+
* @typedef {{
|
|
96
|
+
* decode: (stream: ReadableStream<Uint8Array>) => unknown,
|
|
97
|
+
* }} PageRootOptions
|
|
98
|
+
*
|
|
99
|
+
* A payload decoded and ready to go on screen.
|
|
100
|
+
*
|
|
101
|
+
* @typedef {{ commit: () => Promise<void> }} Prepared
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
const NONE = Object.freeze({});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {Shown} current
|
|
108
|
+
* @param {Update} update
|
|
109
|
+
* @param {Shown | null} onScreen the page last committed
|
|
110
|
+
* @returns {Shown}
|
|
111
|
+
*/
|
|
112
|
+
function apply(current, update, onScreen) {
|
|
113
|
+
// React renders transitions still pending together and commits them once:
|
|
114
|
+
// an action's re-render of the page and a poll's of one segment, say.
|
|
115
|
+
// Only the last state's callback ran, and the mount of the one before it
|
|
116
|
+
// waited for a commit that had already happened -- for good. So an update
|
|
117
|
+
// applied over one not yet on screen carries that one's callback too.
|
|
118
|
+
const waiting = [...(current === onScreen ? [] : (current.waiting ?? [])), update.committed];
|
|
119
|
+
const committed = () => {
|
|
120
|
+
for (const callback of waiting) callback();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// A whole page renews the page segment; layouts stay, as RSC's nested
|
|
124
|
+
// layouts do -- and as Inertia's persistent layouts keep their state
|
|
125
|
+
// whatever preserveState says. A partial reload renews what it carries.
|
|
126
|
+
const renewed = update.partial === undefined ? [PAGE_SEGMENT] : Object.keys(update.partial);
|
|
127
|
+
const keys = update.fresh ? renew(current.keys ?? NONE, renewed) : current.keys;
|
|
128
|
+
|
|
129
|
+
if (update.partial === undefined) {
|
|
130
|
+
return {
|
|
131
|
+
root: update.root,
|
|
132
|
+
generation: (current.generation ?? 0) + 1,
|
|
133
|
+
keys,
|
|
134
|
+
committed,
|
|
135
|
+
waiting,
|
|
136
|
+
from: update,
|
|
137
|
+
...(update.mark === undefined ? {} : { dropBefore: update.mark }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A partial reload -- an action's re-render, say -- lands in the page
|
|
142
|
+
// under the loading boundary, which stays until the visit settles.
|
|
143
|
+
return {
|
|
144
|
+
root: current.root,
|
|
145
|
+
segments: { ...(current.segments ?? NONE), ...update.partial },
|
|
146
|
+
generation: current.generation ?? 0,
|
|
147
|
+
keys,
|
|
148
|
+
committed,
|
|
149
|
+
waiting,
|
|
150
|
+
from: update,
|
|
151
|
+
...(current.instant === undefined ? {} : { instant: current.instant }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {Readonly<Record<string, number>>} keys
|
|
157
|
+
* @param {readonly string[]} segments
|
|
158
|
+
* @returns {Readonly<Record<string, number>>}
|
|
159
|
+
*/
|
|
160
|
+
function renew(keys, segments) {
|
|
161
|
+
const next = { ...keys };
|
|
162
|
+
for (const id of segments) next[id] = (next[id] ?? 0) + 1;
|
|
163
|
+
return next;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The segments a partial payload carries, or null when it is not one --
|
|
168
|
+
* an app whose renderer rendered the page whole anyway, say -- and is
|
|
169
|
+
* then shown as a page.
|
|
170
|
+
*
|
|
171
|
+
* @param {unknown} decoded
|
|
172
|
+
* @returns {Readonly<Record<string, unknown>> | null}
|
|
173
|
+
*/
|
|
174
|
+
function segmentsOf(decoded) {
|
|
175
|
+
const segments = /** @type {{ segments?: unknown } | null} */ (decoded)?.segments;
|
|
176
|
+
return segments !== null && typeof segments === 'object' && !Array.isArray(segments)
|
|
177
|
+
? /** @type {Record<string, unknown>} */ (segments)
|
|
178
|
+
: null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Creates the root component an app hydrates its document with, and the
|
|
183
|
+
* `mount` to give createRouter().
|
|
184
|
+
*
|
|
185
|
+
* @param {PageRootOptions} options
|
|
186
|
+
*/
|
|
187
|
+
export function createPageRoot({ decode }) {
|
|
188
|
+
// Before any payload is decoded through it, so every load a visit's
|
|
189
|
+
// payload starts is seen.
|
|
190
|
+
watchClientModules();
|
|
191
|
+
|
|
192
|
+
/** The last page that reached the screen. */
|
|
193
|
+
let committed = /** @type {Shown | null} */ (null);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An update the router asked for before the root could take it -- a back
|
|
197
|
+
* press can arrive before hydration has run a single effect. Held, and
|
|
198
|
+
* applied once the root can, rather than dropped, which left its mount
|
|
199
|
+
* waiting forever.
|
|
200
|
+
*/
|
|
201
|
+
let early = /** @type {Update | null} */ (null);
|
|
202
|
+
|
|
203
|
+
/** Changes what the root shows; the root's own setter once it has rendered. */
|
|
204
|
+
let set = /** @type {((change: (current: Shown) => Shown) => void) | null} */ (null);
|
|
205
|
+
|
|
206
|
+
/** What each segment last committed; see <Segment>. One map for the root's life. */
|
|
207
|
+
const recorded = new Map();
|
|
208
|
+
|
|
209
|
+
/** The layouts on screen with a loading boundary; see <Segment>. */
|
|
210
|
+
const boundaries = new Map();
|
|
211
|
+
|
|
212
|
+
/** What pages have handed their layouts with setLayoutProps(); see layout-props.js. */
|
|
213
|
+
const layoutProps = createLayoutPropsStore();
|
|
214
|
+
|
|
215
|
+
/** The whole pages shown, as of the last commit: a new one drops the last page's layout props. */
|
|
216
|
+
let shownGeneration = 0;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Changes what the root shows at once, outside a transition: an instant
|
|
220
|
+
* visit's loading boundary is drawn from what the browser holds, so
|
|
221
|
+
* nothing in it waits, and it must not wait behind a page's transition.
|
|
222
|
+
*/
|
|
223
|
+
let setNow = /** @type {((change: (current: Shown) => Shown) => void) | null} */ (null);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {{ initial: unknown }} props
|
|
227
|
+
*/
|
|
228
|
+
function PageRoot({ initial }) {
|
|
229
|
+
const [page, setPage] = useState(/** @returns {Shown} */ () => ({ root: initial }));
|
|
230
|
+
|
|
231
|
+
useLayoutEffect(() => {
|
|
232
|
+
set = (change) => startTransition(() => setPage(change));
|
|
233
|
+
setNow = setPage;
|
|
234
|
+
|
|
235
|
+
if (early !== null) {
|
|
236
|
+
const held = early;
|
|
237
|
+
early = null;
|
|
238
|
+
set((current) => apply(current, held, committed));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
set = null;
|
|
243
|
+
setNow = null;
|
|
244
|
+
};
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
useLayoutEffect(() => {
|
|
248
|
+
committed = page;
|
|
249
|
+
const generation = page.generation ?? 0;
|
|
250
|
+
if (generation !== shownGeneration) {
|
|
251
|
+
shownGeneration = generation;
|
|
252
|
+
if (page.dropBefore !== undefined) layoutProps.dropBefore(page.dropBefore);
|
|
253
|
+
}
|
|
254
|
+
page.committed?.();
|
|
255
|
+
}, [page]);
|
|
256
|
+
|
|
257
|
+
const generation = page.generation ?? 0;
|
|
258
|
+
const keys = page.keys ?? NONE;
|
|
259
|
+
const instant = page.instant ?? null;
|
|
260
|
+
const store = useMemo(
|
|
261
|
+
() => ({
|
|
262
|
+
overrides: page.segments ?? NONE,
|
|
263
|
+
recorded,
|
|
264
|
+
generation,
|
|
265
|
+
keys,
|
|
266
|
+
instant,
|
|
267
|
+
boundaries,
|
|
268
|
+
layoutProps,
|
|
269
|
+
}),
|
|
270
|
+
[page.segments, generation, keys, instant],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return createElement(SegmentContext.Provider, { value: store }, page.root);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {Update} update */
|
|
277
|
+
function show(update) {
|
|
278
|
+
if (set === null) early = update;
|
|
279
|
+
else set((current) => apply(current, update, committed));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Takes back an update whose visit was aborted before it reached the
|
|
284
|
+
* screen.
|
|
285
|
+
*
|
|
286
|
+
* The transition that set it cannot be cancelled, and React commits it
|
|
287
|
+
* whenever what it suspended on arrives. So the state is put back to the
|
|
288
|
+
* page last committed, in a transition of its own: two transitions on one
|
|
289
|
+
* state are rendered together, and only the later is committed -- which
|
|
290
|
+
* now shows the page already on screen. Put back only if the aborted
|
|
291
|
+
* update is still the latest; a newer one stands.
|
|
292
|
+
*
|
|
293
|
+
* @param {Update} update
|
|
294
|
+
*/
|
|
295
|
+
function withdraw(update) {
|
|
296
|
+
if (early === update) {
|
|
297
|
+
early = null;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const back = committed;
|
|
302
|
+
if (set === null || back === null) return;
|
|
303
|
+
|
|
304
|
+
// A fresh object, so the effect that records what reached the screen
|
|
305
|
+
// runs for it; the tree in it is the one already there.
|
|
306
|
+
set((current) =>
|
|
307
|
+
current.from === update
|
|
308
|
+
? { root: back.root, segments: back.segments, generation: back.generation, keys: back.keys }
|
|
309
|
+
: current,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Shows an instant visit's loading boundary: the boundary the layout
|
|
315
|
+
* keyed `under` gave -- or, given true, the one the layout directly above
|
|
316
|
+
* the page gave -- in place of what that layout holds, until the visit's
|
|
317
|
+
* page replaces it or the returned function takes it back. Null, and
|
|
318
|
+
* nothing shown, when no layout on screen by that key has a boundary: the
|
|
319
|
+
* visit is then an ordinary one, and the page on screen stays until the
|
|
320
|
+
* next is ready.
|
|
321
|
+
*
|
|
322
|
+
* Taking it back after the page has replaced it, or after a later instant
|
|
323
|
+
* visit has shown its own, does nothing.
|
|
324
|
+
*
|
|
325
|
+
* @param {true | string} under
|
|
326
|
+
* @returns {(() => void) | null}
|
|
327
|
+
*/
|
|
328
|
+
function instant(under) {
|
|
329
|
+
const key = under === true ? layoutAbove(PAGE_SEGMENT) : under;
|
|
330
|
+
const change = setNow;
|
|
331
|
+
if (typeof key !== 'string' || !boundaries.has(key) || change === null) return null;
|
|
332
|
+
|
|
333
|
+
const token = {};
|
|
334
|
+
change((current) => ({ ...current, instant: { under: key, token } }));
|
|
335
|
+
|
|
336
|
+
return () => {
|
|
337
|
+
setNow?.((current) => {
|
|
338
|
+
if (current.instant?.token !== token) return current;
|
|
339
|
+
const next = { ...current };
|
|
340
|
+
delete next.instant;
|
|
341
|
+
return next;
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The layout whose loading boundary stands in for segment `id`, if one on
|
|
348
|
+
* screen has one.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} id
|
|
351
|
+
* @returns {string | undefined}
|
|
352
|
+
*/
|
|
353
|
+
function layoutAbove(id) {
|
|
354
|
+
for (const [layout, child] of boundaries) {
|
|
355
|
+
if (child === id) return layout;
|
|
356
|
+
}
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The router's first step: decodes the payload, and hands back the
|
|
362
|
+
* commit that puts it on screen -- or null when the visit was aborted
|
|
363
|
+
* meanwhile, and the page is never to show.
|
|
364
|
+
*
|
|
365
|
+
* @param {ReadableStream<Uint8Array>} stream
|
|
366
|
+
* @param {import('./router.js').Page} page
|
|
367
|
+
* @returns {Promise<Prepared | null>}
|
|
368
|
+
*/
|
|
369
|
+
async function prepare(stream, page) {
|
|
370
|
+
watchClientModules();
|
|
371
|
+
const decoded = await decode(stream);
|
|
372
|
+
|
|
373
|
+
// Superseded while decoding: the next page is coming, this one never shows.
|
|
374
|
+
if (page.signal.aborted) return null;
|
|
375
|
+
|
|
376
|
+
// The chunks of the client components the payload has named so far,
|
|
377
|
+
// still loading: waited for here, outside the view transition, so the
|
|
378
|
+
// commit never holds the old page frozen while one arrives.
|
|
379
|
+
const loading = clientModulesSettled();
|
|
380
|
+
if (loading !== null) {
|
|
381
|
+
await Promise.race([loading, abortedOf(page.signal)]);
|
|
382
|
+
if (page.signal.aborted) return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const partial = page.partial ? segmentsOf(decoded) : null;
|
|
386
|
+
const fresh = page.preserveState === false;
|
|
387
|
+
|
|
388
|
+
/** @type {Promise<void> | null} */
|
|
389
|
+
let committing = null;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
/**
|
|
393
|
+
* Shows the page. Resolves once React has committed it, or once the
|
|
394
|
+
* visit is aborted -- and then the page never commits. Asked twice,
|
|
395
|
+
* it shows the page once.
|
|
396
|
+
*/
|
|
397
|
+
commit() {
|
|
398
|
+
committing ??= new Promise((resolve) => {
|
|
399
|
+
/** @type {Update} */
|
|
400
|
+
// A whole page drops what the page before it set for its layouts
|
|
401
|
+
// -- everything set up to now, before any of it has rendered.
|
|
402
|
+
const update =
|
|
403
|
+
partial === null
|
|
404
|
+
? { root: decoded, fresh, committed: () => resolve(undefined), mark: layoutProps.mark() }
|
|
405
|
+
: { partial, fresh, committed: () => resolve(undefined) };
|
|
406
|
+
|
|
407
|
+
if (page.signal.aborted) {
|
|
408
|
+
resolve(undefined);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
page.signal.addEventListener(
|
|
412
|
+
'abort',
|
|
413
|
+
() => {
|
|
414
|
+
withdraw(update);
|
|
415
|
+
resolve(undefined);
|
|
416
|
+
},
|
|
417
|
+
{ once: true },
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
show(update);
|
|
421
|
+
});
|
|
422
|
+
return committing;
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The router's mount in one step: prepare(), then its commit. Resolves
|
|
429
|
+
* once React has committed the page, or once the visit is aborted -- and
|
|
430
|
+
* then the page never commits.
|
|
431
|
+
*
|
|
432
|
+
* @param {ReadableStream<Uint8Array>} stream
|
|
433
|
+
* @param {import('./router.js').Page} page
|
|
434
|
+
* @returns {Promise<void>}
|
|
435
|
+
*/
|
|
436
|
+
async function mount(stream, page) {
|
|
437
|
+
const prepared = await prepare(stream, page);
|
|
438
|
+
await prepared?.commit();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return { PageRoot, mount, prepare, instant };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Settles when $signal aborts.
|
|
446
|
+
*
|
|
447
|
+
* @param {AbortSignal} signal
|
|
448
|
+
* @returns {Promise<void>}
|
|
449
|
+
*/
|
|
450
|
+
function abortedOf(signal) {
|
|
451
|
+
return new Promise((resolve) => {
|
|
452
|
+
if (signal.aborted) resolve();
|
|
453
|
+
else signal.addEventListener('abort', () => resolve(), { once: true });
|
|
454
|
+
});
|
|
455
|
+
}
|
package/src/payload.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A row as the renderer writes it: payload text, or bytes that are not UTF-8 as base64. */
|
|
2
|
+
export type PayloadRow = string | { $b64: string };
|
|
3
|
+
|
|
4
|
+
/** The bytes one row stands for. Throws on anything that is not a row. */
|
|
5
|
+
export function payloadRowBytes(row: unknown): Uint8Array;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The inline payload (`self.__rsc`) as the byte stream React's client
|
|
9
|
+
* decodes. Reads rows as their scripts run, until `self.__rsc_done` is true.
|
|
10
|
+
*/
|
|
11
|
+
export function inlinePayloadStream(globals?: {
|
|
12
|
+
__rsc?: unknown;
|
|
13
|
+
__rsc_done?: unknown;
|
|
14
|
+
document?: { readyState?: string };
|
|
15
|
+
}): ReadableStream<Uint8Array>;
|
package/src/payload.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The RSC payload the renderer writes into the document, read back as the
|
|
2
|
+
// byte stream React's client decodes.
|
|
3
|
+
//
|
|
4
|
+
// Each row is a `self.__rsc.push(...)` script (tinvar-spec.md, "Rendered
|
|
5
|
+
// output"). A row is a string, which is the payload's own UTF-8 text; or,
|
|
6
|
+
// for a stretch of payload that is not valid UTF-8 -- a Uint8Array or
|
|
7
|
+
// ArrayBuffer prop, which Flight serializes as its raw bytes -- an object
|
|
8
|
+
// {"$b64": "..."} carrying those bytes. The same marker the json-lines
|
|
9
|
+
// codec uses for binary. Encoding every row as text again, as browser
|
|
10
|
+
// entries did, replaced each byte that was not UTF-8 and corrupted the prop.
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The bytes one row stands for.
|
|
16
|
+
*
|
|
17
|
+
* @param {unknown} row
|
|
18
|
+
* @returns {Uint8Array}
|
|
19
|
+
*/
|
|
20
|
+
export function payloadRowBytes(row) {
|
|
21
|
+
if (typeof row === 'string') return encoder.encode(row);
|
|
22
|
+
|
|
23
|
+
if (row !== null && typeof row === 'object' && typeof row.$b64 === 'string') {
|
|
24
|
+
const binary = atob(row.$b64);
|
|
25
|
+
const bytes = new Uint8Array(binary.length);
|
|
26
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new TypeError('Not a payload row: expected a string or {"$b64": "..."}.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The longest the stream waits between looks for the payload's end. */
|
|
34
|
+
const MAX_POLL_MS = 100;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The inline payload as a stream, for createFromReadableStream().
|
|
38
|
+
*
|
|
39
|
+
* Rows already in the document are read at once; later ones as their
|
|
40
|
+
* scripts push them, until `self.__rsc_done` is true. The renderer claims
|
|
41
|
+
* both globals before any content, so page content naming an element `__rsc`
|
|
42
|
+
* cannot stand in for them. Checked here too: anything but an array is not
|
|
43
|
+
* the payload, and only `true` means done.
|
|
44
|
+
*
|
|
45
|
+
* A push is read as it is made. The end is looked for between pushes, less
|
|
46
|
+
* often the longer nothing comes, and no more once the document has loaded
|
|
47
|
+
* without it -- a render cut off partway sends nothing further, and was
|
|
48
|
+
* otherwise looked for every few milliseconds for as long as the tab stayed.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ __rsc?: unknown, __rsc_done?: unknown, document?: { readyState?: string } }} [globals] defaults to `globalThis`
|
|
51
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
52
|
+
*/
|
|
53
|
+
export function inlinePayloadStream(globals = /** @type {never} */ (globalThis)) {
|
|
54
|
+
const rows = Array.isArray(globals.__rsc) ? globals.__rsc : [];
|
|
55
|
+
|
|
56
|
+
return new ReadableStream({
|
|
57
|
+
start(controller) {
|
|
58
|
+
let index = 0;
|
|
59
|
+
let delay = 0;
|
|
60
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
61
|
+
let timer = null;
|
|
62
|
+
let over = false;
|
|
63
|
+
|
|
64
|
+
const drain = () => {
|
|
65
|
+
if (over) return;
|
|
66
|
+
if (timer !== null) clearTimeout(timer);
|
|
67
|
+
timer = null;
|
|
68
|
+
const from = index;
|
|
69
|
+
try {
|
|
70
|
+
while (index < rows.length) controller.enqueue(payloadRowBytes(rows[index++]));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
over = true;
|
|
73
|
+
controller.error(error);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (globals.__rsc_done === true) {
|
|
77
|
+
over = true;
|
|
78
|
+
controller.close();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (globals.document?.readyState === 'complete') return;
|
|
82
|
+
delay = index > from ? 0 : Math.min(MAX_POLL_MS, Math.max(4, delay * 2));
|
|
83
|
+
timer = setTimeout(drain, delay);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Read as the renderer's scripts push, not when the next look comes.
|
|
87
|
+
const push = rows.push;
|
|
88
|
+
Object.defineProperty(rows, 'push', {
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: (/** @type {unknown[]} */ ...items) => {
|
|
92
|
+
const length = push.apply(rows, items);
|
|
93
|
+
drain();
|
|
94
|
+
return length;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
drain();
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/poll.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Router, VisitOptions } from './router';
|
|
2
|
+
|
|
3
|
+
/** How much slower a `throttle` poll runs while its tab is in the background. */
|
|
4
|
+
export declare const THROTTLE_FACTOR: number;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a tick does about the poll before it, still in flight: `rest` waits
|
|
8
|
+
* for it and runs the interval from when it settled (the default); `cancel`
|
|
9
|
+
* cancels it; `overlap` lets both run, and an older answer arriving after a
|
|
10
|
+
* newer one is dropped.
|
|
11
|
+
*/
|
|
12
|
+
export type PollMode = 'overlap' | 'cancel' | 'rest';
|
|
13
|
+
|
|
14
|
+
/** While the tab is hidden: ten times less often (the default), not at all, or as usual. */
|
|
15
|
+
export type PollBackground = 'throttle' | 'pause' | 'continue';
|
|
16
|
+
|
|
17
|
+
export interface PollSettings {
|
|
18
|
+
mode?: PollMode;
|
|
19
|
+
background?: PollBackground;
|
|
20
|
+
/** Start on mount. True unless given. */
|
|
21
|
+
autoStart?: boolean;
|
|
22
|
+
/** After each poll, have loaded <Deferred> boundaries fetch again, quietly. */
|
|
23
|
+
deferred?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Poller {
|
|
27
|
+
start(): void;
|
|
28
|
+
stop(): void;
|
|
29
|
+
readonly running: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The poll's timing without React: each tick is `router.reload({ ...options(), background: true })`. */
|
|
33
|
+
export declare function createPoller(settings: {
|
|
34
|
+
router: Pick<Router, 'reload'>;
|
|
35
|
+
interval: number;
|
|
36
|
+
options?: () => VisitOptions;
|
|
37
|
+
mode?: PollMode;
|
|
38
|
+
background?: PollBackground;
|
|
39
|
+
document?: Document;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** Called after each poll that landed. */
|
|
42
|
+
onPolled?: () => void;
|
|
43
|
+
}): Poller;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Refreshes the page on screen every `interval` ms through background
|
|
47
|
+
* reloads -- partial ones, given `only` or `except` -- while the component
|
|
48
|
+
* is mounted. Never navigates; a version mismatch fires `location` instead
|
|
49
|
+
* of reloading.
|
|
50
|
+
*/
|
|
51
|
+
export declare function usePoll(
|
|
52
|
+
interval: number,
|
|
53
|
+
options?: Omit<VisitOptions, 'background' | 'signal'>,
|
|
54
|
+
settings?: PollSettings,
|
|
55
|
+
): { start: () => void; stop: () => void };
|