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.
@@ -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).