@structuralists/scaffolding 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -294,25 +294,45 @@ precision end-to-end. So we wire it up before adding any consumers.
294
294
 
295
295
  - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
296
296
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
297
- onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`;
298
- `errors` is the structured `FormErrors<T>` list (see "The error model"),
299
- live-derived from current values each render (validators are pure and
300
- cheap) with `isValid` its emptiness; `errorAt.ts` holds the typed lookup;
301
- `submitAttempted` lets UIs gate error display; and `submit()` performs
302
- the one honest *refinement* cast to `Refine<T, V>` earned because the
303
- validators just passed at runtime. The hook's error loop carries one
304
- further documented widening (`failure.path as Path<T>`, same species as
305
- its `Object.keys` cast): `[key]` is a valid single-key `Path<T>`, but TS
306
- cannot compute `Path<T>` for an unresolved generic `T` to see the
307
- correlation. `Debugger` is a per-instance
308
- dev-time overlay (fixed trigger, bottom-right, portaled to `<body>`) that
309
- opens a live `JsonTable` view of the form's internal state. Plumbing: the
310
- hook publishes a `FormDebugSnapshot` into a tiny `snapshotStore` after
311
- every commit; only an *open* debugger window subscribes
312
- (`useSyncExternalStore`), so an unused Debugger costs ~nothing. The
313
- component is created once per hook instance (lazy ref) — its identity must
314
- stay stable across renders or it would remount on every keystroke.
315
- `inspectable.ts` converts arrays/Sets to shapes `JsonTable` can render.
297
+ onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
298
+ `useFormState.ts` itself is deliberately **plumbing only**: it holds the
299
+ values `useState` (a bare `useState` today; granular path-based setters
300
+ are what would earn values state a module of its own), links up the
301
+ modules below, and recomposes their outputs into `FormHelpers<T>`. Each
302
+ slice lives in its own file so it is independently comprehensible and
303
+ unit-testable at its own boundary:
304
+ - `types.ts` the value model, the union-policy gate
305
+ (`UnionPolicyCheck` and friends), the `FormError`/`FormErrors` model,
306
+ `FormDebugSnapshot`, `FormHelpers`.
307
+ - `deriveErrors.ts` `deriveFormErrors(values, constraints)`, the pure
308
+ (React-free) derivation of the structured `FormErrors<T>` list (see
309
+ "The error model"), re-run every render (validators are pure and
310
+ cheap); `isValid` is its emptiness, derived inline in the hook. Its
311
+ loop delegates per-entry semantics to `validations/walk.ts` and
312
+ carries two documented honest widenings: the `Object.keys` cast, and
313
+ `failure.path as Path<T>` `[key]` is a valid single-key `Path<T>`,
314
+ but TS cannot compute `Path<T>` for an unresolved generic `T` to see
315
+ the correlation. It also hosts the cast-free `FlatConstraintEntry`
316
+ assignment that polices grammar growth (see the `validations/` bullet).
317
+ - `errorAt.ts` — the typed error lookup.
318
+ - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
319
+ (lets UIs gate error display) and the validity gate in front of
320
+ `onSubmit`; its `submit()` performs the one honest *refinement* cast
321
+ to `Refine<T, V>` — earned because the validators just passed at
322
+ runtime. `V` is not inferable from `onSubmit`'s parameter position,
323
+ so `useFormState` applies `<T, V>` explicitly.
324
+ - `useFormDebugger.ts` — debugger plumbing: creates one `snapshotStore`
325
+ + one Debugger component per hook instance (lazy ref — the component's
326
+ identity must stay stable across renders or it would remount, and lose
327
+ its open/closed state, on every keystroke) and publishes the
328
+ `FormDebugSnapshot` after every commit.
329
+ - `FormDebugger.tsx` / `snapshotStore.ts` / `inspectable.ts` — the
330
+ `Debugger` itself: a per-instance dev-time overlay (fixed trigger,
331
+ bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
332
+ of the form's internal state. Only an *open* debugger window
333
+ subscribes (`useSyncExternalStore`), so an unused Debugger costs
334
+ ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
335
+ can render.
316
336
  - `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
317
337
  Will be used for granular setters and for surfacing per-field
318
338
  errors/touched state. Coupled to the form value model intentionally.
@@ -324,7 +344,8 @@ precision end-to-end. So we wire it up before adding any consumers.
324
344
  hook delegates to (`validateEntry`). `Validations<T>` accepts bare
325
345
  `(val) => string | null` functions too — they simply narrow nothing.
326
346
  The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
327
- the hook's error loop: `constraints[key]` is *assigned* to it, never cast,
347
+ the error loop in `useFormState/deriveErrors.ts`: `constraints[key]` is
348
+ *assigned* to it, never cast,
328
349
  so a grammar form the walk doesn't understand (a nested spec, an `each`)
329
350
  is a compile error at the assignment — a cast there would silently accept
330
351
  new grammar and misinterpret it at runtime (e.g. call an array as a
@@ -16,7 +16,8 @@ export type FormDebuggerProps = {
16
16
  export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
17
17
 
18
18
  // Builds the per-hook-instance Debugger component. Called once per
19
- // `useFormState` instance (from a lazy ref) so the returned component's
19
+ // `useFormState` instance (from `useFormDebugger`'s lazy ref) so the
20
+ // returned component's
20
21
  // identity is stable across renders — a component recreated each render
21
22
  // would remount, and lose its open/closed state, on every keystroke.
22
23
  export const createFormDebugger = <T extends FormValuesObject>(
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { deriveFormErrors } from './deriveErrors';
3
+ import { matches, notEmpty } from '../validators/validators';
4
+
5
+ // Direct, React-free tests of the pure derivation: values + constraints in,
6
+ // structured `{ path, error }[]` out. Hook-level behavior (live re-derivation
7
+ // across renders, submit gating) stays in useFormState.test.tsx.
8
+
9
+ type SignupForm = {
10
+ email: string | undefined;
11
+ nickname: string | undefined;
12
+ };
13
+
14
+ const emptyForm: SignupForm = { email: undefined, nickname: undefined };
15
+
16
+ describe('deriveFormErrors', () => {
17
+ test('no constraints yields no errors', () => {
18
+ expect(deriveFormErrors(emptyForm, undefined)).toEqual([]);
19
+ });
20
+
21
+ test('an empty constraints object yields no errors', () => {
22
+ expect(deriveFormErrors(emptyForm, {})).toEqual([]);
23
+ });
24
+
25
+ test('a failing field contributes one path-addressed entry', () => {
26
+ const errors = deriveFormErrors(emptyForm, { email: notEmpty('email') });
27
+ expect(errors).toEqual([
28
+ { path: ['email'], error: "'email' cannot be empty" },
29
+ ]);
30
+ });
31
+
32
+ test('a passing field contributes nothing', () => {
33
+ const errors = deriveFormErrors(
34
+ { email: 'a@b.co', nickname: undefined },
35
+ { email: notEmpty('email') },
36
+ );
37
+ expect(errors).toEqual([]);
38
+ });
39
+
40
+ test('every failing constrained field gets its own entry', () => {
41
+ const errors = deriveFormErrors(emptyForm, {
42
+ email: notEmpty('email'),
43
+ nickname: notEmpty('nickname'),
44
+ });
45
+ expect(errors).toEqual([
46
+ { path: ['email'], error: "'email' cannot be empty" },
47
+ { path: ['nickname'], error: "'nickname' cannot be empty" },
48
+ ]);
49
+ });
50
+
51
+ test('a validator array runs in order with first-error-wins', () => {
52
+ const constraints = {
53
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
54
+ };
55
+ expect(deriveFormErrors(emptyForm, constraints)).toEqual([
56
+ { path: ['email'], error: "'email' cannot be empty" },
57
+ ]);
58
+ expect(
59
+ deriveFormErrors({ email: 'nope', nickname: undefined }, constraints),
60
+ ).toEqual([{ path: ['email'], error: "'email' must be a valid email" }]);
61
+ expect(
62
+ deriveFormErrors({ email: 'a@b.co', nickname: undefined }, constraints),
63
+ ).toEqual([]);
64
+ });
65
+
66
+ test('an empty validator array passes', () => {
67
+ expect(deriveFormErrors(emptyForm, { email: [] })).toEqual([]);
68
+ });
69
+
70
+ test('a null constraint entry (possible from untyped JS) is skipped', () => {
71
+ const constraints = { email: null } as unknown as {
72
+ email: (val: string | undefined) => string | null;
73
+ };
74
+ expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
75
+ });
76
+ });
@@ -0,0 +1,35 @@
1
+ import type { Path } from '../path/types';
2
+ import type { Validations } from '../validations/types';
3
+ import { validateEntry } from '../validations/walk';
4
+ import type { FlatConstraintEntry } from '../validations/walk';
5
+ import type { FormError, FormValuesObject } from './types';
6
+
7
+ // The pure half of the hook's error model: current values + constraints in,
8
+ // structured `{ path, error }[]` out. Validation is live-derived (validators
9
+ // are pure and cheap), so this runs every render — it must stay free of
10
+ // React and of any per-call state. `isValid` is just this list's emptiness;
11
+ // the hook derives it inline.
12
+ export const deriveFormErrors = <T extends FormValuesObject>(
13
+ values: T,
14
+ constraints: Validations<T> | undefined,
15
+ ): FormError<T>[] => {
16
+ const errors: FormError<T>[] = [];
17
+ if (!constraints) return errors;
18
+
19
+ for (const key of Object.keys(constraints) as (keyof T & string)[]) {
20
+ // No cast: if the grammar grows a constraint form the walk doesn't
21
+ // understand, this assignment is the compile error that says so.
22
+ const entry: FlatConstraintEntry | undefined = constraints[key];
23
+ if (entry == null) continue;
24
+ const failure = validateEntry(entry, values[key], [key]);
25
+ if (failure == null) continue;
26
+ // The walk returns the address it was handed, and `[key]` — a key of
27
+ // a constraints object type-checked against T — is a valid single-key
28
+ // Path<T>. TS can't compute Path<T> for an unresolved generic T, so
29
+ // the correlation needs the same honest widening as the keys cast
30
+ // above.
31
+ errors.push({ path: failure.path as Path<T>, error: failure.error });
32
+ }
33
+
34
+ return errors;
35
+ };
@@ -90,7 +90,8 @@ export type FormError<T extends FormValuesObject> = {
90
90
 
91
91
  export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
92
92
 
93
- // What the hook publishes to its Debugger after every commit. Snapshots are
93
+ // What the hook publishes (via `useFormDebugger`) to its Debugger after
94
+ // every commit. Snapshots are
94
95
  // replaced whole (never mutated) so `useSyncExternalStore` consumers can
95
96
  // rely on reference equality.
96
97
  export type FormDebugSnapshot<T extends FormValuesObject> = {
@@ -0,0 +1,57 @@
1
+ import { afterEach, describe, test, expect } from 'bun:test';
2
+ import { useState } from 'react';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+
5
+ // The Debugger portals into document.body, so leftovers from a previous
6
+ // test are visible to `screen` queries — clean up explicitly (bun:test has
7
+ // no global afterEach for RTL's auto-cleanup to hook).
8
+ afterEach(cleanup);
9
+ import { useFormDebugger } from './useFormDebugger';
10
+
11
+ // The debugger-plumbing boundary in isolation: the hook must hand back a
12
+ // render-stable component wired to the latest published snapshot. The full
13
+ // overlay behavior (open/close, live form wiring) is covered in
14
+ // FormDebugger.test.tsx through useFormState.
15
+
16
+ const Host = () => {
17
+ const [count, setCount] = useState(0);
18
+ const Debugger = useFormDebugger({
19
+ snapshot: {
20
+ values: { count },
21
+ errors: [],
22
+ isValid: true,
23
+ submitAttempted: false,
24
+ },
25
+ });
26
+
27
+ return (
28
+ <div>
29
+ <button type="button" onClick={() => setCount((prev) => prev + 1)}>
30
+ inc
31
+ </button>
32
+ <Debugger label="dbg" />
33
+ </div>
34
+ );
35
+ };
36
+
37
+ describe('useFormDebugger', () => {
38
+ test('returns a Debugger showing the current snapshot', () => {
39
+ render(<Host />);
40
+ fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
41
+
42
+ expect(screen.getByText('count')).toBeTruthy();
43
+ expect(screen.getByText('0')).toBeTruthy();
44
+ });
45
+
46
+ test('the component identity survives re-renders and shows fresh snapshots', () => {
47
+ render(<Host />);
48
+ fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
49
+
50
+ fireEvent.click(screen.getByRole('button', { name: 'inc' }));
51
+
52
+ // The window is still open (a remounted Debugger would have reset to
53
+ // closed) and reflects the snapshot published after the re-render.
54
+ expect(screen.getByText('1')).toBeTruthy();
55
+ expect(screen.queryByText('0')).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,38 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { createFormDebugger } from './FormDebugger';
3
+ import type { FormDebuggerComponent } from './FormDebugger';
4
+ import { createSnapshotStore } from './snapshotStore';
5
+ import type { SnapshotStore } from './snapshotStore';
6
+ import type { FormDebugSnapshot, FormValuesObject } from './types';
7
+
8
+ export type UseFormDebuggerArgs<T extends FormValuesObject> = {
9
+ snapshot: FormDebugSnapshot<T>;
10
+ };
11
+
12
+ // Debugger plumbing: one store + one component per hook instance, created
13
+ // lazily on first render. The component's identity must be stable across
14
+ // renders — recreated each render it would remount (and lose its
15
+ // open/closed state) on every keystroke.
16
+ export const useFormDebugger = <T extends FormValuesObject>(
17
+ args: UseFormDebuggerArgs<T>,
18
+ ): FormDebuggerComponent => {
19
+ const { snapshot } = args;
20
+
21
+ const debugRef = useRef<{
22
+ store: SnapshotStore<FormDebugSnapshot<T>>;
23
+ Debugger: FormDebuggerComponent;
24
+ } | null>(null);
25
+ if (debugRef.current === null) {
26
+ const store = createSnapshotStore(snapshot);
27
+ debugRef.current = { store, Debugger: createFormDebugger(store) };
28
+ }
29
+ const { store, Debugger } = debugRef.current;
30
+
31
+ // Publish after every commit. With no debugger window subscribed this is a
32
+ // field write and an empty notify loop — effectively free.
33
+ useEffect(() => {
34
+ store.publish(snapshot);
35
+ });
36
+
37
+ return Debugger;
38
+ };
@@ -1,19 +1,13 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { createFormDebugger } from './FormDebugger';
3
- import type { FormDebuggerComponent } from './FormDebugger';
4
- import { createSnapshotStore } from './snapshotStore';
5
- import type { SnapshotStore } from './snapshotStore';
1
+ import { useState } from 'react';
2
+ import { deriveFormErrors } from './deriveErrors';
6
3
  import type {
7
- FormDebugSnapshot,
8
- FormError,
9
4
  FormHelpers,
10
5
  FormValuesObject,
11
6
  UnionPolicyCheck,
12
7
  } from './types';
13
- import type { Path } from '../path/types';
8
+ import { useFormDebugger } from './useFormDebugger';
9
+ import { useFormSubmit } from './useFormSubmit';
14
10
  import type { Refine, Validations } from '../validations/types';
15
- import { validateEntry } from '../validations/walk';
16
- import type { FlatConstraintEntry } from '../validations/walk';
17
11
 
18
12
  // `const V` freezes the inferred type of an inline `constraints` object —
19
13
  // each validator's precise type and Refinement marker survive without any
@@ -33,6 +27,11 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
33
27
  onSubmit?: (values: Refine<T, V>) => void;
34
28
  } & UnionPolicyCheck<T>;
35
29
 
30
+ // Plumbing only: each slice of form state lives in its own pure function or
31
+ // focused hook, and this hook just links them up and recomposes their
32
+ // outputs into the `FormHelpers<T>` surface. Values state is the one slice
33
+ // kept inline — today it is a bare `useState`, and the granular setters that
34
+ // would earn it a module of its own arrive with the path-based grammar.
36
35
  export const useFormState = <
37
36
  T extends FormValuesObject,
38
37
  const V extends Validations<T> = Validations<T>,
@@ -42,60 +41,21 @@ export const useFormState = <
42
41
  const { initialValues, constraints, onSubmit } = args;
43
42
 
44
43
  const [values, onValueChanges] = useState<T>(initialValues);
45
- const [submitAttempted, setSubmitAttempted] = useState(false);
46
-
47
- const errors: FormError<T>[] = [];
48
- if (constraints) {
49
- for (const key of Object.keys(constraints) as (keyof T & string)[]) {
50
- // No cast: if the grammar grows a constraint form the walk doesn't
51
- // understand, this assignment is the compile error that says so.
52
- const entry: FlatConstraintEntry | undefined = constraints[key];
53
- if (entry == null) continue;
54
- const failure = validateEntry(entry, values[key], [key]);
55
- if (failure == null) continue;
56
- // The walk returns the address it was handed, and `[key]` — a key of
57
- // a constraints object type-checked against T — is a valid single-key
58
- // Path<T>. TS can't compute Path<T> for an unresolved generic T, so
59
- // the correlation needs the same honest widening as the keys cast
60
- // above.
61
- errors.push({ path: failure.path as Path<T>, error: failure.error });
62
- }
63
- }
64
44
 
45
+ const errors = deriveFormErrors(values, constraints);
65
46
  const isValid = errors.length === 0;
66
47
 
67
- // Debugger plumbing: one store + one component per hook instance, created
68
- // lazily on first render. The component's identity must be stable across
69
- // renders recreated each render it would remount (and lose its
70
- // open/closed state) on every keystroke.
71
- const debugRef = useRef<{
72
- store: SnapshotStore<FormDebugSnapshot<T>>;
73
- Debugger: FormDebuggerComponent;
74
- } | null>(null);
75
- if (debugRef.current === null) {
76
- const store = createSnapshotStore<FormDebugSnapshot<T>>({
77
- values,
78
- errors,
79
- isValid,
80
- submitAttempted,
81
- });
82
- debugRef.current = { store, Debugger: createFormDebugger(store) };
83
- }
84
- const { store, Debugger } = debugRef.current;
85
-
86
- // Publish after every commit. With no debugger window subscribed this is a
87
- // field write and an empty notify loop — effectively free.
88
- useEffect(() => {
89
- store.publish({ values, errors, isValid, submitAttempted });
48
+ // Explicit type arguments: V rides on `onSubmit`'s parameter type, which
49
+ // is not an inference site (see useFormSubmit.ts).
50
+ const { submitAttempted, submit } = useFormSubmit<T, V>({
51
+ values,
52
+ isValid,
53
+ onSubmit,
90
54
  });
91
55
 
92
- const submit = () => {
93
- setSubmitAttempted(true);
94
- if (!isValid) return;
95
- // Every constrained field's validator just passed at runtime — exactly
96
- // the guarantee Refine<T, V> encodes.
97
- onSubmit?.(values as Refine<T, V>);
98
- };
56
+ const Debugger = useFormDebugger({
57
+ snapshot: { values, errors, isValid, submitAttempted },
58
+ });
99
59
 
100
60
  return {
101
61
  values,
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect, mock } from 'bun:test';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useFormSubmit } from './useFormSubmit';
4
+
5
+ // The submit-orchestration boundary in isolation: the attempt flag and the
6
+ // validity gate, driven by whatever `isValid` the caller derived. Full-form
7
+ // behavior (validity flipping as values change) stays in useFormState.test.tsx.
8
+
9
+ type Form = { email: string | undefined };
10
+
11
+ describe('useFormSubmit', () => {
12
+ test('starts with submitAttempted false', () => {
13
+ const { result } = renderHook(() =>
14
+ useFormSubmit<Form>({ values: { email: undefined }, isValid: true }),
15
+ );
16
+ expect(result.current.submitAttempted).toBe(false);
17
+ });
18
+
19
+ test('submit on an invalid form marks the attempt and skips onSubmit', () => {
20
+ const onSubmit = mock(() => {});
21
+ const { result } = renderHook(() =>
22
+ useFormSubmit<Form>({ values: { email: undefined }, isValid: false, onSubmit }),
23
+ );
24
+
25
+ act(() => {
26
+ result.current.submit();
27
+ });
28
+ expect(result.current.submitAttempted).toBe(true);
29
+ expect(onSubmit).not.toHaveBeenCalled();
30
+ });
31
+
32
+ test('submit on a valid form calls onSubmit with the current values', () => {
33
+ const onSubmit = mock(() => {});
34
+ const { result } = renderHook(() =>
35
+ useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true, onSubmit }),
36
+ );
37
+
38
+ act(() => {
39
+ result.current.submit();
40
+ });
41
+ expect(result.current.submitAttempted).toBe(true);
42
+ expect(onSubmit).toHaveBeenCalledTimes(1);
43
+ expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
44
+ });
45
+
46
+ test('submit without an onSubmit handler is a no-op beyond the flag', () => {
47
+ const { result } = renderHook(() =>
48
+ useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true }),
49
+ );
50
+
51
+ act(() => {
52
+ result.current.submit();
53
+ });
54
+ expect(result.current.submitAttempted).toBe(true);
55
+ });
56
+
57
+ test('submitAttempted stays true once validity flips', () => {
58
+ const onSubmit = mock(() => {});
59
+ const initialProps: { isValid: boolean; email: string | undefined } = {
60
+ isValid: false,
61
+ email: undefined,
62
+ };
63
+ const { result, rerender } = renderHook(
64
+ (props: { isValid: boolean; email: string | undefined }) =>
65
+ useFormSubmit<Form>({
66
+ values: { email: props.email },
67
+ isValid: props.isValid,
68
+ onSubmit,
69
+ }),
70
+ { initialProps },
71
+ );
72
+
73
+ act(() => {
74
+ result.current.submit();
75
+ });
76
+ expect(onSubmit).not.toHaveBeenCalled();
77
+
78
+ rerender({ isValid: true, email: 'a@b.co' });
79
+ expect(result.current.submitAttempted).toBe(true);
80
+
81
+ act(() => {
82
+ result.current.submit();
83
+ });
84
+ expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
85
+ });
86
+ });
@@ -0,0 +1,38 @@
1
+ import { useState } from 'react';
2
+ import type { Refine, Validations } from '../validations/types';
3
+ import type { FormValuesObject } from './types';
4
+
5
+ export type UseFormSubmitArgs<
6
+ T extends FormValuesObject,
7
+ V extends Validations<T>,
8
+ > = {
9
+ values: T;
10
+ isValid: boolean;
11
+ onSubmit?: (values: Refine<T, V>) => void;
12
+ };
13
+
14
+ // Submit orchestration: owns the `submitAttempted` flag (UIs gate error
15
+ // display on it) and the validity gate in front of `onSubmit`. This is the
16
+ // home of the ONE sanctioned refinement cast (see the cast doctrine in
17
+ // src/forms/CLAUDE.md). `V` is not inferable from `onSubmit`'s parameter
18
+ // position, so `useFormState` applies both type arguments explicitly.
19
+ export const useFormSubmit = <
20
+ T extends FormValuesObject,
21
+ V extends Validations<T> = Validations<T>,
22
+ >(
23
+ args: UseFormSubmitArgs<T, V>,
24
+ ) => {
25
+ const { values, isValid, onSubmit } = args;
26
+
27
+ const [submitAttempted, setSubmitAttempted] = useState(false);
28
+
29
+ const submit = () => {
30
+ setSubmitAttempted(true);
31
+ if (!isValid) return;
32
+ // Every constrained field's validator just passed at runtime — exactly
33
+ // the guarantee Refine<T, V> encodes.
34
+ onSubmit?.(values as Refine<T, V>);
35
+ };
36
+
37
+ return { submitAttempted, submit };
38
+ };
@@ -19,8 +19,9 @@ export type ValidationError = {
19
19
  type AnyFieldValidator = (val: never) => string | null;
20
20
 
21
21
  // The walk's view of one entry in a `Validations<T>` object. This type is
22
- // what lets the compiler police the walk's assumptions: the hook assigns
23
- // `constraints[key]` to it WITHOUT a cast, so when the grammar grows a form
22
+ // what lets the compiler police the walk's assumptions: the error derivation
23
+ // (`useFormState/deriveErrors.ts`) assigns `constraints[key]` to it WITHOUT
24
+ // a cast, so when the grammar grows a form
24
25
  // that is neither a function nor an array of them (nested spec, list `each`),
25
26
  // that assignment stops compiling and the walk must learn the new form —
26
27
  // instead of a stale walk misinterpreting it at runtime.