@rsc-kit/core 0.11.0 → 0.13.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.
- package/README.md +15 -10
- package/dist/js/Form.d.ts +152 -3
- package/dist/js/Form.js +293 -29
- package/dist/js/Form.js.map +1 -1
- package/dist/js/formEncoding.d.ts +8 -0
- package/dist/js/formEncoding.js +37 -0
- package/dist/js/formEncoding.js.map +1 -0
- package/dist/js/formStore.d.ts +9 -0
- package/dist/js/formStore.js +43 -0
- package/dist/js/formStore.js.map +1 -0
- package/dist/js/updateStore.d.ts +17 -0
- package/dist/js/updateStore.js +44 -0
- package/dist/js/updateStore.js.map +1 -0
- package/dist/js/useAppUpdate.d.ts +4 -0
- package/dist/js/useAppUpdate.js +29 -0
- package/dist/js/useAppUpdate.js.map +1 -0
- package/dist/routes.d.ts +28 -0
- package/dist/routes.js +15 -1
- package/dist/routes.js.map +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +147 -19
- package/dist/vite.js.map +1 -1
- package/package.json +14 -6
- package/dist/js/useForm.d.ts +0 -42
- package/dist/js/useForm.js +0 -164
- package/dist/js/useForm.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rsc-kit/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React Server Components as a Vite plugin: it discovers the route tree, renders it, and serves it. Nitro builds the server around it, so there is no server file to write.
|
|
4
4
|
|
|
5
5
|
This is the library. Most people want [`rsc-kit`](https://www.npmjs.com/package/rsc-kit) (the command line) or `bun create rsc-kit@latest` (a new app).
|
|
6
6
|
|
|
@@ -38,19 +38,16 @@ is ~54 kB. The build refuses the combination rather than shipping a button that
|
|
|
38
38
|
does nothing.
|
|
39
39
|
|
|
40
40
|
**And the framework itself is small, because most of what ships is React.**
|
|
41
|
-
Measured on the example app:
|
|
42
|
-
framework's own runtime is
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
Measured on the example app: a typical route is 85 kB gzipped, of which
|
|
42
|
+
**66 kB is React and react-dom** and this framework's own runtime is 6 kB.
|
|
43
|
+
Client components are chunked per module, so a route only downloads the ones it
|
|
44
|
+
renders — the three routes using TanStack Query are the only ones that pay for
|
|
45
|
+
it. Serving a page frozen at build time costs the server about 25 µs, because
|
|
46
|
+
it renders nothing.
|
|
47
47
|
|
|
48
48
|
**It compiles to a single binary.** `bun build --compile` with the assets and
|
|
49
49
|
frozen pages inside it.
|
|
50
50
|
|
|
51
|
-
**And it is not only JavaScript.** The same engine drives a Laravel host over a
|
|
52
|
-
socket, which is where this started.
|
|
53
|
-
|
|
54
51
|
## What you get
|
|
55
52
|
|
|
56
53
|
- File-based routing with layouts, loading boundaries, parallel routes and
|
|
@@ -59,10 +56,18 @@ socket, which is where this started.
|
|
|
59
56
|
stored, the part that needs the request is rendered per visit
|
|
60
57
|
- Server actions, with an optional builder that gives you validation
|
|
61
58
|
(any Standard Schema library — Zod, Valibot, ArkType) and middleware
|
|
59
|
+
- API routes in `app/**/route.ts` — a real `Request` in, a real `Response` out,
|
|
60
|
+
frozen at build time when they read nothing from the request
|
|
61
|
+
- Typed urls: `params`, `searchParams` and request bodies arrive parsed through
|
|
62
|
+
a schema you export beside the route
|
|
62
63
|
- Typed routes: every build writes the routes it found, so a link to a page
|
|
63
64
|
that does not exist stops compiling
|
|
64
65
|
- `middleware.ts` that runs before every render below it, on every path
|
|
65
66
|
- Request and response access — `headers()`, `cookies()`, `responseHeaders()`
|
|
67
|
+
- Offline and installable: a generated service worker, a web manifest, icons
|
|
68
|
+
found by name, and a place to put your own push handlers
|
|
69
|
+
- A build that explains itself — why each route is dynamic, what it ships, and
|
|
70
|
+
a report an `@rsc-kit/mcp` server can answer questions from
|
|
66
71
|
|
|
67
72
|
## Status
|
|
68
73
|
|
package/dist/js/Form.d.ts
CHANGED
|
@@ -1,18 +1,124 @@
|
|
|
1
1
|
import type { Href } from "../routes.js";
|
|
2
2
|
import { type FormHTMLAttributes, type ReactNode } from "react";
|
|
3
|
+
import type { FormStore } from "./formStore";
|
|
3
4
|
import type { StandardSchemaV1 } from "./standardSchema";
|
|
4
5
|
type PrefetchStrategy = "hover" | "mount" | "none";
|
|
6
|
+
/**
|
|
7
|
+
* What `field(name)` hands a control, spread straight onto it.
|
|
8
|
+
*
|
|
9
|
+
* The same four things react-hook-form's `<Controller>` gives, because it is
|
|
10
|
+
* the same job: a component with no native control behind it needs a value and
|
|
11
|
+
* a way to report a new one.
|
|
12
|
+
*/
|
|
13
|
+
interface FieldBinding<V> {
|
|
14
|
+
name: string;
|
|
15
|
+
value: V;
|
|
16
|
+
/**
|
|
17
|
+
* Either shape: a DOM event, or the value itself.
|
|
18
|
+
*
|
|
19
|
+
* A native input passes the event; a Radix select or a rich editor passes
|
|
20
|
+
* what was chosen. A binder understanding only one of them would work on
|
|
21
|
+
* half the controls anyone actually uses.
|
|
22
|
+
*/
|
|
23
|
+
onChange: (next: V | {
|
|
24
|
+
target: {
|
|
25
|
+
value: V;
|
|
26
|
+
};
|
|
27
|
+
}) => void;
|
|
28
|
+
onBlur: () => void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What is known about one field, separately from what is spread onto it.
|
|
32
|
+
*
|
|
33
|
+
* Two objects rather than one, which is react-hook-form's split and it is right
|
|
34
|
+
* for a mechanical reason: `touched` and `invalid` are not DOM attributes, so a
|
|
35
|
+
* single spreadable object would put them on the element and React would warn
|
|
36
|
+
* about every one.
|
|
37
|
+
*/
|
|
38
|
+
interface FieldState {
|
|
39
|
+
/** Whether it has been left at least once. */
|
|
40
|
+
touched: boolean;
|
|
41
|
+
/** Whether it currently has errors. */
|
|
42
|
+
invalid: boolean;
|
|
43
|
+
/** Its messages, ready for a `<FieldError>`. */
|
|
44
|
+
errors: string[];
|
|
45
|
+
}
|
|
5
46
|
interface FormRenderProps<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
6
47
|
pending: boolean;
|
|
7
48
|
data: T;
|
|
8
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Keyed by field name, so a typo is a type error rather than undefined.
|
|
51
|
+
*
|
|
52
|
+
* Partial because most fields have none, and nested paths join with dots —
|
|
53
|
+
* `errors['address.city']`, which is the key a Standard Schema issue for that
|
|
54
|
+
* field produces.
|
|
55
|
+
*/
|
|
56
|
+
errors: Partial<Record<keyof T & string, string[]>> & Record<string, string[] | undefined>;
|
|
9
57
|
error: (field: keyof T & string) => string | undefined;
|
|
10
58
|
clearErrors: (...fields: (keyof T & string)[]) => void;
|
|
11
59
|
reset: () => void;
|
|
60
|
+
/** Whether the last submit was accepted. */
|
|
61
|
+
succeeded: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* The same thing, for two seconds.
|
|
64
|
+
*
|
|
65
|
+
* The "Saved ✓" that appears and fades. Worth having as state rather than a
|
|
66
|
+
* timer in every form that wants one, because the timer has to be cleared
|
|
67
|
+
* when the component goes away and that is the part people forget.
|
|
68
|
+
*/
|
|
69
|
+
recentlySucceeded: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Bind one field so this component holds its value.
|
|
72
|
+
*
|
|
73
|
+
* Most fields need nothing: they are uncontrolled, the DOM holds the value,
|
|
74
|
+
* and it is read back as FormData on submit. Reach for this when the DOM
|
|
75
|
+
* cannot hold it for you — a control with no native element behind it, or a
|
|
76
|
+
* value you want to read as it is typed:
|
|
77
|
+
*
|
|
78
|
+
* <Input {...field('title')} />
|
|
79
|
+
* <span>{field('body').value.length}/100</span>
|
|
80
|
+
*
|
|
81
|
+
* A bound field is still an ordinary named input, so it arrives in FormData
|
|
82
|
+
* with everything else. Nothing merges; there is one source of truth.
|
|
83
|
+
*/
|
|
84
|
+
field: <K extends keyof T & string>(name: K) => FieldBinding<T[K]>;
|
|
85
|
+
/**
|
|
86
|
+
* What is known about a field, for deciding how to show it.
|
|
87
|
+
*
|
|
88
|
+
* const title = fieldState('title')
|
|
89
|
+
*
|
|
90
|
+
* <Field data-invalid={title.invalid}>
|
|
91
|
+
* <Input {...field('title')} aria-invalid={title.invalid} />
|
|
92
|
+
* <FieldError errors={title.errors.map((message) => ({ message }))} />
|
|
93
|
+
* </Field>
|
|
94
|
+
*
|
|
95
|
+
* `touched` is what separates "not filled in yet" from "filled in wrongly" —
|
|
96
|
+
* an error on a field nobody has visited is a form shouting before anyone
|
|
97
|
+
* has done anything.
|
|
98
|
+
*/
|
|
99
|
+
fieldState: (name: string) => FieldState;
|
|
12
100
|
}
|
|
13
101
|
interface FormProps<T extends Record<string, unknown> = Record<string, unknown>> extends Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "method" | "children" | "onSubmit" | "onError"> {
|
|
14
102
|
action: Href | ((formData: FormData) => Promise<unknown>);
|
|
15
103
|
method?: "get" | "post";
|
|
104
|
+
/**
|
|
105
|
+
* Starting values for fields bound with `field()`.
|
|
106
|
+
*
|
|
107
|
+
* Uncontrolled fields do not need this — they take React's own
|
|
108
|
+
* `defaultValue`, and the DOM keeps whatever is typed into them.
|
|
109
|
+
*/
|
|
110
|
+
defaultValues?: Partial<T>;
|
|
111
|
+
/**
|
|
112
|
+
* A store created above this form, from `useFormStore()`.
|
|
113
|
+
*
|
|
114
|
+
* For the one case the context cannot reach: something that is not a
|
|
115
|
+
* descendant — a top bar showing unsaved changes, a sidebar preview — needs
|
|
116
|
+
* the values to exist above both of them. Create the store where they share
|
|
117
|
+
* an ancestor and hand it down.
|
|
118
|
+
*
|
|
119
|
+
* Without it the form makes its own, which is what almost every form wants.
|
|
120
|
+
*/
|
|
121
|
+
store?: FormStore;
|
|
16
122
|
prefetch?: PrefetchStrategy;
|
|
17
123
|
cacheFor?: number;
|
|
18
124
|
replace?: boolean;
|
|
@@ -43,11 +149,54 @@ interface FormProps<T extends Record<string, unknown> = Record<string, unknown>>
|
|
|
43
149
|
onSubmit?: (formData: FormData) => void | false;
|
|
44
150
|
children: ReactNode | ((form: FormRenderProps<T>) => ReactNode);
|
|
45
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* A value store created above the form rather than by it.
|
|
154
|
+
*
|
|
155
|
+
* const store = useFormStore({ title: '' })
|
|
156
|
+
*
|
|
157
|
+
* <TopBar store={store} /> // not inside the form
|
|
158
|
+
* <Form action={save} store={store}>…</Form>
|
|
159
|
+
*
|
|
160
|
+
* For the one case the context cannot reach. `<Form>` makes its own otherwise,
|
|
161
|
+
* and almost every form should let it — this exists so that a component which
|
|
162
|
+
* is not a descendant can still read the values, which is the flexibility
|
|
163
|
+
* react-hook-form and TanStack Form get from `useForm()` being yours to call.
|
|
164
|
+
*
|
|
165
|
+
* Deliberately not reactive itself: creating the store does not subscribe to
|
|
166
|
+
* it, so the component holding it does not re-render on every keystroke and
|
|
167
|
+
* take the whole subtree with it. Read it with `useFormValues(store)`.
|
|
168
|
+
*/
|
|
169
|
+
export declare function useFormStore<T extends Record<string, unknown>>(initial?: Partial<T>): FormStore;
|
|
170
|
+
/**
|
|
171
|
+
* One field, subscribed on its own.
|
|
172
|
+
*
|
|
173
|
+
* The same thing `field()` gives, from a component that re-renders when this
|
|
174
|
+
* field changes and at no other time. Reach for it when a form is large enough
|
|
175
|
+
* that re-rendering all of it per keystroke is real:
|
|
176
|
+
*
|
|
177
|
+
* function Title() {
|
|
178
|
+
* const { field, invalid, errors } = useField('title')
|
|
179
|
+
*
|
|
180
|
+
* return <Input {...field} aria-invalid={invalid} />
|
|
181
|
+
* }
|
|
182
|
+
*
|
|
183
|
+
* Which is react-hook-form's `<Controller>` without the render prop: the
|
|
184
|
+
* component you already had to write is the subscription boundary.
|
|
185
|
+
*/
|
|
186
|
+
export declare function useField(name: string, store?: FormStore): FieldBinding<string> & FieldState;
|
|
187
|
+
/**
|
|
188
|
+
* Every bound value, from anywhere inside the form.
|
|
189
|
+
*
|
|
190
|
+
* For a summary, a preview, a count of what has changed — something that reads
|
|
191
|
+
* the form without being a field in it. Only values bound through `field()` or
|
|
192
|
+
* `useField` are here: an uncontrolled input's value belongs to the DOM, and
|
|
193
|
+
* this has no way to know it changed.
|
|
194
|
+
*/
|
|
195
|
+
export declare function useFormValues<T extends Record<string, unknown>>(store?: FormStore): Partial<T>;
|
|
46
196
|
export declare function useFormStatus<T extends Record<string, unknown> = Record<string, unknown>>(): FormRenderProps<T>;
|
|
47
197
|
/**
|
|
48
198
|
* Also exported by name, and re-exported below, because both spellings are in
|
|
49
199
|
* use: `import Form from` and `import { Form } from`.
|
|
50
200
|
*/
|
|
51
|
-
export default function Form<T extends Record<string, unknown> = Record<string, unknown>>({ action, method: methodProp, prefetch, cacheFor, replace, preserveScroll, resetOnSuccess, schema, transform, optimistic, onSuccess, onError, onSubmit, children, ...rest }: FormProps<T>): import("react").JSX.Element;
|
|
201
|
+
export default function Form<T extends Record<string, unknown> = Record<string, unknown>>({ action, method: methodProp, defaultValues, store: providedStore, prefetch, cacheFor, replace, preserveScroll, resetOnSuccess, schema, transform, optimistic, onSuccess, onError, onSubmit, children, ...rest }: FormProps<T>): import("react").JSX.Element;
|
|
52
202
|
export { Form };
|
|
53
|
-
export { useForm } from "./useForm";
|
package/dist/js/Form.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useCallback, useContext, useEffect, useRef, useState, useTransition, } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition, } from "react";
|
|
4
4
|
import { ServerValidationError, ServerDumpError } from "./errors";
|
|
5
|
+
import { buildFormData } from "./formEncoding";
|
|
6
|
+
import { createFormStore } from "./formStore";
|
|
5
7
|
import { validateWith } from "./standardSchema";
|
|
6
8
|
/**
|
|
7
9
|
* What an action built with createActionClient answers with.
|
|
@@ -27,16 +29,188 @@ const FormStatusContext = createContext({
|
|
|
27
29
|
error: () => undefined,
|
|
28
30
|
clearErrors: () => { },
|
|
29
31
|
reset: () => { },
|
|
32
|
+
succeeded: false,
|
|
33
|
+
recentlySucceeded: false,
|
|
34
|
+
// Outside a Form there is nothing holding a value, so a binding that reported
|
|
35
|
+
// one would be lying. Name only, which is the part that is still true.
|
|
36
|
+
field: ((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
value: "",
|
|
39
|
+
onChange: () => { },
|
|
40
|
+
onBlur: () => { },
|
|
41
|
+
})),
|
|
42
|
+
fieldState: () => ({ touched: false, invalid: false, errors: [] }),
|
|
30
43
|
});
|
|
44
|
+
/**
|
|
45
|
+
* The store, on its own context.
|
|
46
|
+
*
|
|
47
|
+
* Separate from the status context because that one holds a fresh object every
|
|
48
|
+
* render, so anything reading it re-renders with the form. The store is stable
|
|
49
|
+
* for the life of the form, which is what lets a subscriber below it re-render
|
|
50
|
+
* alone.
|
|
51
|
+
*/
|
|
52
|
+
const FormStoreContext = createContext(null);
|
|
53
|
+
/**
|
|
54
|
+
* A value store created above the form rather than by it.
|
|
55
|
+
*
|
|
56
|
+
* const store = useFormStore({ title: '' })
|
|
57
|
+
*
|
|
58
|
+
* <TopBar store={store} /> // not inside the form
|
|
59
|
+
* <Form action={save} store={store}>…</Form>
|
|
60
|
+
*
|
|
61
|
+
* For the one case the context cannot reach. `<Form>` makes its own otherwise,
|
|
62
|
+
* and almost every form should let it — this exists so that a component which
|
|
63
|
+
* is not a descendant can still read the values, which is the flexibility
|
|
64
|
+
* react-hook-form and TanStack Form get from `useForm()` being yours to call.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately not reactive itself: creating the store does not subscribe to
|
|
67
|
+
* it, so the component holding it does not re-render on every keystroke and
|
|
68
|
+
* take the whole subtree with it. Read it with `useFormValues(store)`.
|
|
69
|
+
*/
|
|
70
|
+
export function useFormStore(initial = {}) {
|
|
71
|
+
const ref = useRef(null);
|
|
72
|
+
ref.current ??= createFormStore({ ...initial });
|
|
73
|
+
return ref.current;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One field, subscribed on its own.
|
|
77
|
+
*
|
|
78
|
+
* The same thing `field()` gives, from a component that re-renders when this
|
|
79
|
+
* field changes and at no other time. Reach for it when a form is large enough
|
|
80
|
+
* that re-rendering all of it per keystroke is real:
|
|
81
|
+
*
|
|
82
|
+
* function Title() {
|
|
83
|
+
* const { field, invalid, errors } = useField('title')
|
|
84
|
+
*
|
|
85
|
+
* return <Input {...field} aria-invalid={invalid} />
|
|
86
|
+
* }
|
|
87
|
+
*
|
|
88
|
+
* Which is react-hook-form's `<Controller>` without the render prop: the
|
|
89
|
+
* component you already had to write is the subscription boundary.
|
|
90
|
+
*/
|
|
91
|
+
export function useField(name, store) {
|
|
92
|
+
const ctx = useContext(FormStoreContext);
|
|
93
|
+
const status = useContext(FormStatusContext);
|
|
94
|
+
if (!ctx && !store) {
|
|
95
|
+
throw new Error("useField() was called outside a <Form>. It reads that form's values, so there has to be one above it.");
|
|
96
|
+
}
|
|
97
|
+
const source = store ?? ctx.store;
|
|
98
|
+
const touch = ctx?.touch;
|
|
99
|
+
const value = useSyncExternalStore(source.subscribe, () => (source.get(name) ?? ""), () => "");
|
|
100
|
+
const errors = status.errors[name] ?? [];
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
value,
|
|
104
|
+
onChange: (next) => {
|
|
105
|
+
source.set(name, typeof next === "object" && next !== null && "target" in next
|
|
106
|
+
? next.target.value
|
|
107
|
+
: next);
|
|
108
|
+
},
|
|
109
|
+
onBlur: () => void touch?.(name),
|
|
110
|
+
touched: status.fieldState(name).touched,
|
|
111
|
+
invalid: errors.length > 0,
|
|
112
|
+
errors,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Every bound value, from anywhere inside the form.
|
|
117
|
+
*
|
|
118
|
+
* For a summary, a preview, a count of what has changed — something that reads
|
|
119
|
+
* the form without being a field in it. Only values bound through `field()` or
|
|
120
|
+
* `useField` are here: an uncontrolled input's value belongs to the DOM, and
|
|
121
|
+
* this has no way to know it changed.
|
|
122
|
+
*/
|
|
123
|
+
export function useFormValues(store) {
|
|
124
|
+
const ctx = useContext(FormStoreContext);
|
|
125
|
+
if (!ctx && !store) {
|
|
126
|
+
throw new Error("useFormValues() was called outside a <Form>. It reads that form's values, so there has to be one above it.");
|
|
127
|
+
}
|
|
128
|
+
const source = store ?? ctx.store;
|
|
129
|
+
return useSyncExternalStore(source.subscribe, () => source.all(), () => ({}));
|
|
130
|
+
}
|
|
31
131
|
export function useFormStatus() {
|
|
32
132
|
return useContext(FormStatusContext);
|
|
33
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The pieces of a field name: `items[0].name` is items, 0, name.
|
|
136
|
+
*
|
|
137
|
+
* Both spellings, because both are in use and a form should not care which one
|
|
138
|
+
* a person reached for: `items[0].name` and `items[0][name]` are the same
|
|
139
|
+
* field. A trailing `[]` is a piece of its own — see below.
|
|
140
|
+
*/
|
|
141
|
+
function pathOf(name) {
|
|
142
|
+
return name
|
|
143
|
+
.replace(/\[(\w*)\]/g, ".$1")
|
|
144
|
+
.split(".")
|
|
145
|
+
.filter((piece, index, all) => piece !== "" || index === all.length - 1);
|
|
146
|
+
}
|
|
147
|
+
/** Whether a piece names an array index rather than a property. */
|
|
148
|
+
const isIndex = (piece) => /^\d+$/.test(piece);
|
|
149
|
+
/**
|
|
150
|
+
* Put one value at one path, making the containers it passes through.
|
|
151
|
+
*
|
|
152
|
+
* Whether a container is an array or an object is decided by the NEXT piece, so
|
|
153
|
+
* `items[0].name` makes an array holding an object without being told which is
|
|
154
|
+
* which.
|
|
155
|
+
*/
|
|
156
|
+
function place(root, path, value) {
|
|
157
|
+
let node = root;
|
|
158
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
159
|
+
const key = path[i];
|
|
160
|
+
const container = node;
|
|
161
|
+
if (container[key] === undefined || typeof container[key] !== "object") {
|
|
162
|
+
// An index makes an array, and so does the empty piece a trailing `[]`
|
|
163
|
+
// leaves — `tags[]` has to reach an array to be pushed into, and building
|
|
164
|
+
// an object there is how this first went wrong.
|
|
165
|
+
const next = path[i + 1];
|
|
166
|
+
container[key] = isIndex(next) || next === "" ? [] : {};
|
|
167
|
+
}
|
|
168
|
+
node = container[key];
|
|
169
|
+
}
|
|
170
|
+
const last = path[path.length - 1];
|
|
171
|
+
// The empty piece a trailing `[]` leaves: push rather than assign, so
|
|
172
|
+
// `tags[]` twice is two entries rather than one overwriting the other.
|
|
173
|
+
if (last === "")
|
|
174
|
+
node.push(value);
|
|
175
|
+
else
|
|
176
|
+
node[last] = value;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A FormData as the object a schema expects.
|
|
180
|
+
*
|
|
181
|
+
* Four things beyond copying entries across, and each of them was a bug or a
|
|
182
|
+
* gap someone would meet on their first non-trivial form:
|
|
183
|
+
*
|
|
184
|
+
* A repeated name is an array. Three checkboxes sharing a name, a multiple
|
|
185
|
+
* select, a list of tags — this used to keep the LAST one and drop the rest
|
|
186
|
+
* silently, so a schema validated an object the person had not submitted.
|
|
187
|
+
*
|
|
188
|
+
* A name ending in `[]` is always an array, even with one value selected.
|
|
189
|
+
* Otherwise a list of checkboxes is a string when one is ticked and an array
|
|
190
|
+
* when two are, and no schema can describe both. It is also what `useForm`
|
|
191
|
+
* writes when it serialises an array, so the two round-trip.
|
|
192
|
+
*
|
|
193
|
+
* Nested names nest. `address.city` and `items[0].name` build the object they
|
|
194
|
+
* describe, which is the shape the schema was written against — and the shape
|
|
195
|
+
* whose validation errors come back keyed the same way, because Standard
|
|
196
|
+
* Schema issue paths are joined with dots too.
|
|
197
|
+
*
|
|
198
|
+
* Files are kept. They were dropped for being non-strings, which meant a schema
|
|
199
|
+
* checking an upload was handed undefined and refused a file that was there.
|
|
200
|
+
*/
|
|
34
201
|
function formDataToObject(formData) {
|
|
35
202
|
const obj = {};
|
|
36
|
-
for (const
|
|
37
|
-
|
|
38
|
-
|
|
203
|
+
for (const name of new Set(formData.keys())) {
|
|
204
|
+
const all = formData.getAll(name);
|
|
205
|
+
const path = pathOf(name);
|
|
206
|
+
// A plain name used more than once is the array case, and it has no
|
|
207
|
+
// brackets to say so — `tags` twice is `['a', 'b']`.
|
|
208
|
+
if (path.length === 1 && path[0] !== "" && all.length > 1) {
|
|
209
|
+
obj[path[0]] = all;
|
|
210
|
+
continue;
|
|
39
211
|
}
|
|
212
|
+
for (const value of all)
|
|
213
|
+
place(obj, path, value);
|
|
40
214
|
}
|
|
41
215
|
return obj;
|
|
42
216
|
}
|
|
@@ -44,10 +218,61 @@ function formDataToObject(formData) {
|
|
|
44
218
|
* Also exported by name, and re-exported below, because both spellings are in
|
|
45
219
|
* use: `import Form from` and `import { Form } from`.
|
|
46
220
|
*/
|
|
47
|
-
export default function Form({ action, method: methodProp, prefetch = "hover", cacheFor, replace = false, preserveScroll = false, resetOnSuccess = true, schema, transform, optimistic, onSuccess, onError, onSubmit, children, ...rest }) {
|
|
221
|
+
export default function Form({ action, method: methodProp, defaultValues, store: providedStore, prefetch = "hover", cacheFor, replace = false, preserveScroll = false, resetOnSuccess = true, schema, transform, optimistic, onSuccess, onError, onSubmit, children, ...rest }) {
|
|
48
222
|
const isGetForm = typeof action === "string";
|
|
49
223
|
const method = methodProp ?? (isGetForm ? "get" : "post");
|
|
50
224
|
const [errors, setErrors] = useState({});
|
|
225
|
+
const [touched, setTouched] = useState({});
|
|
226
|
+
/**
|
|
227
|
+
* Mark a field visited, and check it.
|
|
228
|
+
*
|
|
229
|
+
* Checking on blur rather than on every keystroke, because an error that
|
|
230
|
+
* appears while someone is halfway through typing an email address is a form
|
|
231
|
+
* arguing with them. Leaving the field is the moment they have finished
|
|
232
|
+
* saying what they meant.
|
|
233
|
+
*
|
|
234
|
+
* The whole object is validated and only this field's issues are kept: a
|
|
235
|
+
* Standard Schema has no notion of one field, and filtering by path is the
|
|
236
|
+
* honest way to ask it about one. Everything else's errors are left as they
|
|
237
|
+
* were, so blurring an empty field does not light up the rest of the form.
|
|
238
|
+
*/
|
|
239
|
+
const touch = useCallback(async (name) => {
|
|
240
|
+
setTouched((prev) => (prev[name] ? prev : { ...prev, [name]: true }));
|
|
241
|
+
if (!schema || !formRef.current)
|
|
242
|
+
return;
|
|
243
|
+
const invalid = await validateWith(schema, formDataToObject(new FormData(formRef.current)));
|
|
244
|
+
setErrors((prev) => {
|
|
245
|
+
const next = { ...prev };
|
|
246
|
+
if (invalid?.[name])
|
|
247
|
+
next[name] = invalid[name];
|
|
248
|
+
else
|
|
249
|
+
delete next[name];
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
}, [schema]);
|
|
253
|
+
const [succeeded, setSucceeded] = useState(false);
|
|
254
|
+
const [recentlySucceeded, setRecentlySucceeded] = useState(false);
|
|
255
|
+
const recentTimer = useRef(undefined);
|
|
256
|
+
// Cleared on unmount: a timer that fires into a component that has gone is
|
|
257
|
+
// the warning nobody reads and the leak nobody finds.
|
|
258
|
+
useEffect(() => () => clearTimeout(recentTimer.current), []);
|
|
259
|
+
// Only the fields someone bound. Everything else is the DOM's.
|
|
260
|
+
//
|
|
261
|
+
// A store rather than state, so a component using `useField` can re-render
|
|
262
|
+
// for one field while this one does not. See formStore.ts.
|
|
263
|
+
const storeRef = useRef(null);
|
|
264
|
+
storeRef.current ??= createFormStore({ ...defaultValues });
|
|
265
|
+
const store = providedStore ?? storeRef.current;
|
|
266
|
+
// Which names the render prop read through `field()`. A change to one of
|
|
267
|
+
// those has to re-render this component, because that is where the value is
|
|
268
|
+
// being displayed; a change to anything else does not, which is what lets a
|
|
269
|
+
// `useField` child stand on its own.
|
|
270
|
+
const readHere = useRef(new Set());
|
|
271
|
+
const [, bump] = useState(0);
|
|
272
|
+
useEffect(() => store.subscribe((name) => {
|
|
273
|
+
if (name === "" || readHere.current.has(name))
|
|
274
|
+
bump((n) => n + 1);
|
|
275
|
+
}), [store]);
|
|
51
276
|
const [currentData, setCurrentData] = useState({});
|
|
52
277
|
const [isPending, startTransition] = useTransition();
|
|
53
278
|
const formRef = useRef(null);
|
|
@@ -123,29 +348,13 @@ export default function Form({ action, method: methodProp, prefetch = "hover", c
|
|
|
123
348
|
return;
|
|
124
349
|
}
|
|
125
350
|
const serverAction = action;
|
|
126
|
-
//
|
|
351
|
+
// Rebuilt rather than edited: transform returns the values to send, so
|
|
352
|
+
// whatever was in the form and is not in the result should not go.
|
|
127
353
|
if (transform) {
|
|
128
|
-
const
|
|
129
|
-
for (const key of [...formData.keys()]) {
|
|
354
|
+
for (const key of [...formData.keys()])
|
|
130
355
|
formData.delete(key);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (val === null || val === undefined)
|
|
134
|
-
continue;
|
|
135
|
-
if (val instanceof File) {
|
|
136
|
-
formData.append(key, val);
|
|
137
|
-
}
|
|
138
|
-
else if (typeof val === "boolean") {
|
|
139
|
-
formData.append(key, val ? "1" : "0");
|
|
140
|
-
}
|
|
141
|
-
else if (Array.isArray(val)) {
|
|
142
|
-
for (const item of val) {
|
|
143
|
-
formData.append(`${key}[]`, String(item));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
formData.append(key, String(val));
|
|
148
|
-
}
|
|
356
|
+
for (const [key, value] of buildFormData(transform(data))) {
|
|
357
|
+
formData.append(key, value);
|
|
149
358
|
}
|
|
150
359
|
}
|
|
151
360
|
setErrors({});
|
|
@@ -176,9 +385,14 @@ export default function Form({ action, method: methodProp, prefetch = "hover", c
|
|
|
176
385
|
}
|
|
177
386
|
if (resetOnSuccess) {
|
|
178
387
|
formRef.current?.reset();
|
|
388
|
+
setTouched({});
|
|
179
389
|
setCurrentData({});
|
|
180
390
|
}
|
|
181
391
|
setErrors({});
|
|
392
|
+
setSucceeded(true);
|
|
393
|
+
setRecentlySucceeded(true);
|
|
394
|
+
clearTimeout(recentTimer.current);
|
|
395
|
+
recentTimer.current = setTimeout(() => setRecentlySucceeded(false), 2_000);
|
|
182
396
|
onSuccess?.(result);
|
|
183
397
|
}
|
|
184
398
|
catch (err) {
|
|
@@ -204,15 +418,66 @@ export default function Form({ action, method: methodProp, prefetch = "hover", c
|
|
|
204
418
|
}
|
|
205
419
|
});
|
|
206
420
|
}, [action, isGetForm, method, replace, preserveScroll, resetOnSuccess, schema, transform, optimistic, onSubmit, onSuccess, onError]);
|
|
421
|
+
const field = useCallback((name) => {
|
|
422
|
+
// Recorded during render, deliberately: this is how the form knows which
|
|
423
|
+
// values it is the one displaying. Idempotent, so a double render in
|
|
424
|
+
// development records the same name twice and means the same thing.
|
|
425
|
+
readHere.current.add(name);
|
|
426
|
+
return {
|
|
427
|
+
name,
|
|
428
|
+
value: (store.get(name) ?? ""),
|
|
429
|
+
onChange: (next) => {
|
|
430
|
+
const value = typeof next === "object" && next !== null && "target" in next
|
|
431
|
+
? next.target.value
|
|
432
|
+
: next;
|
|
433
|
+
store.set(name, value);
|
|
434
|
+
},
|
|
435
|
+
onBlur: () => void touch(name),
|
|
436
|
+
};
|
|
437
|
+
}, [store, touch]);
|
|
438
|
+
const fieldState = useCallback((name) => ({
|
|
439
|
+
touched: touched[name] === true,
|
|
440
|
+
invalid: (errors[name]?.length ?? 0) > 0,
|
|
441
|
+
errors: errors[name] ?? [],
|
|
442
|
+
}), [touched, errors]);
|
|
443
|
+
// Stable, so a subscriber below does not re-render because this one did.
|
|
444
|
+
const storeContext = useMemo(() => ({ store, touch }), [store, touch]);
|
|
207
445
|
const formStatus = {
|
|
208
446
|
pending: isPending,
|
|
209
447
|
data: currentData,
|
|
210
|
-
|
|
448
|
+
succeeded,
|
|
449
|
+
recentlySucceeded,
|
|
450
|
+
field,
|
|
451
|
+
fieldState,
|
|
452
|
+
errors: errors,
|
|
211
453
|
error,
|
|
212
454
|
clearErrors,
|
|
213
455
|
reset: resetForm,
|
|
214
456
|
};
|
|
215
|
-
return (_jsx(
|
|
457
|
+
return (_jsx(FormStoreContext.Provider, { value: storeContext, children: _jsx(FormStatusContext.Provider, { value: formStatus, children: _jsx("form", { ref: formRef,
|
|
458
|
+
// On the element as well as in the handler, which is what makes this
|
|
459
|
+
// work before hydration. React emits a form a browser can submit on its
|
|
460
|
+
// own for a server action, and an ordinary action/method pair for a
|
|
461
|
+
// url — so a submit that happens before the javascript arrives still
|
|
462
|
+
// reaches the server.
|
|
463
|
+
//
|
|
464
|
+
// The two do not fight: handleSubmit calls preventDefault() first, and
|
|
465
|
+
// React does not run a form action when the submit event was cancelled.
|
|
466
|
+
// So the enhanced path wins whenever there is one, and the native path
|
|
467
|
+
// is what is left when there is not.
|
|
468
|
+
action: action,
|
|
469
|
+
// Only for a url. React sets the method itself for a server action, and
|
|
470
|
+
// passing one alongside is what it warns about.
|
|
471
|
+
method: isGetForm ? method : undefined, onSubmit: handleSubmit,
|
|
472
|
+
// On the form, not only on the bound fields. `focusout` bubbles where
|
|
473
|
+
// `blur` does not, so React's onBlur here sees every control that was
|
|
474
|
+
// left — including the uncontrolled ones, which are most of them and
|
|
475
|
+
// would otherwise never be marked touched at all.
|
|
476
|
+
onBlur: (event) => {
|
|
477
|
+
const name = event.target.name;
|
|
478
|
+
if (name)
|
|
479
|
+
void touch(name);
|
|
480
|
+
}, onMouseEnter: prefetch === "hover" ? doPrefetch : undefined, "data-pending": isPending ? "" : undefined, ...rest, children: typeof children === "function" ? children(formStatus) : children }) }) }));
|
|
216
481
|
}
|
|
217
482
|
// Also by name, because both spellings are in use: `import Form` and
|
|
218
483
|
// `import { Form }`. This was a re-export of "./Form" — from inside Form.tsx,
|
|
@@ -221,5 +486,4 @@ export default function Form({ action, method: methodProp, prefetch = "hover", c
|
|
|
221
486
|
// FormComponent.tsx that a case-insensitive filesystem would not let be called
|
|
222
487
|
// Form.ts. There is one file now, and it can just export what it declares.
|
|
223
488
|
export { Form };
|
|
224
|
-
export { useForm } from "./useForm";
|
|
225
489
|
//# sourceMappingURL=Form.js.map
|