Skip to content

Reactive validation hook (useValidationState): cross-branch staleness in render-time NodeData functions #357

Description

@CarlosNZ

Summary

Validating (or filtering, or conditionally rendering) inside a render-time function of NodeData that reads fullData is eventually-consistent by design, and silently goes stale under fine-grained re-rendering. This issue captures the problem, a reproducible demo, and a proposed @json-edit-react/utils solution (useValidationState + validationStyles + useStableValue) so it can be designed in the open.

Origin: #197 (flag errors rather than rejecting edits). This is the reactive validation angle — it complements #285 (preventive: stop invalid entry) and the createUpdateValidator commit-gate (catch on commit). Uniquely among the three, it also covers data that was already invalid when loaded.

The problem

Document validity is a whole-document property. An edit at node A can change the validity of node B on a different branch — discriminated unions, dependentRequired, AJV if/then, etc. But under fine-grained re-rendering B never re-renders when A changes, so any function pinned to B that reads fullData never re-runs. The styling/filtering/condition lags reality until something else forces B to re-render.

This is by design, not a bug to fix in core: the React.memo comparator in memoNode.ts deliberately ignores fullData identity (its doc comment names this exact hazard), because keying re-renders off fullData would re-render the whole tree on every keystroke and defeat the §16 performance work.

Reproduction

There's a devOnly demo scratchpad at demo/src/examples/static/validation-staleness/Example.tsx (registry key validation-staleness). It uses an AJV schema where card.number must be minLength: 16 only while payment.method === 'card', and runs the validator inside a style function:

  1. On load, card.number is red (correct — method is card).
  2. Edit payment.methodcash. The banner above the editor (recomputed every commit) flips to valid, but the node stays red — it never re-rendered, so its style function never re-ran.
  3. Collapse and re-expand card — the red clears with no data change.
  4. Edit method back to card: number is invalid again, but no red appears until something forces that node to re-render.

The banner tells the truth; the node styling lies. A naive or lookup-based style function has the same problem — the issue is when the function re-runs, not how fast it is.

Proposed solution

The strategy is identity keyed on the error set: compute the validation result once per data change, but route a changed object identity through whatever channel pierces the memo boundary for the prop in question — and keep that identity referentially stable while the error set is unchanged, so the common valid→valid commit keeps the full memo boundary intact.

1. useValidationState — an error-state index over the whole tree

Run the consumer's validator once per data change, normalize the issues, and expose an O(1)-queryable state:

interface ValidationIssue {
  path: CollectionKey[]   // normalized location; [] = the document root
  message: string         // human-readable, produced by the adapter
  keyword?: string        // lib-specific code ('type', 'required', 'pattern', …)
  raw?: unknown           // the library's original error object — escape hatch
}

interface ValidationState {
  isValid: boolean
  errors: ValidationIssue[]                               // all issues, document order
  hasErrorAt: (path: CollectionKey[]) => boolean          // exact node — style-function hot path
  errorsAt: (path: CollectionKey[]) => ValidationIssue[]  // tooltips / summary panels / gate messages
  hasErrorWithin: (path: CollectionKey[]) => boolean      // node OR any descendant — ancestor marking
}

Internals: one pass over the normalized issues builds a Map<pathString, ValidationIssue[]> (backs hasErrorAt/errorsAt) and a Set of every ancestor prefix (backs hasErrorWithin, so a collapsed parent can show "something's invalid in here"). Path-string keying uses toPathString-style escaping (keys can contain .).

The key move: the hook deep-compares each run's normalized error set against the previous (it's small — paths + messages) and returns a referentially stable object when nothing changed. Valid→valid commits (the overwhelming majority) keep the §16 memo boundary; when validity does change, the identity changes, the tree re-renders once — correctly restyling cross-branch nodes — and each render is an O(1) lookup, not a re-validation. This also kills the naive version's O(N²) mount (N nodes × whole-doc validation) and the per-search-keystroke validations.

2. Route the new identity through the right channel, per prop

The staleness isn't unique to styles — it hits allow* filters and custom-node condition too. Each has a different channel that pierces the memo:

  • Styles → theme identity. A theme context update pierces React.memo tree-wide (see the memo comment in ThemeProvider). validationStyles(validation, css?) is thin sugar: a partial theme whose slot functions consult hasErrorAt (and optionally hasErrorWithin for collection slots), composed as theme={[myTheme, validationStyles(validation)]}. (Preset wrinkle: theme styles are inline, so a ⚠️ indicator preset means a background-image data-URI SVG, not a pseudo-element glyph; red text/border presets are trivial.)
  • allow* and customNodeDefinitionsObject.is-compared node props. JsonEditor threads consumer identity straight through (e.g. allowEditFilter is memoized on allowEdit), so a function memoized on the validation state re-renders the tree exactly when the answer changes: useMemo(() => (nd) => !validation.hasErrorWithin(nd.path), [validation]). Worth a convenience on the hook, e.g. validation.filter(fn) doing the memo internally.
  • condition → memoize the definitions array on the state — same component references inside, so conditions re-evaluate without remounting (the supported switching path).

3. useStableValue(compute, deps, isEqual?) — the shared atom

The identity stabilizer underneath all of the above: recompute per data change, but return the previous reference while the result is deep-equal. It's how useValidationState stays stable across valid→valid commits — exposed standalone so any cross-branch condition (validation or not) can apply the same discipline to whichever channel fits.

Shared kernel

The issue-normalization layer (run validator → normalize issues → match against a node's path) is shared with the createUpdateValidator commit-gate adapter — errorsAt(path)[0]?.message is literally the gate message. Adapter fiddliness the kernel owns: AJV reports missing properties at the parent path (params.missingProperty) while Zod puts the missing key in the path — both normalize to the parent collection path (the missing child has no node to style). Validators stay type-only deps (the consumer hands over a constructed Zod schema / compiled AJV function) → zero runtime deps.

Scope / status

  • Lives in @json-edit-react/utils (design notes currently in packages/utils/IDEAS.md).
  • Not yet implemented — this is a design to review.
  • Deliverables: useValidationState, validationStyles, useStableValue (and validation.filter convenience), sharing a kernel with createUpdateValidator.

Related: #197 (origin), #285, #319, #343, #307.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds discussionutilsPossible "utility" helpers for the `@json-edit-react/utils` pacakge

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions