@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
@@ -0,0 +1,219 @@
1
+ // A form's fields as data, and data as a request body.
2
+ //
3
+ // tinvar-spec.md, "Forms and mutations": "<Form action={moveUnit}> with
4
+ // uncontrolled `name` inputs, nested names (`report[tags][]`)", and "Files:
5
+ // payloads with files become multipart automatically". The names are PHP's:
6
+ // a browser posting the same form without script sends them as they are, and
7
+ // PHP builds the same nesting from them -- so the object built here is the
8
+ // one the action would have received either way.
9
+ //
10
+ // No React and nothing from the router: <Form>, useForm and useHttp all use
11
+ // it, and so do their tests, in a plain DOM.
12
+
13
+ /** The hidden field <Form> renders for a post made without script; transport, not data. */
14
+ export const TOKEN_FIELD = '_token';
15
+
16
+ /**
17
+ * The path a field name stands for: `report[tags][]` is report, tags, and a
18
+ * push. An empty segment is a push, as PHP reads it.
19
+ *
20
+ * @param {string} name
21
+ * @returns {string[]}
22
+ */
23
+ export function pathOf(name) {
24
+ const open = name.indexOf('[');
25
+ if (open <= 0 || !name.endsWith(']')) return [name];
26
+
27
+ const path = [name.slice(0, open)];
28
+ const rest = name.slice(open);
29
+ const segment = /\[([^\]]*)\]/g;
30
+ let match;
31
+ let consumed = 0;
32
+ while ((match = segment.exec(rest)) !== null) {
33
+ if (match.index !== consumed) return [name];
34
+ path.push(match[1] ?? '');
35
+ consumed = segment.lastIndex;
36
+ }
37
+
38
+ // Anything after the last bracket is not PHP's nesting; the name is a name.
39
+ return consumed === rest.length ? path : [name];
40
+ }
41
+
42
+ /**
43
+ * Sets a value at a field name's path, building the nesting on the way.
44
+ *
45
+ * @param {Record<string, unknown>} target
46
+ * @param {string} name
47
+ * @param {unknown} value
48
+ */
49
+ export function setPath(target, name, value) {
50
+ const path = pathOf(name);
51
+ /** @type {any} */
52
+ let level = target;
53
+
54
+ for (let i = 0; i < path.length; i++) {
55
+ const key = path[i];
56
+ const last = i === path.length - 1;
57
+
58
+ // A key a prototype answers to is not a field: `__proto__[x]` would
59
+ // otherwise write onto Object.prototype for every object on the page.
60
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
61
+
62
+ if (key === '') {
63
+ if (!Array.isArray(level)) return;
64
+ if (last) {
65
+ level.push(value);
66
+ return;
67
+ }
68
+ const next = path[i + 1] === '' ? [] : {};
69
+ level.push(next);
70
+ level = next;
71
+ continue;
72
+ }
73
+
74
+ if (last) {
75
+ level[key] = value;
76
+ return;
77
+ }
78
+
79
+ const wantsList = path[i + 1] === '';
80
+ const existing = Object.hasOwn(level, key) ? level[key] : undefined;
81
+ if (existing === null || typeof existing !== 'object' || Array.isArray(existing) !== wantsList) {
82
+ level[key] = wantsList ? [] : {};
83
+ }
84
+ level = level[key];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The value at a field name's path, or undefined. A trailing `[]` names the
90
+ * whole list.
91
+ *
92
+ * @param {unknown} source
93
+ * @param {string} name
94
+ */
95
+ export function getPath(source, name) {
96
+ const path = pathOf(name);
97
+ if (path.at(-1) === '' && path.length > 1) path.pop();
98
+
99
+ /** @type {any} */
100
+ let level = source;
101
+ for (const key of path) {
102
+ if (level === null || typeof level !== 'object' || !Object.hasOwn(level, key)) return undefined;
103
+ level = level[key];
104
+ }
105
+ return level;
106
+ }
107
+
108
+ /**
109
+ * A form's fields, as the object its action receives.
110
+ *
111
+ * Read through FormData, so it is exactly what the browser would post: only
112
+ * checked boxes, every selected option, the button that submitted it, files
113
+ * as File objects. An empty file input -- nothing chosen -- is left out
114
+ * rather than sent as a nameless, empty file. The CSRF field is left out:
115
+ * the token travels in the header.
116
+ *
117
+ * @param {HTMLFormElement} form
118
+ * @param {HTMLElement | null} [submitter]
119
+ * @returns {Record<string, unknown>}
120
+ */
121
+ export function formToObject(form, submitter = null) {
122
+ /** @type {FormData} */
123
+ let entries;
124
+ try {
125
+ entries = submitter ? new FormData(form, submitter) : new FormData(form);
126
+ } catch {
127
+ // A submitter that is not this form's own button.
128
+ entries = new FormData(form);
129
+ }
130
+
131
+ /** @type {Record<string, unknown>} */
132
+ const data = {};
133
+ for (const [name, value] of entries) {
134
+ if (name === TOKEN_FIELD) continue;
135
+ if (isFile(value) && value.name === '' && value.size === 0) continue;
136
+ setPath(data, name, value);
137
+ }
138
+ return data;
139
+ }
140
+
141
+ /** @param {unknown} value */
142
+ export function isFile(value) {
143
+ return (
144
+ (typeof File !== 'undefined' && value instanceof File) ||
145
+ (typeof Blob !== 'undefined' && value instanceof Blob)
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Whether data holds a file anywhere, so has to go as multipart.
151
+ *
152
+ * @param {unknown} value
153
+ * @returns {boolean}
154
+ */
155
+ export function hasFiles(value) {
156
+ if (isFile(value)) return true;
157
+ if (typeof FileList !== 'undefined' && value instanceof FileList) return value.length > 0;
158
+ if (Array.isArray(value)) return value.some(hasFiles);
159
+ if (value !== null && typeof value === 'object') return Object.values(value).some(hasFiles);
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * The data without its files. Precognition leaves them out by default
165
+ * ("files excluded by default"): validating an email should not upload the
166
+ * attachment beside it on every keystroke.
167
+ *
168
+ * @param {unknown} value
169
+ * @returns {unknown}
170
+ */
171
+ export function withoutFiles(value) {
172
+ if (isFile(value) || (typeof FileList !== 'undefined' && value instanceof FileList)) return undefined;
173
+ if (Array.isArray(value)) return value.map(withoutFiles).filter((v) => v !== undefined);
174
+ if (value !== null && typeof value === 'object') {
175
+ /** @type {Record<string, unknown>} */
176
+ const out = {};
177
+ for (const [key, item] of Object.entries(value)) {
178
+ const kept = withoutFiles(item);
179
+ if (kept !== undefined) out[key] = kept;
180
+ }
181
+ return out;
182
+ }
183
+ return value;
184
+ }
185
+
186
+ /**
187
+ * Data as multipart, under the names PHP reads back into the same nesting:
188
+ * `report[tags][]` for a list, `report[title]` for a field. Booleans go as
189
+ * 1 and 0, and null as an empty string, as an HTML form would send them.
190
+ *
191
+ * @param {Record<string, unknown>} data
192
+ * @returns {FormData}
193
+ */
194
+ export function toFormData(data) {
195
+ const out = new FormData();
196
+
197
+ /** @param {string} name @param {unknown} value */
198
+ const append = (name, value) => {
199
+ if (value === undefined) return;
200
+ if (isFile(value)) {
201
+ out.append(name, /** @type {Blob} */ (value));
202
+ } else if (typeof FileList !== 'undefined' && value instanceof FileList) {
203
+ for (const file of value) out.append(`${name}[]`, file);
204
+ } else if (Array.isArray(value)) {
205
+ for (const item of value) append(`${name}[]`, item);
206
+ } else if (value instanceof Date) {
207
+ out.append(name, value.toISOString());
208
+ } else if (value !== null && typeof value === 'object') {
209
+ for (const [key, item] of Object.entries(value)) append(`${name}[${key}]`, item);
210
+ } else if (typeof value === 'boolean') {
211
+ out.append(name, value ? '1' : '0');
212
+ } else {
213
+ out.append(name, value === null ? '' : String(value));
214
+ }
215
+ };
216
+
217
+ for (const [key, value] of Object.entries(data)) append(key, value);
218
+ return out;
219
+ }
package/src/form.d.ts ADDED
@@ -0,0 +1,177 @@
1
+ import type { FormHTMLAttributes, ReactNode } from 'react';
2
+ import type { ActionTarget } from './action';
3
+ import type { PrecognitionTarget } from './endpoint';
4
+
5
+ /** Upload progress, while a multipart submission is sent. */
6
+ export interface Progress {
7
+ loaded: number;
8
+ total: number | undefined;
9
+ percentage: number | undefined;
10
+ }
11
+
12
+ /** What an action answered, as onSuccess sees it. */
13
+ export interface ActionResult {
14
+ value: unknown;
15
+ flash: Record<string, unknown>;
16
+ revalidated: string[];
17
+ payloads: Record<string, string>;
18
+ /** Where the action redirected, when it did; the visit has happened. */
19
+ redirect?: string;
20
+ }
21
+
22
+ /** A status other than 422, as onHttpException sees it. */
23
+ export interface HttpFailure {
24
+ status: number;
25
+ /** The server's machine-readable code, such as `too_many_attempts`. */
26
+ code: string | null;
27
+ /** The server's digest for the failure, to quote to support. */
28
+ reference: string | null;
29
+ /** Seconds, from Retry-After. */
30
+ retryAfter: number | null;
31
+ body: unknown;
32
+ }
33
+
34
+ /** The visit after a submission: the redirect it follows, or the page it re-renders. */
35
+ export interface VisitOptions {
36
+ replace?: boolean;
37
+ preserveScroll?: boolean;
38
+ preserveState?: boolean;
39
+ [option: string]: unknown;
40
+ }
41
+
42
+ /** What a submission does at each step; top-level on `<Form>`, an argument to useForm().submit(). */
43
+ export interface SubmitCallbacks<Data = Record<string, unknown>> {
44
+ /** Return false to stop the submission. */
45
+ onBefore?: (data: Data) => boolean | void;
46
+ onStart?: () => void;
47
+ onProgress?: (progress: Progress) => void;
48
+ onSuccess?: (result: ActionResult) => void | Promise<void>;
49
+ /** 422: the field errors, which the form also shows. */
50
+ onError?: (errors: Record<string, string[]>) => void;
51
+ /** Any other status. */
52
+ onHttpException?: (failure: HttpFailure) => void;
53
+ /** No answer at all. */
54
+ onNetworkError?: (error: unknown) => void;
55
+ onCancel?: () => void;
56
+ onFinish?: () => void;
57
+ onFlash?: (flash: Record<string, unknown>) => void;
58
+ /** Refused twice for its CSRF token: the page outlived its session, and is being loaded again. */
59
+ onExpired?: () => void;
60
+ }
61
+
62
+ /** What `<Form>` hands its children, and useFormContext() returns. */
63
+ export interface FormSlotProps {
64
+ /** The first message for each field. */
65
+ errors: Record<string, string>;
66
+ hasErrors: boolean;
67
+ processing: boolean;
68
+ progress: Progress | null;
69
+ wasSuccessful: boolean;
70
+ /** True for two seconds after a success. */
71
+ recentlySuccessful: boolean;
72
+ isDirty: boolean;
73
+ /** Puts the named fields -- or every field -- back to their defaults. */
74
+ reset: (...fields: string[]) => void;
75
+ submit: () => void;
76
+ cancel: () => void;
77
+ setError: {
78
+ (field: string, message: string): void;
79
+ (errors: Record<string, string>): void;
80
+ };
81
+ clearErrors: (...fields: string[]) => void;
82
+ /**
83
+ * What a post made without script sent for a field, to put back as its
84
+ * default value after the server sent the form back. Never a password.
85
+ */
86
+ old: <T = string>(field: string, fallback?: T) => T | undefined;
87
+ /**
88
+ * The page outlived its session: a 419 survived the retry with the
89
+ * refreshed token, or a post made without script was sent back for it.
90
+ * Not an error screen -- what was typed stays, and the next submit works.
91
+ */
92
+ expired: boolean;
93
+ /** Precognition: asks the action's form request about these fields, after the validation timeout. */
94
+ validate: (field: string | string[]) => void;
95
+ /** A Precognition request is out. */
96
+ validating: boolean;
97
+ /** Precognition said the field is fine, and nothing has said otherwise since. */
98
+ valid: (field: string) => boolean;
99
+ /** The field has an error. */
100
+ invalid: (field: string) => boolean;
101
+ }
102
+
103
+ /** Precognition's settings, on `<Form>` and `useForm().withPrecognition()`. */
104
+ export interface PrecognitionSettings {
105
+ /** How long validate() gathers fields before asking, in ms. 1500 by default. */
106
+ validationTimeout?: number;
107
+ /** Send files with a validation request too. Off by default. */
108
+ validateFiles?: boolean;
109
+ }
110
+
111
+ export interface FormProps
112
+ extends
113
+ SubmitCallbacks,
114
+ PrecognitionSettings,
115
+ Omit<FormHTMLAttributes<HTMLFormElement>, 'action' | 'method' | 'children' | 'onError' | 'onProgress'> {
116
+ /** A reference from `@actions/*`, or the action's name. */
117
+ action: ActionTarget;
118
+ /**
119
+ * What validate() asks in place of the action: an endpoint, by
120
+ * `endpoint.precognitive()` (ADR 0125). The form still submits to its action.
121
+ */
122
+ validateAgainst?: PrecognitionTarget;
123
+ /** Reshapes the fields before they are sent. */
124
+ transform?: (data: Record<string, unknown>) => Record<string, unknown>;
125
+ /** The visit after the submission. */
126
+ options?: VisitOptions;
127
+ headers?: Record<string, string>;
128
+ resetOnSuccess?: boolean | string[];
129
+ resetOnError?: boolean | string[];
130
+ /** After a success, what the fields hold becomes what isDirty compares with. */
131
+ setDefaultsOnSuccess?: boolean;
132
+ /** Makes the form inert while it is processing. */
133
+ disableWhileProcessing?: boolean;
134
+ children?: ReactNode | ((slot: FormSlotProps) => ReactNode);
135
+ }
136
+
137
+ /**
138
+ * A real `<form method="post" action="/_action/…">`: an ordinary post before
139
+ * JavaScript loads, a fetch with the answer applied in place after.
140
+ */
141
+ export declare function Form(props: FormProps): ReactNode;
142
+
143
+ /** What a post made without script left for the page: see FlashStore. */
144
+ export interface Flashed {
145
+ flash?: Record<string, unknown>;
146
+ form?: {
147
+ action: string;
148
+ errors?: Record<string, string[]>;
149
+ old?: Record<string, unknown>;
150
+ expired?: boolean;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * The server's per-request state for the forms below it: the CSRF token for
156
+ * the hidden field, and what a post made without script flashed. Render it
157
+ * once, around the page, from the app's render entry.
158
+ */
159
+ export declare function FormSession(props: {
160
+ csrfToken?: string | null;
161
+ flash?: Flashed | null;
162
+ /** A full page load; tests replace it. */
163
+ hardNavigate?: (url: string) => void;
164
+ children?: ReactNode;
165
+ }): ReactNode;
166
+
167
+ /** The flash the page should show: a post's, carried across its redirect, or an action's since. */
168
+ export declare function useFlash(): Record<string, unknown>;
169
+
170
+ /**
171
+ * The nearest `<Form>`'s slot props, or the useForm() form a `<FormProvider>`
172
+ * shares; null outside either. Name the shape you expect.
173
+ */
174
+ export declare function useFormContext<T = FormSlotProps>(): T | null;
175
+
176
+ /** How long `recentlySuccessful` stays true, in ms. */
177
+ export declare const RECENTLY_SUCCESSFUL_MS: number;