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