Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/utils-validation-state.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions demo/src/examples/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ const allExamples: Record<string, ExampleDef> = {
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)',
Expand Down
79 changes: 79 additions & 0 deletions demo/src/examples/static/validation-flagging/Example.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p style={{ padding: '0.6em 1em', fontFamily: 'monospace' }}>
Document is currently:{' '}
<strong style={{ color: validation.isValid ? 'green' : 'firebrick' }}>
{validation.isValid ? 'VALID ✓' : 'INVALID ✗'}
</strong>
</p>
<JsonEditor
data={data}
setData={setData}
{...useExampleProps()} // ---cut---
rootName="order"
theme={theme}
/>
</div>
)
}
24 changes: 24 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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 <JsonEditor data={data} setData={setData} theme={theme} />
}
```

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
5 changes: 5 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -20,3 +23,5 @@ export * from './_common/events'

export * from './confirm-update'
export * from './undo'
export * from './stable-value'
export * from './validation'
27 changes: 27 additions & 0 deletions packages/utils/src/stable-value/README.md
Original file line number Diff line number Diff line change
@@ -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<T>(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.
36 changes: 36 additions & 0 deletions packages/utils/src/stable-value/deepEqual.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
const objB = b as Record<string, unknown>
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
}
1 change: 1 addition & 0 deletions packages/utils/src/stable-value/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useStableValue } from './useStableValue'
47 changes: 47 additions & 0 deletions packages/utils/src/stable-value/useStableValue.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(
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)
}
Loading
Loading