@tinvar/react 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
package/src/submit.js ADDED
@@ -0,0 +1,333 @@
1
+ // One submission of an action, start to finish: send it, then do what the
2
+ // answer says to the page.
3
+ //
4
+ // <Form> and useForm both submit through here, so "top-level props control
5
+ // the submission, `options` controls the visit after it" means the same in
6
+ // each. The visit after it is whatever the answer asks for:
7
+ //
8
+ // a redirect a client visit there, with `options`
9
+ // a re-rendered payload mounted as the page that is up, with `options`
10
+ // revalidated, no payload the page reloaded -- "reload that route yourself"
11
+ // any success every prefetched page, dropped
12
+ // invalidated tags the page's loaded <Deferred> boundaries, fetched
13
+ // again
14
+ // a flash onFlash, and a `tinvar:flash` event
15
+ //
16
+ // and only then onSuccess, so a callback sees the page the action left.
17
+ // Around all of it, the router's `actionStart` and `actionSettle` -- once
18
+ // the answer has been applied, whatever it was -- and `progress` as files
19
+ // upload.
20
+
21
+ import { actionName, actionUrl, optimisticPatch } from './action.js';
22
+ import { beginRevalidation, revalidateDeferred } from './deferred-revalidation.js';
23
+ import { invalidatedTags } from './router.js';
24
+ import { send } from './transport.js';
25
+ import {
26
+ announceAction,
27
+ announceFlash,
28
+ applyPayloads,
29
+ assignLocation,
30
+ endSession,
31
+ flushAfterAction,
32
+ followRedirect,
33
+ routeTokens,
34
+ } from './router-hooks.js';
35
+
36
+ /**
37
+ * @typedef {{
38
+ * value: unknown,
39
+ * flash: Record<string, unknown>,
40
+ * revalidated: string[],
41
+ * payloads: Record<string, string>,
42
+ * redirect?: string,
43
+ * }} ActionResult
44
+ *
45
+ * @typedef {{
46
+ * status: number,
47
+ * code: string | null,
48
+ * reference: string | null,
49
+ * retryAfter: number | null,
50
+ * body: unknown,
51
+ * }} HttpFailure
52
+ *
53
+ * @typedef {{
54
+ * onStart?: () => void,
55
+ * onProgress?: (progress: import('./transport.js').Progress) => void,
56
+ * onSuccess?: (result: ActionResult) => void | Promise<void>,
57
+ * onError?: (errors: Record<string, string[]>) => void,
58
+ * onHttpException?: (failure: HttpFailure) => void,
59
+ * onNetworkError?: (error: unknown) => void,
60
+ * onCancel?: () => void,
61
+ * onFinish?: () => void,
62
+ * onFlash?: (flash: Record<string, unknown>) => void,
63
+ * onExpired?: () => void,
64
+ * }} Callbacks
65
+ *
66
+ * @typedef {(
67
+ * | { type: 'success', result: ActionResult }
68
+ * | { type: 'validation', errors: Record<string, string[]> }
69
+ * | { type: 'http', failure: HttpFailure }
70
+ * | { type: 'network', error: unknown }
71
+ * | { type: 'expired' }
72
+ * | { type: 'cancelled' }
73
+ * )} Submitted
74
+ */
75
+
76
+ /**
77
+ * @param {{
78
+ * action: import('./action.js').ActionTarget,
79
+ * data: Record<string, unknown>,
80
+ * router?: import('./router-hooks.js').RouterLike,
81
+ * options?: Record<string, unknown>,
82
+ * headers?: Record<string, string>,
83
+ * callbacks?: Callbacks,
84
+ * signal?: AbortSignal,
85
+ * hardNavigate?: (url: string) => void,
86
+ * optimistic?: import('./optimistic.js').Store | null,
87
+ * }} submission
88
+ * @returns {Promise<Submitted>}
89
+ */
90
+ export async function submitAction({
91
+ action,
92
+ data,
93
+ router,
94
+ options = {},
95
+ headers = {},
96
+ callbacks = {},
97
+ signal,
98
+ hardNavigate = assignLocation,
99
+ optimistic = null,
100
+ }) {
101
+ const name = actionName(action);
102
+
103
+ // "The client sends the route tokens it is holding", so the action can
104
+ // re-render the page that is up. Transport, not input: the endpoint takes
105
+ // them off before the form request sees the data.
106
+ const tokens = routeTokens(router);
107
+ const body = tokens.length > 0 ? { ...data, _routes: tokens } : data;
108
+
109
+ // `action.optimistic(patch)`: the patch shows from now, and gives way to
110
+ // the server's re-render once the answer -- payload and all -- has been
111
+ // applied, or is dropped when the request fails (ADR 0016).
112
+ const ticket = optimisticBegin(optimistic, action);
113
+
114
+ // An action that carries the mounted page's token may re-render it, so
115
+ // the page's <Deferred> boundaries show their content as `reloading`
116
+ // until it is known whether it did -- and fetch it again if so.
117
+ const revalidation = tokens.length > 0 ? beginRevalidation() : null;
118
+ let rerendered = false;
119
+ // Tags the action invalidated, on the page it leaves up: what a mounted
120
+ // fragment shows may have read them, whether or not a route was
121
+ // re-rendered -- or a route token sent at all.
122
+ let tagged = false;
123
+
124
+ const settle = (/** @type {boolean} */ succeeded) => {
125
+ if (ticket !== null && optimistic) optimistic.settle(ticket, succeeded);
126
+ if (revalidation) revalidation({ revalidated: tagged || rerendered });
127
+ else if (tagged) revalidateDeferred();
128
+ };
129
+ let succeeded = false;
130
+
131
+ // How the call came back, for its `actionSettle`: what it returned, or
132
+ // `error` for something that threw on the way.
133
+ /** @type {{ type: string, status?: number }} */
134
+ let settledAs = { type: 'error' };
135
+ const settled = (/** @type {Submitted} */ submitted) => {
136
+ settledAs =
137
+ submitted.type === 'http'
138
+ ? { type: 'http', status: submitted.failure.status }
139
+ : { type: submitted.type };
140
+ return submitted;
141
+ };
142
+
143
+ announceAction(router, 'actionStart', { action: name });
144
+ call(callbacks.onStart);
145
+
146
+ try {
147
+ const outcome = await send({
148
+ url: actionUrl(name),
149
+ data: body,
150
+ headers,
151
+ signal,
152
+ onUploadProgress: (progress) => {
153
+ announceAction(router, 'progress', { action: name, progress });
154
+ callbacks.onProgress?.(progress);
155
+ },
156
+ });
157
+
158
+ switch (outcome.type) {
159
+ case 'success': {
160
+ const result = resultOf(outcome.body);
161
+ flushAfterAction(router, outcome.headers);
162
+ if (Object.keys(result.flash).length > 0) {
163
+ call(callbacks.onFlash, result.flash);
164
+ announceFlash(router, result.flash, name);
165
+ }
166
+ rerendered = await applyPayloads(
167
+ router,
168
+ result.payloads,
169
+ result.revalidated,
170
+ options,
171
+ tokens[0] ?? null,
172
+ outcome.body?.live,
173
+ );
174
+ tagged = invalidatedTags(outcome.headers).length > 0;
175
+ succeeded = true;
176
+ settle(true);
177
+ await callAsync(callbacks.onSuccess, result);
178
+ return settled({ type: 'success', result });
179
+ }
180
+
181
+ case 'redirect':
182
+ case 'location': {
183
+ const result = { ...resultOf(null), redirect: outcome.location };
184
+ flushAfterAction(router, outcome.headers);
185
+ // X-Location is another origin, allowlisted by the server: always a
186
+ // document load, as the router treats one.
187
+ if (outcome.type === 'redirect')
188
+ await followRedirect(router, outcome.location, options, hardNavigate);
189
+ else hardNavigate(outcome.location);
190
+ succeeded = true;
191
+ settle(true);
192
+ await callAsync(callbacks.onSuccess, result);
193
+ return settled({ type: 'success', result });
194
+ }
195
+
196
+ case 'validation':
197
+ // The patch goes; the errors stay, on the form that owns them.
198
+ call(callbacks.onError, outcome.errors);
199
+ return settled({ type: 'validation', errors: outcome.errors });
200
+
201
+ case 'http': {
202
+ /** @type {HttpFailure} */
203
+ const failure = {
204
+ status: outcome.status,
205
+ code: outcome.code,
206
+ reference: outcome.reference,
207
+ retryAfter: outcome.retryAfter,
208
+ body: outcome.body,
209
+ };
210
+ call(callbacks.onHttpException, failure);
211
+
212
+ // The session is over, and what the form holds belongs to it; see
213
+ // endSession().
214
+ if (outcome.status === 401) endSession(router, hardNavigate);
215
+ return settled({ type: 'http', failure });
216
+ }
217
+
218
+ case 'network':
219
+ call(callbacks.onNetworkError, outcome.error);
220
+ return settled({ type: 'network', error: outcome.error });
221
+
222
+ case 'expired':
223
+ // Refused twice for its token: the page outlived its session. "A
224
+ // 419 becomes a same-origin redirect back with a 'page expired'
225
+ // flash, not an error screen." Back is this page, loaded again --
226
+ // a client visit, so what was typed stays -- which hands the
227
+ // session's current token over; the form says the page expired.
228
+ call(callbacks.onExpired);
229
+ if (typeof router?.reload === 'function') {
230
+ await router.reload({ preserveScroll: true });
231
+ rerendered = true;
232
+ } else {
233
+ hardNavigate(globalThis.location.href);
234
+ }
235
+ return settled({ type: 'expired' });
236
+
237
+ default:
238
+ call(callbacks.onCancel);
239
+ return settled({ type: 'cancelled' });
240
+ }
241
+ } finally {
242
+ // Every way but success rolls the patch back: a refusal, a failure, no
243
+ // answer, a cancel -- and a throw on the way, which would otherwise
244
+ // leave it showing for good.
245
+ if (!succeeded) settle(false);
246
+ announceAction(router, 'actionSettle', { action: name, ...settledAs });
247
+ call(callbacks.onFinish);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Begins an action's optimistic patch on the store, if it carries one and
253
+ * there is a store to patch.
254
+ *
255
+ * @param {import('./optimistic.js').Store | null | undefined} store
256
+ * @param {import('./action.js').ActionTarget} action
257
+ * @returns {number | null}
258
+ */
259
+ function optimisticBegin(store, action) {
260
+ const patch = optimisticPatch(action);
261
+ if (patch === null) return null;
262
+ if (!store) {
263
+ // Nothing to patch: the request still runs. Said once, so an app that
264
+ // forgot the boundary finds out why nothing moved.
265
+ if (!warnedNoStore) {
266
+ warnedNoStore = true;
267
+ console.warn('[tinvar:client] An optimistic action ran with no <OptimisticState> above it to patch.');
268
+ }
269
+ return null;
270
+ }
271
+ return store.begin(patch);
272
+ }
273
+
274
+ let warnedNoStore = false;
275
+
276
+ /**
277
+ * The endpoint's body as an ActionResult, whatever arrived: a field the
278
+ * server left out, or sent as the wrong shape, is the empty one.
279
+ *
280
+ * @param {any} body
281
+ * @returns {ActionResult}
282
+ */
283
+ export function resultOf(body) {
284
+ const object = (/** @type {unknown} */ value) =>
285
+ value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
286
+
287
+ return {
288
+ value: body?.value ?? null,
289
+ flash: /** @type {Record<string, unknown>} */ (object(body?.flash)),
290
+ revalidated: Array.isArray(body?.revalidated)
291
+ ? body.revalidated.filter((r) => typeof r === 'string')
292
+ : [],
293
+ payloads: /** @type {Record<string, string>} */ (object(body?.payloads)),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Runs an app callback. Its exception is its own bug, not the submission's:
299
+ * reported, as dispatchEvent reports a listener's, and the submission goes
300
+ * on -- a throw in onStart must not leave a form processing for good.
301
+ *
302
+ * @template {unknown[]} A
303
+ * @param {((...args: A) => unknown) | undefined} fn
304
+ * @param {A} args
305
+ */
306
+ export function call(fn, ...args) {
307
+ if (typeof fn !== 'function') return;
308
+ try {
309
+ fn(...args);
310
+ } catch (error) {
311
+ report(error);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * @template {unknown[]} A
317
+ * @param {((...args: A) => unknown) | undefined} fn
318
+ * @param {A} args
319
+ */
320
+ async function callAsync(fn, ...args) {
321
+ if (typeof fn !== 'function') return;
322
+ try {
323
+ await fn(...args);
324
+ } catch (error) {
325
+ report(error);
326
+ }
327
+ }
328
+
329
+ /** @param {unknown} error */
330
+ function report(error) {
331
+ if (typeof globalThis.reportError === 'function') globalThis.reportError(error);
332
+ else console.error(error);
333
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Client telemetry: each page's web vitals and the errors its script saw, reported to /_vitals and
3
+ * /_errors, and the violations of a policy in a meta element, reported to /_reports.
4
+ */
5
+
6
+ import type { Router } from './router.js';
7
+
8
+ export declare const VITALS_PATH: '/_vitals';
9
+ export declare const ERRORS_PATH: '/_errors';
10
+ export declare const REPORTS_PATH: '/_reports';
11
+
12
+ /** The server's cap on one report, in bytes. */
13
+ export declare const MAX_BODY_BYTES: number;
14
+
15
+ /** Errors reported for one page, at most. */
16
+ export declare const MAX_ERRORS_PER_PAGE: number;
17
+
18
+ /** Errors in one report. */
19
+ export declare const MAX_ERRORS_PER_REPORT: number;
20
+
21
+ /** How long errors are gathered before they are sent, in milliseconds. */
22
+ export declare const ERROR_DELAY_MS: number;
23
+
24
+ /** NAV is a client navigation's: from the visit's start to its page's mount. LCP, FCP and TTFB are the document's alone. */
25
+ export type VitalName = 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB' | 'NAV';
26
+
27
+ /** Where an error was seen: the window's events, or one of React's root hooks. */
28
+ export type ClientErrorKind = 'error' | 'rejection' | 'caught' | 'uncaught' | 'recoverable';
29
+
30
+ /** The largest value of each vital the server believes: milliseconds, and a score for CLS. */
31
+ export declare const VITAL_CEILINGS: Readonly<Record<VitalName, number>>;
32
+
33
+ /** Policy violations sent for one document, at most. */
34
+ export declare const MAX_VIOLATIONS: number;
35
+
36
+ /** The route name a route token names, read without verifying; null for anything else. */
37
+ export declare function routeNameOf(token: string | null | undefined): string | null;
38
+
39
+ export interface TelemetryOptions {
40
+ /** The page's router: the page on screen is read from it. Without one, the document's page metadata. */
41
+ router?: (Pick<Router, 'on' | 'routeTokens'> & Partial<Pick<Router, 'currentView'>>) | null;
42
+ window?: Window;
43
+ fetch?: typeof fetch;
44
+ /** Measure and send web vitals; true unless false. */
45
+ vitals?: boolean;
46
+ /** Listen for the window's errors and unhandled rejections; true unless false. */
47
+ errors?: boolean;
48
+ /** Send the violations of a policy in a meta element to /_reports; true unless false. */
49
+ violations?: boolean;
50
+ }
51
+
52
+ export interface Telemetry {
53
+ /** Starts measuring and listening; each page's vitals are sent once, when it is left or first hidden. */
54
+ start(): Telemetry;
55
+ /** Stops listening and measuring, sending nothing more. */
56
+ stop(): void;
57
+ /** Reports an error for the page on screen: deduplicated, capped per page, and sent in a moment. */
58
+ reportError(error: unknown, kind?: ClientErrorKind): void;
59
+ /** For hydrateRoot()'s options: reports, then logs to the console as React does. */
60
+ readonly onCaughtError: (error: unknown, errorInfo?: unknown) => void;
61
+ /** For hydrateRoot()'s options: reports, then hands the error to the browser as React does. */
62
+ readonly onUncaughtError: (error: unknown, errorInfo?: unknown) => void;
63
+ /** For hydrateRoot()'s options: reports, then hands the error to the browser as React does. */
64
+ readonly onRecoverableError: (error: unknown, errorInfo?: unknown) => void;
65
+ /** Sends the errors waiting now. */
66
+ flush(): void;
67
+ /** The page on screen's vitals measured so far: milliseconds, and a score for CLS. */
68
+ vitals(): Partial<Record<VitalName, number>>;
69
+ }
70
+
71
+ export declare function createTelemetry(options?: TelemetryOptions): Telemetry;