diff --git a/.changeset/utils-validation-state.md b/.changeset/utils-validation-state.md new file mode 100644 index 00000000..44b6200c --- /dev/null +++ b/.changeset/utils-validation-state.md @@ -0,0 +1,11 @@ +--- +'@json-edit-react/utils': minor +--- + +Add reactive validation helpers — `useValidationState`, `validationStyles`, `ajvAdapter`, and the `useStableValue` primitive they build on. + +`useValidationState(data, validate)` runs a validator over the whole document and returns a queryable, identity-stable error index: `isValid`, `errors`, and the O(1) lookups `hasErrorAt(path)` / `errorsAt(path)` / `hasErrorWithin(path)`. It targets json-edit-react's fine-grained re-rendering: validating inline in a style function / `allow*` filter / custom-node `condition` goes stale when an edit on one node changes the validity of a node on *another* branch (which doesn't re-render). The hook ties the result's identity to the error set, so memoizing a `theme` / `customNodeDefinitions` / `allow*` value on it re-renders the tree exactly when validity changes — and never on a valid→valid commit. The validator runs once per `data` change (not per node, not per keystroke), so the whole-document cost is O(N), not the O(N²) of validating inside every node's render. + +`validationStyles(validation, options?)` is theme sugar: a partial theme whose leaf slots flag `hasErrorAt` nodes (and, with `within`, collection ancestors via `hasErrorWithin`) — compose it as `theme={[myTheme, validationStyles(validation)]}`. `ajvAdapter(ajv.compile(schema))` adapts a compiled AJV validate function to the `Validate` contract, normalizing `instancePath` JSON-Pointers into node paths and keeping `required` errors at the parent path. You bring your own validator (or pass any `(data) => ValidationIssue[]`), so the package takes no third-party runtime dependency — not even on AJV. + +`useStableValue(compute, deps, isEqual?)` is exported standalone: like `useMemo`, but it returns the previous reference while the computed value is equal, so any cross-branch derived value (validation, duplicate detection, a doc-wide total) can drive a memo-piercing channel without re-rendering the tree on every commit. diff --git a/demo/src/examples/registry.ts b/demo/src/examples/registry.ts index 88cbf387..0b70c9e0 100644 --- a/demo/src/examples/registry.ts +++ b/demo/src/examples/registry.ts @@ -81,6 +81,15 @@ const allExamples: Record = { load: () => import('./static/swap-the-built-ins/Example'), code: () => import('./static/swap-the-built-ins/Example.tsx?raw'), }, + 'validation-flagging': { + kind: 'static', + title: 'Validation flagging (dev)', + blurb: + "Reactive validation with `useValidationState` (from `@json-edit-react/utils`). An AJV schema links `payment.method` to `card.number` (`minLength: 16` only while method is `card`), so editing `method` flips the validity of a node on another branch. The hook runs the validator once per change, and `validationStyles` flags invalid nodes — restyling cross-branch correctly because the hook's identity changes exactly when validity does. This is the fix for the sibling “Validation staleness” gotcha.", + load: () => import('./static/validation-flagging/Example'), + code: () => import('./static/validation-flagging/Example.tsx?raw'), + devOnly: true, + }, 'validation-staleness': { kind: 'static', title: 'Validation staleness (dev)', diff --git a/demo/src/examples/static/validation-flagging/Example.tsx b/demo/src/examples/static/validation-flagging/Example.tsx new file mode 100644 index 00000000..994700b0 --- /dev/null +++ b/demo/src/examples/static/validation-flagging/Example.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react' +import { JsonEditor } from '@json-edit-react' +import { useValidationState, validationStyles, ajvAdapter } from '@json-edit-react/utils' +import Ajv from 'ajv' +import { useExampleTheme, useExampleProps } from '../../kit/exampleProps' // ---cut--- + +// The fix for the cross-branch staleness shown in the sibling "Validation +// staleness" example. Same constraint: `card.number` must be ≥16 chars, but +// only while `payment.method` is 'card'. Editing `method` changes the validity +// of a node on a *different* branch — `useValidationState` restyles it +// correctly, because its result identity changes exactly when validity does. +const schema = { + type: 'object', + properties: { + payment: { type: 'object', properties: { method: { enum: ['cash', 'card'] } } }, + card: { type: 'object', properties: { number: { type: 'string' } } }, + }, + if: { properties: { payment: { properties: { method: { const: 'card' } } } } }, + then: { properties: { card: { properties: { number: { minLength: 16 } } } } }, +} + +// Compile the validator once and wrap it for the hook — `ajvAdapter` normalises +// AJV's errors into the `{ path, message }[]` the hook consumes. +const ajv = new Ajv({ allErrors: true }) +const validate = ajvAdapter(ajv.compile(schema)) + +const initialData = { + payment: { method: 'card' }, + card: { number: '' }, // invalid while method === 'card' +} + +// Try it: +// 1. On load, `card.number` is flagged (method is 'card', number too short). +// 2. Edit `payment.method` → 'cash'. The flag clears *immediately* — even +// though `card.number` is on another branch and its own value didn't +// change. (Collapse `card` first and you'll still see the parent marked.) +// 3. Edit it back to 'card': the flag returns at once. No collapse/re-expand +// needed — the hook re-renders the tree exactly when validity changes. +export default function ValidationFlagging() { + const [data, setData] = useState(initialData) + const baseTheme = useExampleTheme() + + // One hook: re-validates per data change, queryable in O(1), and + // referentially stable until the error set actually changes. + const validation = useValidationState(data, validate) + + // Compose the error styling over the host theme. Memoising on `validation` + // (not an inline array) is what keeps the tree from re-rendering every commit + // — and what lets it re-render, and restyle cross-branch nodes, when validity + // flips. + const theme = useMemo( + () => [ + baseTheme, + validationStyles(validation, { + error: { backgroundColor: 'firebrick', color: 'white' }, + within: { backgroundColor: 'rgba(178, 34, 34, 0.08)' }, + }), + ], + [baseTheme, validation] + ) + + return ( +
+

+ Document is currently:{' '} + + {validation.isValid ? 'VALID ✓' : 'INVALID ✗'} + +

+ +
+ ) +} diff --git a/packages/utils/README.md b/packages/utils/README.md index d7191387..3020dedb 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -20,6 +20,7 @@ pnpm add @json-edit-react/utils - **Confirm-before-update hooks** — gate edits/deletes on a confirmation dialog without hand-rolling the deferred-promise dance. _Available now._ ([#307](https://github.com/CarlosNZ/json-edit-react/issues/307)) - **Undo / redo** — wrap a consumer-owned `data`/`setData` pair with undo/redo (snapshot stacks plus `canUndo` / `canRedo`), zero-dep. _Available now._ +- **Reactive validation** — `useValidationState` runs your validator over the whole document and exposes an O(1), identity-stable error index, so styles / filters / conditions reflect validity correctly even for cross-branch effects. Ships `validationStyles` (theme sugar), `ajvAdapter`, and the `useStableValue` primitive it's built on. Zero-dep (you bring your own validator). _Available now._ ([#357](https://github.com/CarlosNZ/json-edit-react/issues/357)) - **JSON Schema → Filter Functions** — generate `allowEdit` / `allowDelete` / `allowAdd` (etc.) functions from a JSON Schema so the editor UI can't produce invalid data in the first place. _Planned._ ([#285](https://github.com/CarlosNZ/json-edit-react/issues/285)) - **Search helpers** — ready-made `searchFilter` functions for common search patterns. _Planned._ ([#319](https://github.com/CarlosNZ/json-edit-react/issues/319)) @@ -136,6 +137,29 @@ const MyEditor = () => { **Loading a new dataset:** call `reset(newData)`, not `setData` — the hook only sees changes that go through its own API, so `reset` is how you swap the document and clear stale history in one step. See [src/undo/README.md](src/undo/README.md) for the full rationale. +## Reactive validation + +`useValidationState` runs a validator over the whole document and returns a queryable, identity-stable error index (`isValid`, `errors`, `hasErrorAt`, `errorsAt`, `hasErrorWithin`). It's designed for json-edit-react's fine-grained re-rendering: validating inline in a style function goes stale when an edit on one node changes the validity of a node on *another* branch (which doesn't re-render). This hook ties the result's identity to the error set, so memoizing a `theme` / `customNodeDefinitions` / `allow*` value on it re-renders the tree exactly when validity changes — and never on a valid→valid commit. + +```tsx +import { useMemo, useState } from 'react' +import { JsonEditor } from 'json-edit-react' +import { useValidationState, validationStyles, ajvAdapter } from '@json-edit-react/utils' +import Ajv from 'ajv' + +const validate = ajvAdapter(new Ajv({ allErrors: true }).compile(schema)) + +const MyEditor = () => { + const [data, setData] = useState(initialData) + const validation = useValidationState(data, validate) + const theme = useMemo(() => [myTheme, validationStyles(validation)], [validation]) + + return +} +``` + +You bring your own validator (`ajvAdapter` wraps a compiled AJV function; or pass any `(data) => ValidationIssue[]`), so the package stays zero-dependency. See [src/validation/README.md](src/validation/README.md) for the consumption recipes (styles, a glyph via a custom node, `allow*` gating) and [src/stable-value/README.md](src/stable-value/README.md) for `useStableValue`, the identity-stabilizer it's built on. + ## License MIT diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e360746c..0b2a5459 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,6 +9,9 @@ // - Confirm-before-update hooks — useJsonEditorConfirm / useConfirmOnUpdate // https://github.com/CarlosNZ/json-edit-react/issues/307 // - Undo / redo hook — useUndo +// - Reactive validation — useValidationState / validationStyles / +// ajvAdapter, plus the useStableValue primitive they build on +// https://github.com/CarlosNZ/json-edit-react/issues/357 // - JSON Schema → Filter Functions generator [planned] // https://github.com/CarlosNZ/json-edit-react/issues/285 // - Ready-made `searchFilter` helpers for common search use cases [planned] @@ -20,3 +23,5 @@ export * from './_common/events' export * from './confirm-update' export * from './undo' +export * from './stable-value' +export * from './validation' diff --git a/packages/utils/src/stable-value/README.md b/packages/utils/src/stable-value/README.md new file mode 100644 index 00000000..508243da --- /dev/null +++ b/packages/utils/src/stable-value/README.md @@ -0,0 +1,27 @@ +# Stable identity — `useStableValue` + +Like `useMemo`, but it returns the **previous reference** whenever the freshly computed value is equal to the last one — so the identity changes only when the *value* genuinely changes, not merely when `deps` change. Zero dependencies. + +```ts +useStableValue(compute: () => T, deps: DependencyList, isEqual?: (prev: T, next: T) => boolean): T +``` + +`isEqual` defaults to a structural deep-equal (early-exit on first difference); pass your own when the value has a cheaper key or fields that shouldn't be compared. + +## Why it exists + +json-edit-react re-renders the tree when a memo-piercing prop changes identity — `theme`, `customNodeDefinitions`, an `allow*` filter. To drive that channel from a value derived off the whole document (validation errors, duplicate detection, a doc-wide total), you want to recompute per `data` change but hand the editor the *same* reference until the derived value actually differs: a stable identity keeps the §16 node-memo boundary intact across no-op commits, and the identity flips — re-rendering the tree once — exactly when the value changes (including cross-branch effects no single node would re-render for). + +Plain `useMemo(() => derive(data), [data])` can't do this: `data` changes every commit, so the memo yields a fresh identity every commit, re-rendering the whole tree on every keystroke. + +```tsx +const duplicates = useStableValue(() => findDuplicatePaths(data), [data]) +const theme = useMemo(() => [base, highlightDuplicates(duplicates)], [duplicates]) +// the tree re-renders only when the set of duplicate paths changes +``` + +[`useValidationState`](../validation) is built on this; reach for `useStableValue` directly for any other cross-branch derived condition. + +## Note on the comparison + +`isEqual` compares the **computed result**, not `data` — so keep the result small (a derived summary: an error set, a list of paths, a total), and the compare stays cheap regardless of document size. Returning the whole document as the result is the one way to make it expensive. diff --git a/packages/utils/src/stable-value/deepEqual.ts b/packages/utils/src/stable-value/deepEqual.ts new file mode 100644 index 00000000..988cedd3 --- /dev/null +++ b/packages/utils/src/stable-value/deepEqual.ts @@ -0,0 +1,36 @@ +// Structural deep-equality with early-exit on the first difference. The default +// comparator for `useStableValue`. Hand-rolled to keep the package free of +// runtime dependencies (core has none and neither does this package). +// +// It handles the JSON-shaped values these helpers compare: primitives (via +// `Object.is`, so `NaN` equals `NaN` and `+0`/`-0` differ), arrays (length then +// element-wise), and plain objects (same own-key set, then value-wise). It is +// deliberately not structural-clone-grade — Map/Set/Date/RegExp/typed arrays +// aren't special-cased because they don't appear in the values it compares. +export const deepEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true + + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) + return false + + const aIsArray = Array.isArray(a) + if (aIsArray !== Array.isArray(b)) return false + + if (aIsArray) { + const arrA = a as unknown[] + const arrB = b as unknown[] + if (arrA.length !== arrB.length) return false + for (let i = 0; i < arrA.length; i++) if (!deepEqual(arrA[i], arrB[i])) return false + return true + } + + const objA = a as Record + const objB = b as Record + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) return false + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(objB, key)) return false + if (!deepEqual(objA[key], objB[key])) return false + } + return true +} diff --git a/packages/utils/src/stable-value/index.ts b/packages/utils/src/stable-value/index.ts new file mode 100644 index 00000000..43c47e37 --- /dev/null +++ b/packages/utils/src/stable-value/index.ts @@ -0,0 +1 @@ +export { useStableValue } from './useStableValue' diff --git a/packages/utils/src/stable-value/useStableValue.ts b/packages/utils/src/stable-value/useStableValue.ts new file mode 100644 index 00000000..5716ff1d --- /dev/null +++ b/packages/utils/src/stable-value/useStableValue.ts @@ -0,0 +1,47 @@ +import { useMemo, useRef, type DependencyList } from 'react' +import { deepEqual } from './deepEqual' + +/** + * Like `useMemo`, but returns the *previous* reference whenever the freshly + * computed value is equal to it — so the identity changes only when the value + * genuinely changes, not merely when `deps` change. + * + * This is the identity-stabilizer that lets a value derived from the whole + * document (validation errors, duplicate detection, a doc-wide total) drive a + * memo-piercing channel in json-edit-react without re-rendering the tree on + * every commit. Recompute per `data` change, but hand the editor the same + * reference until the result actually differs: a stable `theme` / + * `customNodeDefinitions` / `allow*` identity keeps the §16 node-memo boundary + * intact, and the identity flips — re-rendering the tree once — exactly when + * the derived value changes (including cross-branch effects that no single + * node would otherwise re-render for). + * + * `useValidationState` is built on this; reach for it directly for any other + * cross-branch derived condition: + * + * ```tsx + * const duplicates = useStableValue(() => findDuplicatePaths(data), [data]) + * const theme = useMemo(() => [base, highlight(duplicates)], [duplicates]) + * // the tree re-renders only when the set of duplicates changes + * ``` + * + * `isEqual` defaults to a structural deep-equal; pass your own when the value + * has a cheaper key or fields that shouldn't be compared. + */ +export const useStableValue = ( + compute: () => T, + deps: DependencyList, + isEqual: (prev: T, next: T) => boolean = deepEqual +): T => { + // The ref is the real stability anchor: even if React discards the memo and + // recomputes with unchanged deps, an equal result returns the same ref. + const ref = useRef<{ value: T } | null>(null) + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => { + const next = compute() + if (ref.current && isEqual(ref.current.value, next)) return ref.current.value + ref.current = { value: next } + return next + }, deps) +} diff --git a/packages/utils/src/validation/README.md b/packages/utils/src/validation/README.md new file mode 100644 index 00000000..3311f402 --- /dev/null +++ b/packages/utils/src/validation/README.md @@ -0,0 +1,76 @@ +# Reactive validation — `useValidationState` + +Run a validator over the whole document and get back a queryable, identity-stable error index. Style functions, `allow*` filters, and custom-node `condition`s can then ask "is this node invalid?" in O(1) — and, crucially, get the **right answer for cross-branch effects** that fine-grained re-rendering would otherwise leave stale. Zero runtime dependencies (validators are the consumer's own, passed in). + +This is the reactive complement to schema-driven prevention ([#285](https://github.com/CarlosNZ/json-edit-react/issues/285)) and a commit-time gate: it flags problems as they exist in the data, including data that was **already invalid when loaded**. Origin: [#197](https://github.com/CarlosNZ/json-edit-react/issues/197), design discussion in [#357](https://github.com/CarlosNZ/json-edit-react/issues/357). + +## The problem it solves + +Document validity is a whole-document property: an edit at one node can flip the validity of another node on a *different branch* (JSON Schema `if/then`, `dependentRequired`, discriminated unions). Under json-edit-react's fine-grained re-rendering that other node doesn't re-render when you edit the first one, so validating inline inside its style function / filter / condition shows a stale result until something else forces it to re-render. `useValidationState` fixes this by tying the result's *identity* to the error set, so the consumer can drive a memo-piercing channel (theme, `customNodeDefinitions`, `allow*`) that re-renders the tree exactly when validity changes — and never on a valid→valid commit. + +## Quick start — flag invalid nodes red + +`validationStyles` turns the state into a partial theme; compose it over your own and memoize on the validation object: + +```tsx +import { useMemo, useState } from 'react' +import { JsonEditor } from 'json-edit-react' +import { useValidationState, validationStyles, ajvAdapter } from '@json-edit-react/utils' +import Ajv from 'ajv' + +const ajv = new Ajv({ allErrors: true }) +const validate = ajvAdapter(ajv.compile(schema)) // compile once, module scope + +const MyEditor = () => { + const [data, setData] = useState(initialData) + + const validation = useValidationState(data, validate) + const theme = useMemo(() => [myTheme, validationStyles(validation)], [validation]) + + return +} +``` + +`[validation]` — not an inline array literal — is what keeps the tree from re-rendering on every keystroke; the validation object only changes identity when the error set actually changes. + +## Consumption recipes + +The state is just a queryable object, so it drives any render-time channel: + +- **Styles (recommended for "go red"):** `validationStyles(validation, { error, within })` — leaf slots paint `hasErrorAt` nodes; pass `within` to mark collection ancestors via `hasErrorWithin`. Inline styles only (color/border/etc.), so no pseudo-elements. +- **A glyph / icon / tooltip:** a custom-node component that wraps `originalNode` and appends content when `validation.hasErrorAt(nodeData.path)`. Memoize `customNodeDefinitions` on `[validation]`. (See [#358](https://github.com/CarlosNZ/json-edit-react/issues/358).) +- **Disable editing of invalid subtrees:** `allowEdit={useMemo(() => (nd) => !validation.hasErrorWithin(nd.path), [validation])}`. +- **A document-level banner / disabled Save button:** read `validation.isValid` and `validation.errors` directly. + +## API + +`useValidationState(data, validate)` → `ValidationState`: + +| Member | Type | Behaviour | +| --- | --- | --- | +| `isValid` | `boolean` | True when there are no issues. | +| `errors` | `ValidationIssue[]` | Every issue, in the order the validator produced them. | +| `hasErrorAt` | `(path) => boolean` | Is there an issue at *exactly* this node? The style-function hot path. | +| `errorsAt` | `(path) => ValidationIssue[]` | Issues at exactly this node — for tooltips, messages, summaries. | +| `hasErrorWithin` | `(path) => boolean` | Issue at this node **or any descendant** — for ancestor marking. | + +`validate` is a `Validate` — `(data) => ValidationIssue[]`. Wrap a library validator with an adapter, or write one inline: + +```ts +const validate: Validate = (data) => + data.total > budget ? [{ path: ['total'], message: 'over budget', keyword: 'max' }] : [] +``` + +A `ValidationIssue` is `{ path, message, keyword?, raw? }`, where `path` is the node location (`[]` is the root) and `raw` is the library's original error object (an escape hatch). + +### `ajvAdapter` + +`ajvAdapter(ajv.compile(schema))` returns a `Validate`. Compile AJV with `allErrors: true` to surface everything at once. It normalizes AJV's `instancePath` JSON-Pointers into node paths (decoding `~0`/`~1`, coercing array indices to numbers) and keeps `required` errors at the parent path (the missing child has no node to style) with the property named in the message. The package takes **no `ajv` dependency** — the adapter is typed against AJV's error *shape*, so you bring your own AJV. + +## Performance + +`validate` runs once per `data` change (which, in json-edit-react, is per commit — not per keystroke), and every lookup after that is an O(1) map/set hit. The state object's reference is held stable while the error set is unchanged, so valid→valid commits keep the §16 node-memo boundary fully intact; only a genuine change in validity re-renders the tree, once. + +## `useStableValue` + +The identity-stabilizer `useValidationState` is built on is exported in its own right — see the [stable-value README](../stable-value) — for any other cross-branch derived value (duplicate detection, doc-wide totals) you want to drive a memo-piercing channel with. diff --git a/packages/utils/src/validation/adapters/ajv.ts b/packages/utils/src/validation/adapters/ajv.ts new file mode 100644 index 00000000..30704c68 --- /dev/null +++ b/packages/utils/src/validation/adapters/ajv.ts @@ -0,0 +1,67 @@ +import type { ValidationIssue, Validate } from '../types' + +/** + * The slice of an AJV error object this adapter reads. Declared structurally so + * the package needs no `ajv` dependency — not even a type-only one: a real + * compiled AJV validate function and its errors satisfy this shape. + */ +export interface AjvErrorLike { + instancePath: string + message?: string + keyword: string + params?: { missingProperty?: string } +} + +/** A compiled AJV validate function (structural — see `AjvErrorLike`). */ +export interface AjvValidateFunction { + (data: unknown): boolean + errors?: AjvErrorLike[] | null +} + +// Parse an AJV `instancePath` — a JSON Pointer like `/payment/method` or +// `/items/0` — into the canonical path array. Empty pointer → `[]` (root). +// Decodes the JSON-Pointer escapes (`~1` → `/`, then `~0` → `~`, per RFC 6901) +// and coerces all-digit segments to numbers (array indices). +const pointerToPath = (pointer: string): (string | number)[] => { + if (pointer === '') return [] + return pointer + .split('/') + .slice(1) + .map((segment) => { + const decoded = segment.replace(/~1/g, '/').replace(/~0/g, '~') + return /^\d+$/.test(decoded) ? Number(decoded) : decoded + }) +} + +/** + * Adapt a compiled AJV validate function (`ajv.compile(schema)`) to the + * `Validate` contract `useValidationState` consumes. Compile AJV with + * `allErrors: true` to surface every problem at once. + * + * ```tsx + * const ajv = new Ajv({ allErrors: true }) + * const validate = ajvAdapter(ajv.compile(schema)) + * const validation = useValidationState(data, validate) + * ``` + * + * `required` errors are reported by AJV at the *parent* object's path with the + * missing key in `params.missingProperty`. The parent path is kept (the missing + * child has no node to style) and the property name is folded into the message. + */ +export const ajvAdapter = + (validate: AjvValidateFunction): Validate => + (data) => { + validate(data) + return (validate.errors ?? []).map((error): ValidationIssue => { + const message = + error.keyword === 'required' && error.params?.missingProperty + ? `Missing required property '${error.params.missingProperty}'` + : (error.message ?? 'Invalid') + return { + path: pointerToPath(error.instancePath), + message, + keyword: error.keyword, + raw: error, + } + }) + } diff --git a/packages/utils/src/validation/index.ts b/packages/utils/src/validation/index.ts new file mode 100644 index 00000000..db544438 --- /dev/null +++ b/packages/utils/src/validation/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export { useValidationState } from './useValidationState' +export { validationStyles, type ValidationStyleOptions } from './validationStyles' +export { ajvAdapter, type AjvErrorLike, type AjvValidateFunction } from './adapters/ajv' diff --git a/packages/utils/src/validation/types.ts b/packages/utils/src/validation/types.ts new file mode 100644 index 00000000..cc277f0f --- /dev/null +++ b/packages/utils/src/validation/types.ts @@ -0,0 +1,45 @@ +// A node location. Mirrors core's `NodeData['path']` (an array of object keys +// and array indices). `CollectionKey` isn't part of core's public API, so the +// element type is inlined here. +type Path = (string | number)[] + +/** + * One normalized validation problem. Adapters (e.g. `ajvAdapter`) map a + * validation library's native error into this shape; a hand-written normalizer + * can produce it directly. + */ +export interface ValidationIssue { + /** Normalized node location. `[]` is the document root. */ + path: Path + /** Human-readable message, produced by the adapter / normalizer. */ + message: string + /** Library-specific code, e.g. `'required'`, `'type'`, `'pattern'`. */ + keyword?: string + /** The library's original error object — an escape hatch; never compared. */ + raw?: unknown +} + +/** + * A normalized validator: runs over the whole document and returns the current + * set of issues (empty when valid). This is what `useValidationState` consumes + * — wrap an AJV validator with `ajvAdapter`, or write one inline. + */ +export type Validate = (data: unknown) => ValidationIssue[] + +/** + * The queryable validation index returned by `useValidationState`. Every lookup + * is O(1). The object's *reference* is stable while the error set is unchanged + * — see `useValidationState` for why that matters. + */ +export interface ValidationState { + /** True when there are no issues. */ + isValid: boolean + /** Every issue, in the order the normalizer produced them. */ + errors: ValidationIssue[] + /** Is there an issue at exactly this node? (the style-function hot path) */ + hasErrorAt: (path: Path) => boolean + /** Issues at exactly this node (for tooltips / messages / summaries). */ + errorsAt: (path: Path) => ValidationIssue[] + /** Is there an issue at this node OR any descendant? (ancestor marking) */ + hasErrorWithin: (path: Path) => boolean +} diff --git a/packages/utils/src/validation/useValidationState.ts b/packages/utils/src/validation/useValidationState.ts new file mode 100644 index 00000000..1e10bbb7 --- /dev/null +++ b/packages/utils/src/validation/useValidationState.ts @@ -0,0 +1,90 @@ +import { useMemo } from 'react' +import { useStableValue } from '../stable-value' +import type { ValidationIssue, ValidationState, Validate } from './types' + +// An injective path → string key for the internal index. Mirrors the scheme +// core uses for node IDs (encodeURIComponent per segment, '/'-joined, with a +// sentinel for the lone empty-string key) so keys containing '.' or '/' never +// collide. Kept local to preserve the package's type-only dependency on core. +const toKey = (path: (string | number)[]): string => { + if (path.length === 1 && path[0] === '') return '\0' + return path.map((part) => encodeURIComponent(String(part))).join('/') +} + +// Compare issue lists by value, ignoring `raw` (which may be a large or cyclic +// library error object — never deep-recurse it). Two issues match when their +// path, message, and keyword match; lists match element-wise in order +// (validators emit issues deterministically for a given document). +const issuesEqual = (a: ValidationIssue[], b: ValidationIssue[]): boolean => { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if (x.message !== y.message || x.keyword !== y.keyword) return false + if (x.path.length !== y.path.length) return false + for (let j = 0; j < x.path.length; j++) if (x.path[j] !== y.path[j]) return false + } + return true +} + +// One pass over the issues builds the O(1) query surface. The Map (keyed by the +// injective `toKey`, which safely escapes keys containing `.` or `/`) backs +// exact-node lookups; the Set holds every ancestor prefix of every error path — +// including the path itself — so a collapsed parent can answer "something +// invalid in here". +const buildValidationState = (issues: ValidationIssue[]): ValidationState => { + const exact = new Map() + const within = new Set() + + for (const issue of issues) { + const key = toKey(issue.path) + const list = exact.get(key) + if (list) list.push(issue) + else exact.set(key, [issue]) + + for (let i = 0; i <= issue.path.length; i++) within.add(toKey(issue.path.slice(0, i))) + } + + return { + isValid: issues.length === 0, + errors: issues, + hasErrorAt: (path) => exact.has(toKey(path)), + errorsAt: (path) => exact.get(toKey(path)) ?? [], + hasErrorWithin: (path) => within.has(toKey(path)), + } +} + +/** + * Reactive, whole-document validation as a queryable, identity-stable index. + * + * Document validity is a whole-document property: an edit at one node can flip + * the validity of another node on a *different* branch (e.g. JSON Schema + * `if/then`, `dependentRequired`, discriminated unions). Under fine-grained + * re-rendering that other node never re-renders on its own, so validating + * inline in a style function / `allow*` filter / custom-node `condition` goes + * stale. This hook fixes that by tying the result's *identity* to the error + * set: + * + * - It runs `validate` once per `data` change (not per node, not per + * keystroke), so the whole-document cost is O(N), not the O(N²) of validating + * inside every node's render. + * - The returned object is **referentially stable while the error set is + * unchanged**. Memoize a `theme` / `customNodeDefinitions` / `allow*` value on + * it (`useMemo(() => …, [validation])`) and the §16 node-memo boundary stays + * intact across valid→valid commits; when validity actually changes, the new + * identity pierces `React.memo` through that channel and the tree re-renders + * once — correctly restyling cross-branch nodes. + * + * Lookups are O(1): `hasErrorAt(path)` for a node, `errorsAt(path)` for its + * messages, `hasErrorWithin(path)` for "this node or anything inside it". + * + * ```tsx + * const validation = useValidationState(data, ajvAdapter(ajv.compile(schema))) + * const theme = useMemo(() => [base, validationStyles(validation)], [validation]) + * // + * ``` + */ +export const useValidationState = (data: unknown, validate: Validate): ValidationState => { + const issues = useStableValue(() => validate(data), [data, validate], issuesEqual) + return useMemo(() => buildValidationState(issues), [issues]) +} diff --git a/packages/utils/src/validation/validationStyles.ts b/packages/utils/src/validation/validationStyles.ts new file mode 100644 index 00000000..6ee6af1c --- /dev/null +++ b/packages/utils/src/validation/validationStyles.ts @@ -0,0 +1,52 @@ +import type { CSSProperties } from 'react' +import type { NodeData, ThemeStyles } from 'json-edit-react' +import type { ValidationState } from './types' + +export interface ValidationStyleOptions { + /** Styles for a leaf value node that has an error. Defaults to red text. */ + error?: CSSProperties + /** + * Styles for a collection element containing an error somewhere inside it. + * Omitted by default — ancestor marking is opt-in. + */ + within?: CSSProperties +} + +const DEFAULT_ERROR: CSSProperties = { color: '#cb4b16' } + +/** + * Build a partial theme that flags invalid nodes, to compose over your own: + * `theme={[myTheme, validationStyles(validation)]}`. + * + * The leaf value slots (`string` / `number` / `boolean` / `null`) consult + * `hasErrorAt` per node; with the `within` option the `collectionElement` slot + * consults `hasErrorWithin` so a collapsed parent can show that something inside + * it is invalid (same mount-frontier blindness as search — only mounted nodes + * paint). Styles are inline, so this is colour/border/etc. only; for a glyph or + * icon use a custom-node component that wraps `originalNode`. + * + * Memoize it on the validation state so the tree re-renders only when validity + * changes: `useMemo(() => [base, validationStyles(v)], [v])`. + */ +export const validationStyles = ( + validation: ValidationState, + options: ValidationStyleOptions = {} +): ThemeStyles => { + const error = options.error ?? DEFAULT_ERROR + const { within } = options + + const leaf = (nodeData: NodeData) => (validation.hasErrorAt(nodeData.path) ? error : null) + + const styles: ThemeStyles = { + string: leaf, + number: leaf, + boolean: leaf, + null: leaf, + } + + if (within) + styles.collectionElement = (nodeData: NodeData) => + validation.hasErrorWithin(nodeData.path) ? within : null + + return styles +} diff --git a/test/stable-value.test.tsx b/test/stable-value.test.tsx new file mode 100644 index 00000000..51fd9223 --- /dev/null +++ b/test/stable-value.test.tsx @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react' +import { useStableValue } from '../packages/utils/src' + +describe('useStableValue', () => { + it('returns the previous reference when the recomputed value is deep-equal', () => { + const { result, rerender } = renderHook( + ({ value }: { value: number[] }) => useStableValue(() => value.map((x) => x * 2), [value]), + { initialProps: { value: [1, 2] } } + ) + + const first = result.current + expect(first).toEqual([2, 4]) + + // New `deps` reference (fresh array), but the computed result is equal — so + // the identity must NOT change. + rerender({ value: [1, 2] }) + expect(result.current).toBe(first) + }) + + it('returns a new reference when the recomputed value differs', () => { + const { result, rerender } = renderHook( + ({ value }: { value: number[] }) => useStableValue(() => value.map((x) => x * 2), [value]), + { initialProps: { value: [1, 2] } } + ) + + const first = result.current + rerender({ value: [1, 3] }) + expect(result.current).not.toBe(first) + expect(result.current).toEqual([2, 6]) + }) + + it('honors a custom isEqual comparator', () => { + // Always-equal: the identity should never change however the input moves. + const { result, rerender } = renderHook( + ({ value }: { value: number }) => useStableValue(() => ({ value }), [value], () => true), + { initialProps: { value: 1 } } + ) + + const first = result.current + rerender({ value: 99 }) + expect(result.current).toBe(first) + expect(result.current).toEqual({ value: 1 }) // the first computed value sticks + }) + + it('recomputes (new identity) only when deps actually change', () => { + const compute = jest.fn(({ n }: { n: number }) => ({ doubled: n * 2 })) + const { result, rerender } = renderHook( + (props: { n: number }) => useStableValue(() => compute(props), [props.n]), + { initialProps: { n: 2 } } + ) + + expect(compute).toHaveBeenCalledTimes(1) + const first = result.current + + // Same dep value → React skips the memo, compute is not called again. + rerender({ n: 2 }) + expect(compute).toHaveBeenCalledTimes(1) + expect(result.current).toBe(first) + + // Changed dep → recompute, new identity. + rerender({ n: 3 }) + expect(compute).toHaveBeenCalledTimes(2) + expect(result.current).toEqual({ doubled: 6 }) + }) +}) diff --git a/test/validation-state.test.tsx b/test/validation-state.test.tsx new file mode 100644 index 00000000..b188fe1e --- /dev/null +++ b/test/validation-state.test.tsx @@ -0,0 +1,222 @@ +import { useMemo, useState } from 'react' +import { render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { NodeData } from '../src' +import { JsonEditor } from '../src/JsonEditor' +import { + ajvAdapter, + useValidationState, + validationStyles, + type AjvValidateFunction, + type Validate, + type ValidationIssue, + type ValidationState, +} from '../packages/utils/src' + +// Cast a bare path into the NodeData a slot function reads (the slot only ever +// touches `.path`). +const nd = (path: (string | number)[]) => ({ path }) as unknown as NodeData + +describe('useValidationState — query surface', () => { + const issues: ValidationIssue[] = [ + { path: ['a', 'b'], message: 'b is bad', keyword: 'type' }, + { path: ['items', 0], message: 'index 0 bad', keyword: 'type' }, + { path: ['weird.key'], message: 'dotted key bad', keyword: 'type' }, + { path: [], message: 'root bad', keyword: 'required' }, + ] + const validate: Validate = () => issues + + it('answers hasErrorAt / errorsAt for exact nodes', () => { + const { result } = renderHook(() => useValidationState({}, validate)) + const v = result.current + + expect(v.isValid).toBe(false) + expect(v.errors).toHaveLength(4) + expect(v.hasErrorAt(['a', 'b'])).toBe(true) + expect(v.hasErrorAt(['a'])).toBe(false) // ancestor, not an exact match + expect(v.hasErrorAt(['items', 0])).toBe(true) // numeric index + expect(v.hasErrorAt([])).toBe(true) // document root + expect(v.errorsAt(['a', 'b'])[0].message).toBe('b is bad') + expect(v.errorsAt(['nope'])).toEqual([]) + }) + + it('distinguishes a key containing "." from a nested path', () => { + const { result } = renderHook(() => useValidationState({}, validate)) + const v = result.current + expect(v.hasErrorAt(['weird.key'])).toBe(true) + expect(v.hasErrorAt(['weird', 'key'])).toBe(false) + }) + + it('answers hasErrorWithin for a node or any descendant', () => { + const { result } = renderHook(() => useValidationState({}, validate)) + const v = result.current + expect(v.hasErrorWithin(['a'])).toBe(true) // ['a','b'] is inside + expect(v.hasErrorWithin(['a', 'b'])).toBe(true) // the node itself + expect(v.hasErrorWithin(['a', 'c'])).toBe(false) + expect(v.hasErrorWithin([])).toBe(true) // anything invalid → root has error within + expect(v.hasErrorWithin(['items'])).toBe(true) + }) +}) + +describe('useValidationState — identity stability (the §16 invariant)', () => { + const byFlag: Validate = (data) => + (data as { flag: boolean }).flag + ? [{ path: ['a'], message: 'bad', keyword: 'type' }] + : [] + + it('keeps a stable reference while the error set is unchanged, flips when it changes', () => { + const { result, rerender } = renderHook( + ({ data }: { data: object }) => useValidationState(data, byFlag), + { initialProps: { data: { flag: true, n: 0 } } } + ) + + const s1 = result.current + // New data, but the same issues → identity must NOT change (valid→valid, + // or here invalid→invalid: the memo boundary stays intact). + rerender({ data: { flag: true, n: 1 } }) + expect(result.current).toBe(s1) + + // Error set changes (invalid → valid) → new identity, ready to pierce. + rerender({ data: { flag: false, n: 1 } }) + expect(result.current).not.toBe(s1) + expect(result.current.isValid).toBe(true) + }) +}) + +describe('ajvAdapter', () => { + const makeValidate = (errors: AjvValidateFunction['errors']): AjvValidateFunction => + Object.assign(() => (errors ?? []).length === 0, { errors }) + + it('parses instancePath JSON-Pointers into canonical paths', () => { + const issues = ajvAdapter( + makeValidate([{ instancePath: '/payment/method', message: 'must be string', keyword: 'type' }]) + )({}) + expect(issues[0].path).toEqual(['payment', 'method']) + expect(issues[0].keyword).toBe('type') + expect(issues[0].message).toBe('must be string') + }) + + it('coerces numeric segments to numbers and maps the empty pointer to root', () => { + expect(ajvAdapter(makeValidate([{ instancePath: '/items/0', keyword: 'type' }]))({})[0].path).toEqual([ + 'items', + 0, + ]) + expect(ajvAdapter(makeValidate([{ instancePath: '', keyword: 'type' }]))({})[0].path).toEqual([]) + }) + + it('decodes JSON-Pointer escapes (~1 → /, ~0 → ~)', () => { + const issues = ajvAdapter(makeValidate([{ instancePath: '/a~1b/c~0d', keyword: 'type' }]))({}) + expect(issues[0].path).toEqual(['a/b', 'c~d']) + }) + + it('keeps a required error at the parent path and names the missing property', () => { + const issues = ajvAdapter( + makeValidate([ + { instancePath: '/payment', keyword: 'required', params: { missingProperty: 'method' } }, + ]) + )({}) + expect(issues[0].path).toEqual(['payment']) // parent — the missing child has no node + expect(issues[0].message).toContain('method') + }) + + it('exposes the raw error as an escape hatch', () => { + const error = { instancePath: '/x', keyword: 'type', message: 'nope' } + expect(ajvAdapter(makeValidate([error]))({})[0].raw).toBe(error) + }) +}) + +describe('validationStyles', () => { + const v: ValidationState = { + isValid: false, + errors: [], + hasErrorAt: (p) => p.length === 1 && p[0] === 'bad', + errorsAt: () => [], + hasErrorWithin: (p) => p.length === 0, + } + + it('styles only the leaf nodes that have an error', () => { + const styles = validationStyles(v, { error: { color: 'red' } }) + const stringFn = styles.string as unknown as (n: NodeData) => unknown + expect(stringFn(nd(['bad']))).toEqual({ color: 'red' }) + expect(stringFn(nd(['ok']))).toBeNull() + // the same leaf function backs every value slot + expect(styles.number).toBe(styles.string) + expect(styles.boolean).toBe(styles.string) + expect(styles.null).toBe(styles.string) + }) + + it('defaults to red text and omits ancestor marking unless asked', () => { + const styles = validationStyles(v) + expect((styles.string as unknown as (n: NodeData) => unknown)(nd(['bad']))).toEqual({ + color: '#cb4b16', + }) + expect(styles.collectionElement).toBeUndefined() + }) + + it('marks collection ancestors with hasErrorWithin when `within` is given', () => { + const styles = validationStyles(v, { within: { background: 'pink' } }) + const collFn = styles.collectionElement as unknown as (n: NodeData) => unknown + expect(collFn(nd([]))).toEqual({ background: 'pink' }) // root has an error within + expect(collFn(nd(['elsewhere']))).toBeNull() + }) +}) + +// The headline end-to-end regression: editing one node changes the validity of +// a node on a DIFFERENT branch. That other node bails on the commit (§16 memo +// boundary — see test/renderScope.test.tsx), so only the theme-identity pierce +// driven by useValidationState's stable-until-changed identity can restyle it. +describe('cross-branch staleness (end-to-end)', () => { + const RED = 'rgb(255, 0, 0)' + + // cardNumber must be ≥ 4 chars, but only while paying by card. Editing + // `method` flips cardNumber's validity from across the tree. + const crossBranch: Validate = (data) => { + const d = data as { method?: string; cardNumber?: unknown } + return d.method === 'card' && String(d.cardNumber ?? '').length < 4 + ? [{ path: ['cardNumber'], message: 'card number too short', keyword: 'minLength' }] + : [] + } + + const Host = ({ initial }: { initial: object }) => { + const [data, setData] = useState(initial) + const validation = useValidationState(data, crossBranch) + const theme = useMemo( + () => [validationStyles(validation, { error: { color: RED } })], + [validation] + ) + return + } + + it('clears a stale error on a cross-branch node when the edit makes it valid', async () => { + const user = userEvent.setup() + render() + + // Invalid on load: method is 'card' and the number is too short → red. + expect(screen.getByText('"12"')).toHaveStyle({ color: RED }) + + // Edit method 'card' → 'cash'. cardNumber's subtree bails on this commit. + await user.dblClick(screen.getByText('"card"')) + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'cash{Enter}') + await screen.findByText('"cash"') + + // It restyled anyway — the pierce reached the cross-branch node. + expect(screen.getByText('"12"')).not.toHaveStyle({ color: RED }) + }) + + it('applies an error to a cross-branch node when the edit makes it invalid', async () => { + const user = userEvent.setup() + render() + + // Valid on load (method is 'cash') → not red. + expect(screen.getByText('"12"')).not.toHaveStyle({ color: RED }) + + // Edit method 'cash' → 'card'. cardNumber is now invalid. + await user.dblClick(screen.getByText('"cash"')) + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'card{Enter}') + await screen.findByText('"card"') + + expect(screen.getByText('"12"')).toHaveStyle({ color: RED }) + }) +})