@structuralists/scaffolding 0.11.0 → 0.12.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/package.json +1 -1
- package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
- package/src/components/Json/JsonTable/index.tsx +13 -6
- package/src/components/Json/JsonTable/styles.module.css +20 -0
- package/src/components/Json/JsonTable/types.ts +3 -5
- package/src/forms/CLAUDE.md +104 -26
- package/src/forms/plan.md +104 -23
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
- package/src/forms/state/useFormState/deriveErrors.ts +10 -10
- package/src/forms/state/useFormState/errorAt.test.ts +3 -3
- package/src/forms/state/useFormState/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +2 -2
- package/src/forms/state/useFormState/useFormState.stories.tsx +32 -10
- package/src/forms/state/useFormState/useFormState.test-d.ts +436 -0
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/validations/types.ts +77 -17
- package/src/forms/state/validations/walk.test.ts +159 -19
- package/src/forms/state/validations/walk.ts +86 -25
- package/tokens.css +55 -0
package/package.json
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
|
-
import
|
|
1
|
+
import styles from './styles.module.css';
|
|
2
2
|
|
|
3
3
|
export type JsonLeafNodeProps = {
|
|
4
4
|
value: unknown;
|
|
5
|
-
/** When true, render `null` / `undefined` via Text with the monospace font.
|
|
6
|
-
* JsonTable sets this on recursive calls so leaves inside a table inherit
|
|
7
|
-
* the same font as the surrounding monospace cells (Text otherwise sets
|
|
8
|
-
* its own font-family and would break the inheritance). */
|
|
9
|
-
isMono?: boolean;
|
|
10
5
|
};
|
|
11
6
|
|
|
12
7
|
/**
|
|
13
|
-
* Renders one value as a leaf — text only, no JSON tree viewer.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
8
|
+
* Renders one value as a leaf — text only, no JSON tree viewer. Each
|
|
9
|
+
* primitive type gets a color class so the value reads like Chrome's
|
|
10
|
+
* inspector: strings in orange (with quotes), numbers in blue, booleans
|
|
11
|
+
* in purple, `null`/`undefined` in pink. Anything else (objects, arrays,
|
|
12
|
+
* Dates, Maps, …) currently falls through to `{value}` — that will throw
|
|
13
|
+
* at render time for non-renderable types and is the placeholder until
|
|
14
|
+
* JsonTable grows specialized renderers for them.
|
|
19
15
|
*/
|
|
20
16
|
export const JsonLeafNode = (props: JsonLeafNodeProps) => {
|
|
21
|
-
const { value
|
|
22
|
-
const size = isMono ? 'small' : undefined;
|
|
17
|
+
const { value } = props;
|
|
23
18
|
|
|
24
19
|
if (value === null) {
|
|
25
|
-
return <
|
|
20
|
+
return <span className={styles.nil}>null</span>;
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
if (value === undefined) {
|
|
29
|
-
return <
|
|
24
|
+
return <span className={styles.nil}>undefined</span>;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
if (typeof value === 'boolean') {
|
|
33
|
-
return
|
|
28
|
+
return <span className={styles.boolean}>{String(value)}</span>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
return <span className={styles.string}>{JSON.stringify(value)}</span>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof value === 'number') {
|
|
36
|
+
return <span className={styles.number}>{value}</span>;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
return <>{value}</>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, within } from 'storybook/test';
|
|
2
3
|
import { JsonTable } from './index';
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof JsonTable> = {
|
|
@@ -50,3 +51,89 @@ export const PrimitivesAtTopLevel: Story = {
|
|
|
50
51
|
</div>
|
|
51
52
|
),
|
|
52
53
|
};
|
|
54
|
+
|
|
55
|
+
export const ValueColors: Story = {
|
|
56
|
+
name: 'Value colors per primitive type',
|
|
57
|
+
render: () => (
|
|
58
|
+
<JsonTable
|
|
59
|
+
title="Primitive value colors"
|
|
60
|
+
value={{
|
|
61
|
+
aString: 'hello world',
|
|
62
|
+
anEmptyString: '',
|
|
63
|
+
aQuotedString: 'say "hi"',
|
|
64
|
+
aMultilineString: 'line one\nline two',
|
|
65
|
+
aNumber: 12345,
|
|
66
|
+
aFloat: 3.14159,
|
|
67
|
+
aTrue: true,
|
|
68
|
+
aFalse: false,
|
|
69
|
+
aNull: null,
|
|
70
|
+
anUndefined: undefined,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
play: async ({ canvasElement }) => {
|
|
75
|
+
const canvas = within(canvasElement);
|
|
76
|
+
await expect(canvas.getByText('"hello world"')).toBeInTheDocument();
|
|
77
|
+
await expect(canvas.getByText('"say \\"hi\\""')).toBeInTheDocument();
|
|
78
|
+
await expect(canvas.getByText('"line one\\nline two"')).toBeInTheDocument();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const deepSample = {
|
|
83
|
+
id: 'org_8821',
|
|
84
|
+
name: 'Northwind Logistics',
|
|
85
|
+
region: 'us-west-2',
|
|
86
|
+
active: true,
|
|
87
|
+
founded: 2014,
|
|
88
|
+
headquarters: {
|
|
89
|
+
address: {
|
|
90
|
+
street: '120 Pier Ave',
|
|
91
|
+
city: 'Seattle',
|
|
92
|
+
state: 'WA',
|
|
93
|
+
zip: '98101',
|
|
94
|
+
coords: { lat: 47.6062, lng: -122.3321 },
|
|
95
|
+
},
|
|
96
|
+
capacity: 240,
|
|
97
|
+
isLeased: false,
|
|
98
|
+
},
|
|
99
|
+
teams: [
|
|
100
|
+
{
|
|
101
|
+
id: 'team_platform',
|
|
102
|
+
name: 'Platform',
|
|
103
|
+
headcount: 12,
|
|
104
|
+
lead: { id: 'usr_1042', name: 'Alice Park', email: 'alice@northwind.io' },
|
|
105
|
+
members: [
|
|
106
|
+
{ id: 'usr_1042', name: 'Alice Park', role: 'lead', onCall: true },
|
|
107
|
+
{ id: 'usr_1109', name: 'Ben Ortiz', role: 'engineer', onCall: false },
|
|
108
|
+
{ id: 'usr_1188', name: 'Chen Wu', role: 'engineer', onCall: false },
|
|
109
|
+
],
|
|
110
|
+
tags: ['core', 'oncall', 'sre'],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'team_data',
|
|
114
|
+
name: 'Data',
|
|
115
|
+
headcount: 7,
|
|
116
|
+
lead: { id: 'usr_1301', name: 'Dana Reyes', email: 'dana@northwind.io' },
|
|
117
|
+
members: [
|
|
118
|
+
{ id: 'usr_1301', name: 'Dana Reyes', role: 'lead', onCall: false },
|
|
119
|
+
{ id: 'usr_1422', name: 'Evan Liu', role: 'analyst', onCall: false },
|
|
120
|
+
],
|
|
121
|
+
tags: ['analytics', 'etl'],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
integrations: [
|
|
125
|
+
{ kind: 'slack', enabled: true, channel: '#alerts', retries: 3 },
|
|
126
|
+
{ kind: 'pagerduty', enabled: true, escalation: { primary: 'usr_1042', secondary: null } },
|
|
127
|
+
{ kind: 'jira', enabled: false, project: null },
|
|
128
|
+
],
|
|
129
|
+
flags: {
|
|
130
|
+
betaFeatures: ['new-dashboard', 'inline-edit'],
|
|
131
|
+
rateLimits: { rpm: 600, burst: 1200, throttleAfter: null },
|
|
132
|
+
},
|
|
133
|
+
notes: undefined,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const DeepNested: Story = {
|
|
137
|
+
name: 'Deeply nested (objects + arrays)',
|
|
138
|
+
render: () => <JsonTable value={deepSample} title="Organization" />,
|
|
139
|
+
};
|
|
@@ -13,8 +13,13 @@ import styles from './styles.module.css';
|
|
|
13
13
|
export const JsonTable = (props: JsonTableProps) => {
|
|
14
14
|
const { value, title, isNested } = props;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const isObject = isPlainObject(value);
|
|
17
|
+
const isArray = Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
if (isObject || isArray) {
|
|
20
|
+
const entries: [string, unknown][] = isArray
|
|
21
|
+
? value.map((item, i) => [String(i), item])
|
|
22
|
+
: Object.entries(value as Record<string, unknown>);
|
|
18
23
|
|
|
19
24
|
const headerRow = title ? (
|
|
20
25
|
<QuickTableHeaderRow>
|
|
@@ -26,12 +31,14 @@ export const JsonTable = (props: JsonTableProps) => {
|
|
|
26
31
|
<QuickTable headerRow={headerRow} hasColumnDividers hasOuterBorder={!isNested}>
|
|
27
32
|
{entries.map((entry) => {
|
|
28
33
|
const [key, child] = entry;
|
|
29
|
-
const
|
|
34
|
+
const childIsContainer = isPlainObject(child) || Array.isArray(child);
|
|
30
35
|
|
|
31
36
|
return (
|
|
32
37
|
<QuickTableRow key={key}>
|
|
33
|
-
<QuickTableCell width={1}>
|
|
34
|
-
|
|
38
|
+
<QuickTableCell width={1}>
|
|
39
|
+
<span className={styles.key}>{key}</span>
|
|
40
|
+
</QuickTableCell>
|
|
41
|
+
<QuickTableCell hasPadding={!childIsContainer}>
|
|
35
42
|
<JsonTable value={child} title={key} isNested />
|
|
36
43
|
</QuickTableCell>
|
|
37
44
|
</QuickTableRow>
|
|
@@ -43,7 +50,7 @@ export const JsonTable = (props: JsonTableProps) => {
|
|
|
43
50
|
return isNested ? table : <div className={styles.root}>{table}</div>;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
return <JsonLeafNode value={value}
|
|
53
|
+
return <JsonLeafNode value={value} />;
|
|
47
54
|
};
|
|
48
55
|
|
|
49
56
|
export type { JsonTableProps };
|
|
@@ -5,3 +5,23 @@
|
|
|
5
5
|
.root thead tr {
|
|
6
6
|
border-bottom-color: var(--ui-border-subtle);
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
.key {
|
|
10
|
+
color: var(--ui-json-key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.string {
|
|
14
|
+
color: var(--ui-json-string);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.number {
|
|
18
|
+
color: var(--ui-json-number);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.boolean {
|
|
22
|
+
color: var(--ui-json-boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.nil {
|
|
26
|
+
color: var(--ui-json-nil);
|
|
27
|
+
}
|
|
@@ -12,11 +12,9 @@ export type JsonTableProps = {
|
|
|
12
12
|
* labeled table. */
|
|
13
13
|
title?: ReactNode;
|
|
14
14
|
/** @internal Set by JsonTable on every recursive call (i.e. when rendering
|
|
15
|
-
* a child entry of a parent table).
|
|
15
|
+
* a child entry of a parent table). On a nested object or array,
|
|
16
16
|
* suppresses the outer border so the inner table sits flush inside its
|
|
17
|
-
* parent cell — whose padding is also dropped at the parent level
|
|
18
|
-
*
|
|
19
|
-
* via Text with the monospace font so they inherit the surrounding font
|
|
20
|
-
* set by the JsonTable root. Not intended for external callers. */
|
|
17
|
+
* parent cell — whose padding is also dropped at the parent level. Not
|
|
18
|
+
* intended for external callers. */
|
|
21
19
|
isNested?: boolean;
|
|
22
20
|
};
|
package/src/forms/CLAUDE.md
CHANGED
|
@@ -50,25 +50,67 @@ function composition.
|
|
|
50
50
|
|
|
51
51
|
## What a constraints key may map to
|
|
52
52
|
|
|
53
|
+
The grammar is recursive. What a key admits is directed by the *field's*
|
|
54
|
+
type, never guessed from the constraint's shape:
|
|
55
|
+
|
|
53
56
|
```ts
|
|
54
57
|
type FieldConstraint<F> =
|
|
58
|
+
// leaf forms — legal for ANY field type, structural fields included
|
|
55
59
|
| FieldValidator<F> // one validator
|
|
56
60
|
| readonly FieldValidator<F>[] // several, run in order, first error wins
|
|
61
|
+
// structural forms — only where the field type permits
|
|
62
|
+
| (F extends FormValuesObject ? Validations<F> : never) // nested spec
|
|
63
|
+
| (F extends FormValueList ? ListConstraint<F[number]> : never); // { each: … }
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
The array form is the everyday way to stack validators on a field at the
|
|
60
|
-
constraint site
|
|
67
|
+
constraint site; a nested spec addresses the fields of an object section;
|
|
68
|
+
`each` applies a spec to every element of a list. All compose freely:
|
|
61
69
|
|
|
62
70
|
```ts
|
|
63
71
|
constraints: {
|
|
64
72
|
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
73
|
+
homeAddress: {
|
|
74
|
+
city: notEmpty('city'),
|
|
75
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
76
|
+
},
|
|
77
|
+
drivers: {
|
|
78
|
+
each: {
|
|
79
|
+
name: notEmpty('name'),
|
|
80
|
+
incidents: { each: { date: notEmpty('date') } },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
65
83
|
}
|
|
66
84
|
```
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
*reusable, named* composite validators): validators run in array
|
|
70
|
-
the first error is the field's error, and later validators don't run
|
|
71
|
-
one fails. An empty array passes and narrows nothing.
|
|
86
|
+
Array semantics are identical to `allOf` (which remains the tool for
|
|
87
|
+
building *reusable, named* composite validators): validators run in array
|
|
88
|
+
order, the first error is the field's error, and later validators don't run
|
|
89
|
+
once one fails. An empty array passes and narrows nothing.
|
|
90
|
+
|
|
91
|
+
Grammar doctrine, in force at the type level and in the runtime walk:
|
|
92
|
+
|
|
93
|
+
- **Disambiguation is by the value model.** An object field that owns a key
|
|
94
|
+
literally named `each` still takes a nested spec — for an object-typed
|
|
95
|
+
field only the `Validations<F>` arm is live, for a list-typed field only
|
|
96
|
+
`ListConstraint`. Both `RefineField` and the walk interrogate the
|
|
97
|
+
field/value before the constraint's shape.
|
|
98
|
+
- **Leaf forms stay legal on structural fields.** A bare validator on an
|
|
99
|
+
object field is a whole-section validator (`mailingAddress:
|
|
100
|
+
notEmpty(…)` is how "required section" is expressed); a validator array
|
|
101
|
+
on a list field validates the list as a value. Leaf and structural forms
|
|
102
|
+
are mutually exclusive per key (recorded open decision — a future `self`
|
|
103
|
+
slot).
|
|
104
|
+
- **A nested spec on an absent (null/undefined) section validates
|
|
105
|
+
nothing**, at the type level (only the present branch refines;
|
|
106
|
+
nullability survives around the refined interior) and at runtime (the
|
|
107
|
+
walk skips — nothing to walk). Same for `each` over a null list.
|
|
108
|
+
- **`F` stays naked in the structural arms.** Nullable sections/lists work
|
|
109
|
+
purely by distribution; wrapping the checked type in `NonNullable` breaks
|
|
110
|
+
it and blows the recursion stack (TS2589).
|
|
111
|
+
- **Runtime status of `each`:** type-level only until plan phase 3. An
|
|
112
|
+
`each` constraint on a present list makes the walk THROW a clear error
|
|
113
|
+
instead of silently not validating.
|
|
72
114
|
|
|
73
115
|
## Aggregation: `perField`
|
|
74
116
|
|
|
@@ -79,7 +121,11 @@ const constraints = perField({
|
|
|
79
121
|
```
|
|
80
122
|
|
|
81
123
|
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
82
|
-
value while preserving the precise types of each individual validator.
|
|
124
|
+
value while preserving the precise types of each individual validator. It
|
|
125
|
+
currently admits only the leaf forms (a validator or validator array per
|
|
126
|
+
key) — extending it to pre-built nested/`each` specs is plan phase 4
|
|
127
|
+
(composition hardening); until then, write structural constraints inline in
|
|
128
|
+
the `useFormState` call.
|
|
83
129
|
|
|
84
130
|
### The precision-preserving ceremony, by call-site shape
|
|
85
131
|
|
|
@@ -137,10 +183,11 @@ unreliable in some intersections.
|
|
|
137
183
|
|
|
138
184
|
## `Refine<FormType, typeof constraints>`
|
|
139
185
|
|
|
140
|
-
Walks the constraints object,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
Walks the constraints object recursively, mirroring the grammar: leaves
|
|
187
|
+
apply `Exclude<FormType[K], __excludes>` from their validators' markers,
|
|
188
|
+
nested specs recurse into the section, `each` specs refine the element type
|
|
189
|
+
and flow it through `Array<...>`. Fields without a constraint pass through
|
|
190
|
+
unchanged, at every level. The result is the type handed to `onSubmit`.
|
|
144
191
|
|
|
145
192
|
```ts
|
|
146
193
|
type FormType = { a: string | undefined; b: number };
|
|
@@ -148,6 +195,18 @@ type C = typeof constraints; // a: notEmpty
|
|
|
148
195
|
type SubmitType = Refine<FormType, C>; // { a: string; b: number }
|
|
149
196
|
```
|
|
150
197
|
|
|
198
|
+
`Refine` opens with an identity gate — `Validations<T> extends V ? T :
|
|
199
|
+
RefineObject<T, V>` — and the gate is load-bearing, not an optimization:
|
|
200
|
+
with constraints omitted the hook's default `V = Validations<T>` would
|
|
201
|
+
distribute `FieldConstraint<F> | undefined` through the walk at every key
|
|
202
|
+
and hand back unions of structurally-identical mapped *copies* of every
|
|
203
|
+
section — assignable to `T` but not identity-equal (mangled hover types;
|
|
204
|
+
the "without constraints, onSubmit receives the unrefined form type" probe
|
|
205
|
+
fails). Any concrete constraints literal is strictly narrower than
|
|
206
|
+
`Validations<T>`, so the gate stays open and the real walk runs. Nested
|
|
207
|
+
occurrences don't need their own gate: a concrete literal's `keyof V` holds
|
|
208
|
+
only the keys actually written, so uncovered fields take `T[K]` verbatim.
|
|
209
|
+
|
|
151
210
|
Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
|
|
152
211
|
is deliberately **naked** so unions of constraint types distribute. Two
|
|
153
212
|
regimes of "multiple markers per field" follow from that, both sound:
|
|
@@ -169,10 +228,17 @@ regimes of "multiple markers per field" follow from that, both sound:
|
|
|
169
228
|
per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
|
|
170
229
|
narrowing the running branch didn't earn.
|
|
171
230
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
231
|
+
Both regimes survive recursion verbatim — the same soundness holds for
|
|
232
|
+
union-typed constraints inside nested and `each` specs (probed at depth in
|
|
233
|
+
`useFormState.test-d.ts`).
|
|
234
|
+
|
|
235
|
+
Branch order inside `RefineField`: `Refinement` first — validator functions
|
|
236
|
+
are objects, and a marked validator must not fall into a structural arm —
|
|
237
|
+
then arrays, then the structural arms, which interrogate the FIELD type
|
|
238
|
+
before the constraint's shape (`F extends FormValueList` is asked before
|
|
239
|
+
looking for an `each` key, so an object field literally named `each`
|
|
240
|
+
refines as a nested spec). A bare marker-less validator on a structural
|
|
241
|
+
field lands in `RefineObject` with an empty `keyof C` — an identity map.
|
|
176
242
|
|
|
177
243
|
## Hook surface
|
|
178
244
|
|
|
@@ -189,10 +255,15 @@ useFormState({
|
|
|
189
255
|
Validation failures surface as a structured list, not a keyed record:
|
|
190
256
|
|
|
191
257
|
```ts
|
|
192
|
-
type FormError<T> = { path: Path<T>; error: string }; //
|
|
258
|
+
type FormError<T> = { path: Path<T>; error: string }; // one entry per failing constrained node
|
|
193
259
|
type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
|
|
194
260
|
```
|
|
195
261
|
|
|
262
|
+
Paths are as deep as the failing node: a root leaf contributes `['email']`,
|
|
263
|
+
a nested-spec leaf `['homeAddress', 'postalCode']`. Sibling nodes fail
|
|
264
|
+
independently — first-error-wins applies *within* one validator array, not
|
|
265
|
+
across fields.
|
|
266
|
+
|
|
196
267
|
Read one field's message with the typed accessor, never by hand-assembled
|
|
197
268
|
keys — serialized path strings (`'drivers.0.name'`) are deliberately not
|
|
198
269
|
exposed:
|
|
@@ -390,9 +461,11 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
390
461
|
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
391
462
|
loop delegates per-entry semantics to `state/validations/walk.ts` and
|
|
392
463
|
carries two documented honest widenings: the `Object.keys` cast, and
|
|
393
|
-
`failure.path as Path<T>` — `[key]`
|
|
394
|
-
|
|
395
|
-
|
|
464
|
+
`failure.path as Path<T>` — the walk extends the `[key]` seed only
|
|
465
|
+
along keys of specs type-checked against `T`'s subtree, so every
|
|
466
|
+
returned path is a valid `Path<T>`, but TS cannot compute `Path<T>`
|
|
467
|
+
for an unresolved generic `T` to see the correlation. It also hosts
|
|
468
|
+
the cast-free `ConstraintEntry`
|
|
396
469
|
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
397
470
|
- `errorAt.ts` — the typed error lookup.
|
|
398
471
|
- `useFieldBinding.ts` — field binding: owns the touched list and builds
|
|
@@ -417,8 +490,8 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
417
490
|
bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
|
|
418
491
|
of the form's internal state. Only an *open* debugger window
|
|
419
492
|
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
420
|
-
~nothing. `inspectable.ts` converts
|
|
421
|
-
can render.
|
|
493
|
+
~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
|
|
494
|
+
can render (objects and arrays it renders natively).
|
|
422
495
|
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
423
496
|
`read()`), plus `write()` (the immutable-update mirror of `read()` behind
|
|
424
497
|
granular field writes) and `pathsEqual` (the one definition of structural
|
|
@@ -427,14 +500,19 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
427
500
|
Union handling is governed by the "Union policy" section above; the
|
|
428
501
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
429
502
|
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
430
|
-
- `state/validations/` — `perField`,
|
|
431
|
-
`
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
503
|
+
- `state/validations/` — `perField`, the recursive grammar
|
|
504
|
+
(`Validations<T>` / `FieldConstraint<F>` / `ListConstraint<E>`),
|
|
505
|
+
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the recursive runtime
|
|
506
|
+
walk the hook delegates to (`validateEntry` — returns every failure in the
|
|
507
|
+
entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
|
|
508
|
+
accepts bare `(val) => string | null` functions too — they simply narrow
|
|
509
|
+
nothing. The walk disambiguates a structural spec against the VALUE at
|
|
510
|
+
the path (array ⇒ `{ each }`, which throws until phase 3; object ⇒
|
|
511
|
+
nested spec; absent ⇒ skip), never against the constraint's shape.
|
|
512
|
+
The walk's entry type (`ConstraintEntry`) is how the compiler polices
|
|
435
513
|
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
436
514
|
*assigned* to it, never cast,
|
|
437
|
-
so a grammar form the walk doesn't understand (a
|
|
515
|
+
so a grammar form the walk doesn't understand (a future `self` slot, say)
|
|
438
516
|
is a compile error at the assignment — a cast there would silently accept
|
|
439
517
|
new grammar and misinterpret it at runtime (e.g. call an array as a
|
|
440
518
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
package/src/forms/plan.md
CHANGED
|
@@ -28,7 +28,8 @@ TS wall can't strand finished work behind it.
|
|
|
28
28
|
(state/useFormState/errorAt.ts) — the smallest surface that keeps stories
|
|
29
29
|
readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
|
|
30
30
|
`errorMessage`. The Debugger needed no change (`toInspectable`
|
|
31
|
-
index-
|
|
31
|
+
index-keyed arrays at the time; it now passes them through, since
|
|
32
|
+
`JsonTable` renders arrays natively).
|
|
32
33
|
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
33
34
|
item 6 so the wrappers land in their final home once. ✅ *done* — one
|
|
34
35
|
`src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
|
|
@@ -45,6 +46,17 @@ TS wall can't strand finished work behind it.
|
|
|
45
46
|
Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
46
47
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
47
48
|
known response. Worst case, everything above still shipped.
|
|
49
|
+
- **Spike: ✅ done — verdict WORKS, decisively.** The full recursive
|
|
50
|
+
grammar (nested specs, `each`, arrays at every level, recursive
|
|
51
|
+
`Refine`) plus a stress tier (~110-leaf form, 12-level object ladder,
|
|
52
|
+
4-deep `each` ladder) cost **1.31× instantiations, check time flat** —
|
|
53
|
+
nowhere near the ~10×/multi-second failure thresholds. No depth caps,
|
|
54
|
+
lazy-recursion or boxing tricks needed. Two binding grammar
|
|
55
|
+
adjustments came out of it, both folded into the sketches below:
|
|
56
|
+
value-model-first branch order in `RefineField`, and the `Refine`
|
|
57
|
+
identity gate for the default-`V` case.
|
|
58
|
+
- **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
|
|
59
|
+
"Phases" below.
|
|
48
60
|
|
|
49
61
|
## Goal
|
|
50
62
|
|
|
@@ -126,30 +138,55 @@ useFormState({
|
|
|
126
138
|
|
|
127
139
|
## Refinement through the grammar
|
|
128
140
|
|
|
129
|
-
`Refine`
|
|
141
|
+
`Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
|
|
142
|
+
type-spike adjustments):
|
|
130
143
|
|
|
131
144
|
```ts
|
|
132
|
-
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
133
145
|
// MemberExcludes<C>: the per-member-sound union of a validator tuple's
|
|
134
146
|
// excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
|
|
135
147
|
|
|
136
|
-
type RefineField<F, C> =
|
|
137
|
-
|
|
138
|
-
: C extends readonly unknown[]
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
type RefineField<F, C> = C extends Refinement<infer Excluded>
|
|
149
|
+
? Exclude<F, Excluded> // single marked validator
|
|
150
|
+
: C extends readonly unknown[]
|
|
151
|
+
? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
|
|
152
|
+
: C extends object // structural: interrogate F (the value model) FIRST
|
|
153
|
+
? F extends FormValueList
|
|
154
|
+
? C extends { readonly each: infer E }
|
|
155
|
+
? Array<RefineObject<F[number], E>> // per-element
|
|
156
|
+
: F
|
|
157
|
+
: F extends FormValuesObject
|
|
158
|
+
? RefineObject<F, C> // nested spec
|
|
159
|
+
: F
|
|
160
|
+
: F; // bare fn / no marker
|
|
144
161
|
|
|
145
162
|
type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
|
|
163
|
+
|
|
164
|
+
// The identity gate — NOT optional (spike adjustment 2): with constraints
|
|
165
|
+
// omitted, the default V = Validations<T> would distribute through the walk
|
|
166
|
+
// and hand back unions of structurally-identical mapped copies of every
|
|
167
|
+
// section — assignable to T but not identity-equal (mangled hover types,
|
|
168
|
+
// fails toEqualTypeOf). Validations<T> extends V exactly when V is the
|
|
169
|
+
// default (or empty/fully-widened — no markers survive anyway).
|
|
170
|
+
type Refine<T extends FormValuesObject, V extends Validations<T>> =
|
|
171
|
+
Validations<T> extends V ? T : RefineObject<T, V>;
|
|
146
172
|
```
|
|
147
173
|
|
|
148
|
-
Branch-order constraints
|
|
149
|
-
in:
|
|
174
|
+
Branch-order constraints baked in (baseline + spike):
|
|
150
175
|
|
|
151
176
|
- **Check `Refinement` before `object`** — validator functions are objects.
|
|
152
|
-
- **
|
|
177
|
+
- **The structural arms branch on the value model (`F`) before the
|
|
178
|
+
constraint's shape** (spike adjustment 1): asking `C extends { each }`
|
|
179
|
+
first would strand an object field that legitimately owns a key named
|
|
180
|
+
`each` — its nested spec would match the `each` arm, fail the list test,
|
|
181
|
+
and fall back unrefined. Pinned by the `audit` disambiguation probe in
|
|
182
|
+
`useFormState.test-d.ts`.
|
|
183
|
+
- **A bare function** (no marker) falls through: on a scalar field to the
|
|
184
|
+
final `F` arm; on a structural field into `RefineObject<F, C>` with
|
|
185
|
+
`keyof C` empty — an identity map, structurally unchanged, as a
|
|
186
|
+
whole-value validator should be.
|
|
187
|
+
- Nested occurrences need no identity gate: a concrete literal `V` has only
|
|
188
|
+
the keys actually written, so uncovered fields take the `T[K]` arm
|
|
189
|
+
verbatim at every level.
|
|
153
190
|
- The runtime cast in `submit()` stays the same single honest cast: valid ⇒
|
|
154
191
|
every constrained node passed, at every depth, which is what the recursive
|
|
155
192
|
`Refine` now encodes.
|
|
@@ -200,11 +237,39 @@ multi-second check time:
|
|
|
200
237
|
0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
|
|
201
238
|
post-item-5 — the `ValueAt` instantiations at each binding call site are
|
|
202
239
|
real but cheap; check time flat)
|
|
240
|
+
- type spike (full recursive grammar, chunky + stress probes, throwaway
|
|
241
|
+
worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
|
|
242
|
+
check — 1.31× post-item-6; verdict works, no mitigation tricks required.
|
|
243
|
+
The real phases should land well under that: the spike carried a stress
|
|
244
|
+
tier (~110-leaf mega form with a large spec inlined twice, 12-level
|
|
245
|
+
object ladder, 4-deep `each` ladder) the phases don't.
|
|
246
|
+
- post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
|
|
247
|
+
ported chunky/boundary/negative probe suite, recursive walk): check
|
|
248
|
+
0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
|
|
249
|
+
post-item-6; matches the spike's equivalent non-stress milestone at
|
|
250
|
+
134,441 almost exactly)
|
|
203
251
|
|
|
204
252
|
## Runtime consequences (can't be dodged)
|
|
205
253
|
|
|
206
|
-
- **The validator walk becomes a tree walk.**
|
|
207
|
-
`state/
|
|
254
|
+
- **The validator walk becomes a tree walk.** ✅ *Landed with phase 2*
|
|
255
|
+
(`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
|
|
256
|
+
`PathStep[]` address; deliberately parallel to `read()`, not shared —
|
|
257
|
+
the walk descends a *constraints* tree, `read()` a *path*). Interpretation
|
|
258
|
+
of a structural spec is directed by the VALUE at the path, mirroring the
|
|
259
|
+
type level (see "Disambiguation").
|
|
260
|
+
- **Absent sections: a nested spec on an absent/null section is skipped.**
|
|
261
|
+
*Decided with the type spike.* The type level commits to this implicitly —
|
|
262
|
+
`F` distributes naked through the structural grammar arms, so a spec on
|
|
263
|
+
`UsAddress | undefined` refines only the present branch and the
|
|
264
|
+
nullability survives around the refined interior — and the runtime walk
|
|
265
|
+
mirrors it: nothing to walk when the section is absent (same for `each`
|
|
266
|
+
over a null list). A **required section** is expressed as a *leaf*
|
|
267
|
+
validator on the section field (`mailingAddress: notEmpty(…)`), which
|
|
268
|
+
stays legal on structural fields — with the recorded caveat that leaf and
|
|
269
|
+
structural forms are mutually exclusive per key (the "whole-value +
|
|
270
|
+
structural constraints on the same key" open decision below). Never
|
|
271
|
+
"help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
|
|
272
|
+
distribution and blows the recursion stack (TS2589).
|
|
208
273
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
209
274
|
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
210
275
|
paths). Decided: errors become a plain list of structured entries,
|
|
@@ -244,13 +309,29 @@ tests, story updates where visible, probe ratchet.
|
|
|
244
309
|
distributes over a naked constraint parameter, which made union-typed
|
|
245
310
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
246
311
|
refinements, pinned in `state/validations/types.test-d.ts`).
|
|
247
|
-
2. **Nested object specs + recursive `Refine`.** The risk phase —
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
312
|
+
2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
|
|
313
|
+
by the type spike. ✅ *done* — full recursive grammar
|
|
314
|
+
(`FieldConstraint`/`ListConstraint`/`Validations` in
|
|
315
|
+
state/validations/types.ts) and recursive `Refine` with both spike
|
|
316
|
+
adjustments (value-model-first `RefineField` branch order; the identity
|
|
317
|
+
gate); recursive `validateEntry` walk with absent-section skip; the
|
|
318
|
+
spike's chunky/boundary/soundness/negative probe suite ported into
|
|
319
|
+
`useFormState.test-d.ts` (the error model needed no change, as
|
|
320
|
+
predicted — nested errors just carry longer paths). **The `each` TYPE
|
|
321
|
+
arm landed here too** (the spike proved the grammar whole, and carving
|
|
322
|
+
it out of `RefineField` would have been artificial), but the runtime
|
|
323
|
+
walk for it is phase 3 — until then an `each` constraint on a present
|
|
324
|
+
list THROWS from the walk (pinned in walk.test.ts) rather than silently
|
|
325
|
+
not validating; on a null list it skips, which already matches phase-3
|
|
326
|
+
semantics.
|
|
327
|
+
3. **List `each` specs — runtime.** The walk visits every element; error
|
|
328
|
+
paths carry the numeric step (`['drivers', 3, 'name']`), replacing the
|
|
329
|
+
phase-2 throw. (The type level — refined element flowing through
|
|
330
|
+
`Array<...>` — already landed with phase 2.) Two probes the spike says
|
|
331
|
+
to keep pinned are already in the phase-2 suite: the `audit`-style
|
|
332
|
+
"object field literally named `each`" disambiguation probe, and the
|
|
333
|
+
`each: <bare validator>` negative (rejected by TypeScript's weak-type
|
|
334
|
+
check — an obscure checker rule worth a canary).
|
|
254
335
|
4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
|
|
255
336
|
`perField` still the entry point for pre-built specs, docs
|
|
256
337
|
(forms/CLAUDE.md) updated to the new grammar.
|