jsonisch 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/form-ref-BEri3JKv.js +1123 -0
- package/dist/form-ref-D_xHSaqL.d.ts +994 -0
- package/dist/handle-submit-CD3WY9gW.js +208 -0
- package/dist/handle-submit-Dk7QW3_q.d.ts +34 -0
- package/dist/index.d.ts +724 -0
- package/dist/index.js +1521 -0
- package/dist/plugin/index.d.ts +188 -0
- package/dist/plugin/index.js +4 -0
- package/dist/plugin-BCsAE8RK.d.ts +612 -0
- package/dist/plugin-Qka-vaO1.js +1545 -0
- package/dist/react/index.d.ts +476 -0
- package/dist/react/index.js +456 -0
- package/docs/decode-fork.md +24 -0
- package/docs/plugin-lifecycle.md +44 -0
- package/docs/rerender-history.md +61 -0
- package/docs/wire-shapes.md +70 -0
- package/package.json +87 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { B as getFieldBool, F as createTracker, a as getFieldStore, f as createFormStore, n as validateFormInput, r as getFieldInput, v as dispatchFieldSnapshot } from "../form-ref-BEri3JKv.js";
|
|
2
|
+
import { a as setFieldInput, n as setFieldBool, r as validateIfRequired, t as handleSubmit } from "../handle-submit-CD3WY9gW.js";
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
|
|
6
|
+
//#region src/react/use-signal-snapshot.ts
|
|
7
|
+
/**
|
|
8
|
+
* Value-level equality for snapshot objects: top-level keys compared with
|
|
9
|
+
* `Object.is`, with ONE extra level for plain arrays and plain objects —
|
|
10
|
+
* computed results (a `DerivedState`, an errors array) are rebuilt with a
|
|
11
|
+
* fresh identity on every recompute, and without the value compare every
|
|
12
|
+
* notification would produce a render even when nothing visible changed.
|
|
13
|
+
* Deeper nesting falls back to "not equal" (re-render), never the other
|
|
14
|
+
* way — a false negative costs one render, a false positive would cost a
|
|
15
|
+
* stale UI.
|
|
16
|
+
*/
|
|
17
|
+
function snapshotEqual(a, b) {
|
|
18
|
+
if (Object.is(a, b)) return true;
|
|
19
|
+
if (typeof a !== "object" || a === null) return false;
|
|
20
|
+
if (typeof b !== "object" || b === null) return false;
|
|
21
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
22
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
23
|
+
return a.every((value, index) => valueEqual(value, b[index]));
|
|
24
|
+
}
|
|
25
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
26
|
+
const aKeys = Object.keys(a);
|
|
27
|
+
const bKeys = Object.keys(b);
|
|
28
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
29
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && valueEqual(a[key], b[key]));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The one-level-down compare: `Object.is`, widened to a shallow compare for
|
|
33
|
+
* plain arrays/objects (fresh-identity computed results).
|
|
34
|
+
*/
|
|
35
|
+
function valueEqual(a, b) {
|
|
36
|
+
if (Object.is(a, b)) return true;
|
|
37
|
+
if (typeof a !== "object" || a === null) return false;
|
|
38
|
+
if (typeof b !== "object" || b === null) return false;
|
|
39
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
40
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
41
|
+
return a.every((value, index) => Object.is(value, b[index]));
|
|
42
|
+
}
|
|
43
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
44
|
+
const aKeys = Object.keys(a);
|
|
45
|
+
const bKeys = Object.keys(b);
|
|
46
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
47
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && Object.is(a[key], b[key]));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Creates the external store bridging the signal graph to React.
|
|
51
|
+
*
|
|
52
|
+
* Four load-bearing properties:
|
|
53
|
+
*
|
|
54
|
+
* - `getSnapshot` NEVER computes — it returns the cached reference. React
|
|
55
|
+
* calls it during render and after every notification; computing there
|
|
56
|
+
* would either violate render purity or clobber an outer listener. The
|
|
57
|
+
* tracked read lives in the tracker's invalidation callback instead.
|
|
58
|
+
* - The invalidation callback re-reads ONLY while subscribed to React.
|
|
59
|
+
* One-shot core semantics make an unsubscribed store dormant after its
|
|
60
|
+
* first notification, so a render that never commits (StrictMode's
|
|
61
|
+
* discarded pass, a concurrent abort) cannot leak a live subscription —
|
|
62
|
+
* the signal graph drops its last reference to the tracker and the
|
|
63
|
+
* whole store is garbage.
|
|
64
|
+
* - `subscribe` re-reads before installing the React callback: it may be
|
|
65
|
+
* re-establishing a store that went dormant (mount race) or was disposed
|
|
66
|
+
* (StrictMode's synthetic unmount/remount), and the value may have moved
|
|
67
|
+
* while nobody listened. React re-reads `getSnapshot` right after
|
|
68
|
+
* subscribing and re-renders on mismatch, so a change in the gap is
|
|
69
|
+
* caught by value, not by version bookkeeping.
|
|
70
|
+
* - Equality is by VALUE (`isEqual`): a notification that changes nothing
|
|
71
|
+
* visible keeps the previous snapshot reference and produces no render.
|
|
72
|
+
*/
|
|
73
|
+
function createSnapshotStore(compute, isEqual) {
|
|
74
|
+
let notifyReact;
|
|
75
|
+
let snapshot;
|
|
76
|
+
const tracker = createTracker(() => {
|
|
77
|
+
if (notifyReact === void 0) return;
|
|
78
|
+
const next = tracker.read(compute);
|
|
79
|
+
if (isEqual(snapshot, next)) return;
|
|
80
|
+
snapshot = next;
|
|
81
|
+
notifyReact();
|
|
82
|
+
});
|
|
83
|
+
snapshot = tracker.read(compute);
|
|
84
|
+
return {
|
|
85
|
+
subscribe(onStoreChange) {
|
|
86
|
+
const next = tracker.read(compute);
|
|
87
|
+
if (!isEqual(snapshot, next)) snapshot = next;
|
|
88
|
+
notifyReact = onStoreChange;
|
|
89
|
+
return () => {
|
|
90
|
+
notifyReact = void 0;
|
|
91
|
+
tracker.dispose();
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
getSnapshot: () => snapshot
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Subscribes the component to exactly the signals `compute` reads and
|
|
99
|
+
* returns the computed snapshot. THE reactive primitive of the react
|
|
100
|
+
* adapter — every jsonisch hook reads through it, and it is the public
|
|
101
|
+
* escape hatch for a component that needs a narrower subscription than
|
|
102
|
+
* `useField` provides.
|
|
103
|
+
*
|
|
104
|
+
* React-Compiler-safe by construction: the tracked reads happen inside a
|
|
105
|
+
* closure the library invokes — the compiler can only elide expressions
|
|
106
|
+
* whose call sites it memoized, never a call made from a hook's internals —
|
|
107
|
+
* and the returned snapshot is an immutable value whose identity changes
|
|
108
|
+
* when any observed value changes, so compiler memo caches keyed on it
|
|
109
|
+
* invalidate correctly instead of freezing.
|
|
110
|
+
*
|
|
111
|
+
* `useMemo` semantics apply to `deps`: the compute closure is captured when
|
|
112
|
+
* `deps` change, so everything it reads must be a signal (tracked) or
|
|
113
|
+
* listed in `deps` (recreates the store). `isEqual` gates re-renders by
|
|
114
|
+
* value; it defaults to `snapshotEqual`.
|
|
115
|
+
*
|
|
116
|
+
* @param compute The tracked read producing the snapshot.
|
|
117
|
+
* @param deps The non-signal inputs of `compute` (useMemo contract).
|
|
118
|
+
* @param isEqual The snapshot equality gate.
|
|
119
|
+
*
|
|
120
|
+
* @returns The current snapshot.
|
|
121
|
+
*/
|
|
122
|
+
function useSignalSnapshot(compute, deps, isEqual = snapshotEqual) {
|
|
123
|
+
const store = useMemo(() => createSnapshotStore(compute, isEqual), deps);
|
|
124
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/react/use-field.ts
|
|
129
|
+
/**
|
|
130
|
+
* The snapshot member names owned by the adapter itself — no plugin's
|
|
131
|
+
* `fieldSnapshot` may claim one (`dispatchFieldSnapshot` throws). Covers
|
|
132
|
+
* the public `FieldStore` members plus the two internal tracked keys
|
|
133
|
+
* (`hasValidationErrors`/`autoFocus`) that feed them.
|
|
134
|
+
*/
|
|
135
|
+
const RESERVED_SNAPSHOT_KEYS = new Set([
|
|
136
|
+
"path",
|
|
137
|
+
"name",
|
|
138
|
+
"schema",
|
|
139
|
+
"control",
|
|
140
|
+
"input",
|
|
141
|
+
"errors",
|
|
142
|
+
"isTouched",
|
|
143
|
+
"isEdited",
|
|
144
|
+
"isDirty",
|
|
145
|
+
"isValid",
|
|
146
|
+
"visible",
|
|
147
|
+
"onChange",
|
|
148
|
+
"props",
|
|
149
|
+
"hasValidationErrors",
|
|
150
|
+
"autoFocus"
|
|
151
|
+
]);
|
|
152
|
+
/**
|
|
153
|
+
* Everything reactive about a field, read in one tracked pass owned by
|
|
154
|
+
* `useSignalSnapshot` (never inline in a component body, so React Compiler
|
|
155
|
+
* memoization cannot elide the reads). Plugin members merge in FLAT — at
|
|
156
|
+
* the snapshot's top level, never nested under a sub-object — so the
|
|
157
|
+
* one-extra-level value compare of `snapshotEqual` still reaches inside a
|
|
158
|
+
* recomputed result object (a `DerivedState`) and gates the re-render.
|
|
159
|
+
*/
|
|
160
|
+
function readFieldSnapshot(internalFormStore, internalFieldStore, path) {
|
|
161
|
+
return {
|
|
162
|
+
input: getFieldInput(internalFieldStore),
|
|
163
|
+
errors: internalFieldStore.errors.value,
|
|
164
|
+
isTouched: getFieldBool(internalFieldStore, "isTouched"),
|
|
165
|
+
isEdited: getFieldBool(internalFieldStore, "isEdited"),
|
|
166
|
+
isDirty: getFieldBool(internalFieldStore, "isDirty"),
|
|
167
|
+
hasValidationErrors: getFieldBool(internalFieldStore, "validationErrors"),
|
|
168
|
+
visible: internalFieldStore.control === "hidden" ? false : internalFieldStore.visible?.value ?? true,
|
|
169
|
+
autoFocus: !!internalFieldStore.validationErrors.value,
|
|
170
|
+
...dispatchFieldSnapshot(internalFormStore, internalFieldStore, path, RESERVED_SNAPSHOT_KEYS)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Creates a reactive field store for the field at the given path. Widgets
|
|
175
|
+
* are controlled components: render `field.input`, call
|
|
176
|
+
* `field.onChange(value)`, and spread `field.props` onto the DOM element so
|
|
177
|
+
* focus/blur validation modes and focus-on-error work.
|
|
178
|
+
*
|
|
179
|
+
* The returned store is an immutable SNAPSHOT: its identity changes when
|
|
180
|
+
* any observed value changes and is stable otherwise, so it composes with
|
|
181
|
+
* React Compiler memoization instead of fighting it (the LOS-567/LOS-602
|
|
182
|
+
* rewrite). Callbacks and DOM plumbing keep a stable identity for the
|
|
183
|
+
* field's lifetime.
|
|
184
|
+
*
|
|
185
|
+
* @param form The form store the field belongs to.
|
|
186
|
+
* @param path The path to the field.
|
|
187
|
+
*
|
|
188
|
+
* @returns The field store snapshot.
|
|
189
|
+
*/
|
|
190
|
+
function useField(form, path) {
|
|
191
|
+
const internalFormStore = form.internal;
|
|
192
|
+
const internalFieldStore = getFieldStore(internalFormStore, path);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
return () => {
|
|
195
|
+
const elements = internalFieldStore.elements.filter((element) => element.isConnected);
|
|
196
|
+
if (internalFieldStore.elements === internalFieldStore.initialElements) internalFieldStore.initialElements = elements;
|
|
197
|
+
else internalFieldStore.initialElements = internalFieldStore.initialElements.filter((element) => element.isConnected);
|
|
198
|
+
internalFieldStore.elements = elements;
|
|
199
|
+
};
|
|
200
|
+
}, [internalFieldStore]);
|
|
201
|
+
const reactive = useSignalSnapshot(() => readFieldSnapshot(internalFormStore, internalFieldStore, path), [internalFormStore, internalFieldStore]);
|
|
202
|
+
const stable = useMemo(() => ({
|
|
203
|
+
onChange(value) {
|
|
204
|
+
setFieldInput(internalFormStore, path, value);
|
|
205
|
+
validateIfRequired(internalFormStore, internalFieldStore, "input");
|
|
206
|
+
validateIfRequired(internalFormStore, internalFieldStore, "change");
|
|
207
|
+
},
|
|
208
|
+
props: {
|
|
209
|
+
name: internalFieldStore.name,
|
|
210
|
+
ref(element) {
|
|
211
|
+
if (element && !internalFieldStore.elements.includes(element)) internalFieldStore.elements.push(element);
|
|
212
|
+
},
|
|
213
|
+
onFocus() {
|
|
214
|
+
setFieldBool(internalFieldStore, "isTouched", true);
|
|
215
|
+
validateIfRequired(internalFormStore, internalFieldStore, "touch");
|
|
216
|
+
},
|
|
217
|
+
onBlur() {
|
|
218
|
+
validateIfRequired(internalFormStore, internalFieldStore, "blur");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}), [internalFormStore, internalFieldStore]);
|
|
222
|
+
return useMemo(() => {
|
|
223
|
+
const { hasValidationErrors, autoFocus, ...rest } = reactive;
|
|
224
|
+
return {
|
|
225
|
+
path,
|
|
226
|
+
name: internalFieldStore.name,
|
|
227
|
+
schema: internalFieldStore.schema,
|
|
228
|
+
control: internalFieldStore.control,
|
|
229
|
+
...rest,
|
|
230
|
+
isValid: !hasValidationErrors,
|
|
231
|
+
onChange: stable.onChange,
|
|
232
|
+
props: {
|
|
233
|
+
...stable.props,
|
|
234
|
+
autoFocus
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}, [reactive, stable]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/react/use-field-array.ts
|
|
242
|
+
/**
|
|
243
|
+
* The array field's reactive state, read in one tracked pass.
|
|
244
|
+
*/
|
|
245
|
+
function readFieldArraySnapshot(internalFieldStore) {
|
|
246
|
+
return {
|
|
247
|
+
items: internalFieldStore.items.value,
|
|
248
|
+
errors: internalFieldStore.errors.value,
|
|
249
|
+
isTouched: getFieldBool(internalFieldStore, "isTouched"),
|
|
250
|
+
isEdited: getFieldBool(internalFieldStore, "isEdited"),
|
|
251
|
+
isDirty: getFieldBool(internalFieldStore, "isDirty"),
|
|
252
|
+
hasValidationErrors: getFieldBool(internalFieldStore, "validationErrors")
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Creates a reactive field array store for the array field at the given
|
|
257
|
+
* path. Render one row per `items` entry and use the item ID as the React
|
|
258
|
+
* key so row state and DOM follow their row across reorders.
|
|
259
|
+
*
|
|
260
|
+
* The returned store is an immutable SNAPSHOT (see `useField` for the
|
|
261
|
+
* model).
|
|
262
|
+
*
|
|
263
|
+
* @param form The form store the array field belongs to.
|
|
264
|
+
* @param path The path to the array field.
|
|
265
|
+
*
|
|
266
|
+
* @returns The field array store snapshot.
|
|
267
|
+
*/
|
|
268
|
+
function useFieldArray(form, path) {
|
|
269
|
+
const internalFieldStore = getFieldStore(form.internal, path);
|
|
270
|
+
if (internalFieldStore.kind !== "array") throw new Error(`Expected an array field at path ${JSON.stringify(path)}, got "${internalFieldStore.kind}"`);
|
|
271
|
+
const reactive = useSignalSnapshot(() => readFieldArraySnapshot(internalFieldStore), [internalFieldStore]);
|
|
272
|
+
return useMemo(() => ({
|
|
273
|
+
path,
|
|
274
|
+
items: reactive.items,
|
|
275
|
+
errors: reactive.errors,
|
|
276
|
+
isTouched: reactive.isTouched,
|
|
277
|
+
isEdited: reactive.isEdited,
|
|
278
|
+
isDirty: reactive.isDirty,
|
|
279
|
+
isValid: !reactive.hasValidationErrors
|
|
280
|
+
}), [reactive]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/react/components.tsx
|
|
285
|
+
/**
|
|
286
|
+
* Headless form component: a native `<form noValidate>` wired to
|
|
287
|
+
* `handleSubmit` — an invalid submit blocks the handler and focuses the
|
|
288
|
+
* first erroring field. The registry-aware `Form` from `createFormHook`
|
|
289
|
+
* builds on this and adds whole-form auto-rendering.
|
|
290
|
+
*
|
|
291
|
+
* @param props The form component props.
|
|
292
|
+
*
|
|
293
|
+
* @returns A native form element.
|
|
294
|
+
*/
|
|
295
|
+
function Form({ of, onSubmit, ...other }) {
|
|
296
|
+
return /* @__PURE__ */ jsx("form", {
|
|
297
|
+
...other,
|
|
298
|
+
noValidate: true,
|
|
299
|
+
ref: (element) => {
|
|
300
|
+
if (element) of.internal.element = element;
|
|
301
|
+
},
|
|
302
|
+
onSubmit: handleSubmit(of, onSubmit)
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Headless field component — the escape hatch for custom layouts: takes a
|
|
307
|
+
* form store and a path, and calls the render function with the reactive
|
|
308
|
+
* field store.
|
|
309
|
+
*
|
|
310
|
+
* @param props The field component props.
|
|
311
|
+
*
|
|
312
|
+
* @returns The rendered field UI.
|
|
313
|
+
*/
|
|
314
|
+
function Field({ of, path, children }) {
|
|
315
|
+
return children(useField(of, path));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Headless field array component: calls the render function with the
|
|
319
|
+
* reactive field array store (stable item IDs for React keys).
|
|
320
|
+
*
|
|
321
|
+
* @param props The field array component props.
|
|
322
|
+
*
|
|
323
|
+
* @returns The rendered field array UI.
|
|
324
|
+
*/
|
|
325
|
+
function FieldArray({ of, path, children }) {
|
|
326
|
+
return children(useFieldArray(of, path));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/react/use-form.ts
|
|
331
|
+
/**
|
|
332
|
+
* The form-level reactive state, read in one tracked pass. The whole-tree
|
|
333
|
+
* walks behind `isTouched`/`isEdited`/`isDirty`/`isValid` are cached as
|
|
334
|
+
* computeds on the internal store (`aggregates`), so a notification
|
|
335
|
+
* re-reads four cache hits, not four tree walks.
|
|
336
|
+
*/
|
|
337
|
+
function readFormSnapshot(internal) {
|
|
338
|
+
return {
|
|
339
|
+
isSubmitting: internal.isSubmitting.value,
|
|
340
|
+
isSubmitted: internal.isSubmitted.value,
|
|
341
|
+
isValidating: internal.isValidating.value,
|
|
342
|
+
isTouched: internal.aggregates.isTouched.value,
|
|
343
|
+
isEdited: internal.aggregates.isEdited.value,
|
|
344
|
+
isDirty: internal.aggregates.isDirty.value,
|
|
345
|
+
isValid: internal.aggregates.isValid.value,
|
|
346
|
+
errors: internal.errors.value
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Creates a reactive form store from a form configuration. The store is
|
|
351
|
+
* created once for the component's lifetime — config changes after mount
|
|
352
|
+
* are ignored (`applyBaseline` rebases on a fresh server record; `reset`
|
|
353
|
+
* with a new `initialInput` discards in-flight edits with it).
|
|
354
|
+
*
|
|
355
|
+
* The returned store is an immutable SNAPSHOT over the stable `internal`
|
|
356
|
+
* store: its identity changes when any observed form-level value changes
|
|
357
|
+
* (see `useField` for the model).
|
|
358
|
+
*
|
|
359
|
+
* @param config The form configuration.
|
|
360
|
+
*
|
|
361
|
+
* @returns The form store snapshot.
|
|
362
|
+
*/
|
|
363
|
+
function useForm(config) {
|
|
364
|
+
const internal = useMemo(() => createFormStore(config), []);
|
|
365
|
+
useLayoutEffect(() => {
|
|
366
|
+
if (config.validate === "initial") validateFormInput(internal);
|
|
367
|
+
}, []);
|
|
368
|
+
const reactive = useSignalSnapshot(() => readFormSnapshot(internal), [internal]);
|
|
369
|
+
return useMemo(() => ({
|
|
370
|
+
internal,
|
|
371
|
+
...reactive
|
|
372
|
+
}), [internal, reactive]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/react/create-form-hook.tsx
|
|
377
|
+
/**
|
|
378
|
+
* The built-in fallback for a widget kind without a registry entry: a
|
|
379
|
+
* visible, explicit placeholder — silently dropping a field would make a
|
|
380
|
+
* schema misconfiguration invisible.
|
|
381
|
+
*/
|
|
382
|
+
function UnsupportedWidget({ field }) {
|
|
383
|
+
return /* @__PURE__ */ jsxs("p", {
|
|
384
|
+
"data-slot": "jsonisch-unsupported",
|
|
385
|
+
role: "note",
|
|
386
|
+
children: [
|
|
387
|
+
"Unsupported field kind “",
|
|
388
|
+
field.control,
|
|
389
|
+
"” (",
|
|
390
|
+
field.name,
|
|
391
|
+
")"
|
|
392
|
+
]
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Creates the app's form API around a widget registry (the TanStack
|
|
397
|
+
* `createFormHook` borrow): widgets are registered ONCE at module level,
|
|
398
|
+
* and every form derives its fields from the schema through them.
|
|
399
|
+
*
|
|
400
|
+
* @param config The registry and injected validator compiler.
|
|
401
|
+
*
|
|
402
|
+
* @returns The bound `useAppForm`, `Form` and `Field`.
|
|
403
|
+
*/
|
|
404
|
+
function createFormHook(config) {
|
|
405
|
+
const Fallback = config.fallback ?? UnsupportedWidget;
|
|
406
|
+
function useAppForm(formConfig) {
|
|
407
|
+
const validator = useMemo(() => config.validate?.(formConfig.schema), []);
|
|
408
|
+
return useForm({
|
|
409
|
+
...formConfig,
|
|
410
|
+
validator
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
function AppField({ of, path, children }) {
|
|
414
|
+
if (children) return /* @__PURE__ */ jsx(Field, {
|
|
415
|
+
of,
|
|
416
|
+
path,
|
|
417
|
+
children
|
|
418
|
+
});
|
|
419
|
+
return /* @__PURE__ */ jsx(RegistryField, {
|
|
420
|
+
of,
|
|
421
|
+
path
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function RegistryField({ of, path }) {
|
|
425
|
+
const field = useField(of, path);
|
|
426
|
+
if (!field.visible) return null;
|
|
427
|
+
return /* @__PURE__ */ jsx(config.widgets[field.control] ?? Fallback, {
|
|
428
|
+
field,
|
|
429
|
+
form: of
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
function AutoFields({ of }) {
|
|
433
|
+
const root = of.internal;
|
|
434
|
+
return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(root.children).map(([key, child]) => child.control === "hidden" ? null : /* @__PURE__ */ jsx(AppField, {
|
|
435
|
+
of,
|
|
436
|
+
path: [key]
|
|
437
|
+
}, key)) });
|
|
438
|
+
}
|
|
439
|
+
function AppForm({ of, onSubmit, children, ...other }) {
|
|
440
|
+
return /* @__PURE__ */ jsx(Form, {
|
|
441
|
+
of,
|
|
442
|
+
onSubmit,
|
|
443
|
+
...other,
|
|
444
|
+
children: children ?? /* @__PURE__ */ jsx(AutoFields, { of })
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
useAppForm,
|
|
449
|
+
Form: AppForm,
|
|
450
|
+
Field: AppField,
|
|
451
|
+
Fields: AutoFields
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
//#endregion
|
|
456
|
+
export { Field, FieldArray, Form, createFormHook, snapshotEqual, useField, useFieldArray, useForm, useSignalSnapshot };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# The decode fork
|
|
2
|
+
|
|
3
|
+
How a server record becomes form state: **one decode, then a fork** — the
|
|
4
|
+
canonical prose lives atop `src/core/codec/decode-record.ts` (the ASCII
|
|
5
|
+
version at the point of use); this page is the rendered view.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TD
|
|
9
|
+
PG["Postgres row<br/>{ …columns, data: { myField: { kind, value?, mode, … } } }"]
|
|
10
|
+
PG -->|"① decodeRecord — schema-declared keys only,<br/>envelopes pass through WHOLE<br/>(bag preferred over a column mirror)"| II["initialInput<br/>{ myField: { kind, value?, mode, … } }"]
|
|
11
|
+
II -->|"② createFormStore visits each declared field"| FORK{{"estimate / amount-or-percent leaf:<br/>the SAME raw envelope is read twice"}}
|
|
12
|
+
FORK -->|"value half<br/>unwrapLeafInput (core/plugin/driver.ts)"| INPUT["field input signal<br/>60000 — what you type over,<br/>what formulas read"]
|
|
13
|
+
FORK -->|"meta half<br/>envelopes() buildScope<br/>(plugins/envelopes/plugin.ts)"| SLOT["envelope slot<br/>mode: estimate/formula —<br/>what the toggle shows"]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It is a **fork, not a chain**: the meta reader consumes the original raw,
|
|
17
|
+
never the value reader's output, so the value a user sees and the mode state
|
|
18
|
+
next to it can never derive from different data.
|
|
19
|
+
|
|
20
|
+
The same fork re-runs on `reset`, `applyBaseline` (root and per row, after
|
|
21
|
+
the value rebase), and array-row reuse — every path funnels through the same
|
|
22
|
+
two readers. There is no envelope side-channel: `decodeCompanions` was
|
|
23
|
+
deleted in LOS-603 because the envelope rides the field key itself, at every
|
|
24
|
+
depth (a row's estimate decodes from its own row object identically).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Plugin lifecycle
|
|
2
|
+
|
|
3
|
+
Since LOS-603 everything computed on top of the base pipeline is a plugin
|
|
4
|
+
(`src/core/plugin/{key,types,driver}.ts`). The standard trio a stage form
|
|
5
|
+
registers: `envelopes()` → `derivation(engine)` → `visibility()` — array
|
|
6
|
+
order is load-bearing (`derivation` declares `dependsOn: [envelopesKey]`;
|
|
7
|
+
a missing/later dependency throws at `createFormStore`).
|
|
8
|
+
|
|
9
|
+
**Core owns the phases** — every hook is a pinned call site in core; plugins
|
|
10
|
+
only order among themselves within a hook.
|
|
11
|
+
|
|
12
|
+
```mermaid
|
|
13
|
+
flowchart TD
|
|
14
|
+
A["createFormStore(config)"] --> B["resolvePlugins:<br/>flatten, validate names/keys/hooks,<br/>dependsOn-throw, collect wire envelopes"]
|
|
15
|
+
B --> C["build (per plugin, array order):<br/>state container only — BEFORE the walk"]
|
|
16
|
+
C --> D["schema walk (initializeFieldStore)<br/>leaf inputs unwrap envelopes via unwrapLeafInput"]
|
|
17
|
+
D -- "each array-item object,<br/>AS the walk creates it" --> E["buildScope(rowStore, rawRow)"]
|
|
18
|
+
D --> F["root buildScope(form, rawInitialInput)<br/>envelopes → derivation → visibility"]
|
|
19
|
+
F --> G["aggregates (isDirty = field walk OR pluginsDirty —<br/>reads EVERY plugin, never short-circuits)"]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Later dispatches, each from its pinned core site:
|
|
23
|
+
|
|
24
|
+
| Hook | Fires from | Purpose |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `reseedScope` | `resetItemState` (row reuse/regrow) | re-seed slots in place — computeds keep tracking |
|
|
27
|
+
| `rebase` | `applyBaseline` (root) + `rebaseFieldBaseline` (rows), AFTER the value rebase | baselines adopt fresh meta; live signals only when clean |
|
|
28
|
+
| `resetField` | inside `reset`'s walk | restore slots to decode-time baseline (scoped resets free) |
|
|
29
|
+
| `syncInput` | `setFieldInput` leaf write | plugin writes `store.input` + its slot (`writeEnvelope`); return true to skip core's write |
|
|
30
|
+
| `syncInitial` | `reset({ initialInput })` via `setInitialFieldInput` | re-decode start envelope from the same raw |
|
|
31
|
+
| `transferField` / `swapField` | `copyItemState` / `swapItemState` | per-field slots travel with rows (stores are position-fixed) |
|
|
32
|
+
| `fieldIsDirty` / `isDirty` | dirty walks / the aggregate | payload emission + Save enablement |
|
|
33
|
+
| `encodeValue` | dirty encoding (`getDirtyFieldInput`/`pickDirty`/`encodeScopeValues`) | wrap your own key — the LOS-573 envelope |
|
|
34
|
+
|
|
35
|
+
State lives in `form.pluginState`, keyed by `PluginKey` identity; per-field
|
|
36
|
+
slots (`FieldSlotKey`) are maps keyed by field-store identity. Cross-plugin
|
|
37
|
+
reads go through exported keys only (derivation imports `envelopesKey`, never
|
|
38
|
+
the envelopes implementation). The react surface reads slots via the same
|
|
39
|
+
keys until slice 3 (LOS-604) moves it to `fieldSnapshot` contributions.
|
|
40
|
+
|
|
41
|
+
Hard rule (spec D6): a plugin's `isDirty` runs inside a computed — it must
|
|
42
|
+
read its signals unconditionally, and the aggregate never short-circuits
|
|
43
|
+
between plugins; an unran handler contributes no signal reads and deafens the
|
|
44
|
+
projection.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# A short history of the re-render problem
|
|
2
|
+
|
|
3
|
+
Where jsonisch's reactivity engine comes from. This is lineage, not pitch —
|
|
4
|
+
the re-render problem was solved by the signals generation (formisch and the
|
|
5
|
+
SolidJS school); jsonisch inherits that answer rather than contributing one.
|
|
6
|
+
It's recorded here because knowing which era a library belongs to tells you
|
|
7
|
+
most of what you need to know about it.
|
|
8
|
+
|
|
9
|
+
Every React form library is, underneath, an answer to one question: **when I
|
|
10
|
+
type one character into a 60-field form, what re-renders?** The history is
|
|
11
|
+
basically that answer getting better.
|
|
12
|
+
|
|
13
|
+
| Era | Library | How it held form state | What re-rendered on a keystroke |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| ~2016 | **Redux-Form** | in the Redux store | the whole form subtree, from the store — correct, and famously slow |
|
|
16
|
+
| ~2018 | **Formik** | one React state object at the top | all fields — the "everything re-renders" problem |
|
|
17
|
+
| ~2018 | **React Final Form** | an observable form-state object + **per-field subscriptions** | only the field you typed in — hand-rolled fine-grained reactivity |
|
|
18
|
+
| ~2019 | **react-hook-form** | **uncontrolled inputs + refs** (the DOM holds the value) | nothing, until you ask — fast by *dodging* React |
|
|
19
|
+
| ~2020 | **TanStack Form** | a framework-agnostic store + **selector subscriptions** | only the subscribed slice — plus deep type inference and a composition API |
|
|
20
|
+
| ~2024 | **Formisch** (and the SolidJS lineage) | **signals** | only the true dependents — reactivity at the *value* level, with computeds for free |
|
|
21
|
+
|
|
22
|
+
React Final Form and TanStack Form both reach for **subscriptions**; signals
|
|
23
|
+
are the same idea made automatic and general — read a value and you're
|
|
24
|
+
subscribed, write it and only the readers re-run.
|
|
25
|
+
|
|
26
|
+
## What's a signal, concretely?
|
|
27
|
+
|
|
28
|
+
A signal is a value that tracks who reads it, so it can notify exactly those
|
|
29
|
+
readers when it changes. The whole mechanism is small:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
let currentListener = null; // "who's reading right now?"
|
|
33
|
+
|
|
34
|
+
function signal(value) {
|
|
35
|
+
const subs = new Set();
|
|
36
|
+
return {
|
|
37
|
+
get() { if (currentListener) subs.add(currentListener); return value; }, // read = subscribe
|
|
38
|
+
set(next) { value = next; subs.forEach((fn) => fn()); }, // write = notify
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function effect(fn) { // re-runs when any signal it read changes
|
|
43
|
+
const run = () => { currentListener = run; fn(); currentListener = null; };
|
|
44
|
+
run();
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The trick is the global `currentListener`: while a computation runs, any
|
|
49
|
+
`signal.get()` it calls auto-subscribes it. No dependency arrays. A
|
|
50
|
+
**computed** (e.g. a derived field) is just an `effect` that reads some
|
|
51
|
+
signals and writes to its own. In React, a snapshot hook bridges the gap —
|
|
52
|
+
it registers a subscriber that re-renders the component and collects which
|
|
53
|
+
signals were read during render.
|
|
54
|
+
|
|
55
|
+
## What jsonisch takes from this
|
|
56
|
+
|
|
57
|
+
Signals as the reactivity engine (its own implementation, no external signal
|
|
58
|
+
lib), so a keystroke re-renders only the fields that depend on it, and
|
|
59
|
+
derived fields recompute automatically as computeds over the dependency
|
|
60
|
+
graph. That's the formisch inheritance; the rest of jsonisch — deriving the
|
|
61
|
+
whole form from a runtime schema value — is layered on top of it.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Wire shapes
|
|
2
|
+
|
|
3
|
+
Estimate and amount-or-percent fields persist a **kind-discriminated
|
|
4
|
+
envelope on their own key** — there are no `<key>Source`/`<key>Hybrid`
|
|
5
|
+
sibling keys anywhere on the wire:
|
|
6
|
+
|
|
7
|
+
```jsonc
|
|
8
|
+
// estimate (control "estimate")
|
|
9
|
+
"totalProjectBudget": {
|
|
10
|
+
"kind": "estimate",
|
|
11
|
+
"value": 60000,
|
|
12
|
+
"mode": "estimate",
|
|
13
|
+
"manualValue": 60000,
|
|
14
|
+
"lastFlippedAt": "…"
|
|
15
|
+
}
|
|
16
|
+
// formula-owned: NO value key — the server recompute authors it
|
|
17
|
+
"totalProjectBudget": { "kind": "estimate", "mode": "formula" }
|
|
18
|
+
|
|
19
|
+
// amount-or-percent (control "amount-or-percent")
|
|
20
|
+
"initialDisbursement": {
|
|
21
|
+
"kind": "amount-or-percent",
|
|
22
|
+
"value": 12500,
|
|
23
|
+
"mode": "percent",
|
|
24
|
+
"basis": "total_commitment"
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Wire `mode` uses settled names (`estimate`/`formula`, `amount`/`percent`).
|
|
29
|
+
Every other field persists bare. The shape is owned by ONE static
|
|
30
|
+
descriptor — `envelopesWire` in `src/plugins/envelopes/wire.ts` — imported
|
|
31
|
+
identically by the client plugin and the server codec (spec D7), so the
|
|
32
|
+
halves cannot drift.
|
|
33
|
+
|
|
34
|
+
## Encode: `encodeDirty(schema, dirty, { knownColumns, wire })`
|
|
35
|
+
|
|
36
|
+
```mermaid
|
|
37
|
+
flowchart TD
|
|
38
|
+
D["dirty values<br/>(pickDirty / getDirtyInput — envelope leaves already wrapped)"] --> K{"for each declared root key"}
|
|
39
|
+
K -- undeclared --> DROP["dropped (allow-list)"]
|
|
40
|
+
K -- declared --> SKIP{"wire.skipValue?"}
|
|
41
|
+
SKIP -- "formula (derivationWire)" --> DROP2["dropped — recompute is the author"]
|
|
42
|
+
SKIP -- no --> ENV{"envelope control?"}
|
|
43
|
+
ENV -- yes --> NORM["envelopesWire.encode:<br/>estimate not pinned estimate → strip value half;<br/>bare estimate value → dropped (LOS-461)"]
|
|
44
|
+
NORM --> BAG["data (envelope WHOLE)"]
|
|
45
|
+
NORM -- "x-column: true" --> MIRROR["columns (scalar value-half mirror)"]
|
|
46
|
+
ENV -- no --> COL{"x-column === true?"}
|
|
47
|
+
COL -- "yes, in knownColumns" --> C["columns"]
|
|
48
|
+
COL -- "yes, unknown column" --> DROP3["dropped (undeclared write path)"]
|
|
49
|
+
COL -- no --> BAG2["data"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Key rules:
|
|
53
|
+
|
|
54
|
+
- **The envelope is one bag key.** Partial writes clobber the other half, so
|
|
55
|
+
every emission is a COMPLETE envelope; whole-array posts wrap every
|
|
56
|
+
estimate/hybrid row leaf, dirty or not (`encodeScopeValues`).
|
|
57
|
+
- **Column-backed envelope fields**: the bag holds the envelope (source of
|
|
58
|
+
truth); the column gets a mirrored scalar for SQL/list pages. Decode prefers
|
|
59
|
+
the bag.
|
|
60
|
+
- The LOS-461 policy is unchanged, relocated: a formula value is always
|
|
61
|
+
server-recomputed; an estimate value persists exactly when its meta pins
|
|
62
|
+
`mode: "estimate"`.
|
|
63
|
+
|
|
64
|
+
## Decode
|
|
65
|
+
|
|
66
|
+
`decodeRecord(schema, record, { envelopes })` routes by `x-column` and passes
|
|
67
|
+
envelopes through whole; the walk unwraps the value half at each leaf
|
|
68
|
+
(`unwrapLeafInput`) and the envelopes plugin takes the meta half — see the
|
|
69
|
+
fork diagram atop `src/core/codec/decode-record.ts` and
|
|
70
|
+
[decode-fork.md](./decode-fork.md).
|