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:
- On load,
card.number is red (correct — method is card).
- Edit
payment.method → cash. 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.
- Collapse and re-expand
card — the red clears with no data change.
- 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 customNodeDefinitions → Object.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.
Summary
Validating (or filtering, or conditionally rendering) inside a render-time function of
NodeDatathat readsfullDatais 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/utilssolution (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
createUpdateValidatorcommit-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, AJVif/then, etc. But under fine-grained re-rendering B never re-renders when A changes, so any function pinned to B that readsfullDatanever 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.memocomparator in memoNode.ts deliberately ignoresfullDataidentity (its doc comment names this exact hazard), because keying re-renders offfullDatawould re-render the whole tree on every keystroke and defeat the §16 performance work.Reproduction
There's a
devOnlydemo scratchpad at demo/src/examples/static/validation-staleness/Example.tsx (registry keyvalidation-staleness). It uses an AJV schema wherecard.numbermust beminLength: 16only whilepayment.method === 'card', and runs the validator inside a style function:card.numberis red (correct — method iscard).payment.method→cash. 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.card— the red clears with no data change.methodback tocard:numberis 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
datachange, 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 treeRun the consumer's validator once per
datachange, normalize the issues, and expose an O(1)-queryable state:Internals: one pass over the normalized issues builds a
Map<pathString, ValidationIssue[]>(backshasErrorAt/errorsAt) and aSetof every ancestor prefix (backshasErrorWithin, so a collapsed parent can show "something's invalid in here"). Path-string keying usestoPathString-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-nodeconditiontoo. Each has a different channel that pierces the memo:React.memotree-wide (see the memo comment inThemeProvider).validationStyles(validation, css?)is thin sugar: a partial theme whose slot functions consulthasErrorAt(and optionallyhasErrorWithinfor collection slots), composed astheme={[myTheme, validationStyles(validation)]}. (Preset wrinkle: theme styles are inline, so aallow*andcustomNodeDefinitions→Object.is-compared node props.JsonEditorthreads consumer identity straight through (e.g.allowEditFilteris memoized onallowEdit), 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 atomThe 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
useValidationStatestays 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
createUpdateValidatorcommit-gate adapter —errorsAt(path)[0]?.messageis 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
@json-edit-react/utils(design notes currently inpackages/utils/IDEAS.md).useValidationState,validationStyles,useStableValue(andvalidation.filterconvenience), sharing a kernel withcreateUpdateValidator.Related: #197 (origin), #285, #319, #343, #307.