For the @json-edit-react/components package: export the pre-built custom-node definitions as factory functions rather than plain CustomNodeDefinition objects, so customizing them can never silently drop the built-in safety condition.
Supersedes the original mergeDefinitions helper proposal (preserved in the edit history). A helper is an opt-in convention — nothing stops consumers spreading the raw object and bypassing it. Since this package is unpublished, the export shape is still free to change, and factories make the safe merge policy the only door rather than a documented suggestion.
The problem
Consumers import a pre-built definition and usually want to tweak it — most commonly the condition, to target specific keys ("only treat description as Markdown"). The natural way to do that is to spread the definition and override condition — which silently drops the built-in guard. Markdown/definition.ts currently invites exactly this:
condition: ({ value }) => typeof value === 'string', // Over-ride this for specific cases
Follow that comment with condition: ({ key }) => key === 'description' and the first non-string value under that key throws inside react-markdown. DatePicker has the same shape (its ISO-regex condition is the guard that keeps the date parser safe). The footgun isn't hypothetical — it's the documented path.
As long as the definitions are exported plain objects, this can't be prevented: spread always type-checks, branding doesn't survive it, and a merge helper is forever optional.
Proposal: definition factories
Three layers:
createDefinitionFactory — a package-private builder in _common/, the single place the merge policy is encoded. Changing the policy is a one-file edit; all factories behave identically.
- Per-component factories — each
definition.ts keeps its existing definition object exactly as it is (shape, name, contents), drops the export keyword, and exports the factory built from it instead. The raw object never leaves the package, so there is nothing to spread the guard out of.
- Components — exported alongside the factories (see below).
The design splits the meaning of condition into two concepts at the factory layer:
- guard — what data the component can safely render. The base definition's existing
condition is the guard — conveniently, both current non-trivial conditions (Markdown's typeof check, DatePicker's ISO regex) are pure guard, so the base objects need no changes at all.
- targeting — which nodes the consumer wants the component on. Defaults to "everywhere the guard passes"; the builder takes an optional
defaultTargeting second argument for any future definition that wants something narrower (no current definition does).
The override surface (exported as a type, so consumers can type an overrides object they build separately):
export type DefinitionOverrides<T> = Partial<Omit<CustomNodeDefinition<T>, 'condition'>> & {
// Replaces the default TARGETING — always ANDed with the guard
condition?: FilterFunction
// Replaces the GUARD itself — the explicit escape hatch
guard?: FilterFunction
}
The builder takes a plain CustomNodeDefinition and interprets its condition as the guard:
const createDefinitionFactory =
<T>(base: CustomNodeDefinition<T>, defaultTargeting: FilterFunction = () => true) =>
(overrides: DefinitionOverrides<T> = {}): CustomNodeDefinition<T> => {
const { guard, condition: targeting, componentProps, ...rest } = overrides
const g = guard ?? base.condition
const t = targeting ?? defaultTargeting
return {
...base,
...rest,
condition: (nodeData) => g(nodeData) && t(nodeData),
componentProps: { ...base.componentProps, ...componentProps } as T,
}
}
So Markdown/definition.ts becomes:
// The condition doubles as the guard: consumer `condition` overrides are
// targeting, ANDed with this by the factory; replacing it requires the
// explicit `guard` override.
const MarkdownNodeDefinition: CustomNodeDefinition<MarkdownCustomProps> = {
condition: ({ value }) => typeof value === 'string',
component: MarkdownComponent,
showOnView: true,
showOnEdit: false,
}
export const markdownDefinition = createDefinitionFactory(MarkdownNodeDefinition)
The builder stays private for now. Because it accepts any plain CustomNodeDefinition, it would work just as well on consumer-authored or third-party definitions — so exporting it later as a general "make any definition safely customizable" wrapper is a compelling, purely additive option, but not part of this issue.
Merge policy
| Override key |
Policy |
condition |
Reinterpreted as targeting: replaces the default targeting, always ANDed with the guard. Writing condition physically cannot drop the guard — no 'and' | 'replace' mode flag needed. |
guard |
Replaces the guard. Deliberate and self-documenting — the only way to lose the ISO check is to type the word guard. |
componentProps |
Shallow-merged, base then override — e.g. keep { showTime: true } while adding one prop. |
Everything else (showOnView, name, defaultValue, component, fromStandardType, …) |
Override wins. |
Every field remains overridable, and no behaviour is unreachable — guard: () => true plus condition reproduces a bare full replacement, but as a named act rather than the default spelling. The factory output is a plain CustomNodeDefinition with a single fused condition; core is unchanged — guard/targeting exist only at the factory layer.
Usage
import { markdownDefinition, datePickerDefinition } from '@json-edit-react/components'
// Tier 1: defaults — identical behaviour to the current definition objects
const definitions = [markdownDefinition()]
// Tier 2: narrowing — the case that is currently the footgun.
// Effective condition: typeof value === 'string' && key === 'description'.
// A non-string under `description` renders as a normal node instead of
// crashing, and the consumer never has to remember to re-check the type.
const definitions = [markdownDefinition({ condition: ({ key }) => key === 'description' })]
// Tier 3: replacing the guard — e.g. DatePicker for a non-ISO format.
// The consumer now owns the safety contract (and realistically also
// overrides fromStandardType / defaultValue — ordinary override-wins fields).
const NZ_DATE = /^\d{2}\/\d{2}\/\d{4}$/
const definitions = [
datePickerDefinition({
guard: ({ value }) => typeof value === 'string' && NZ_DATE.test(value),
componentProps: { dateFormat: 'dd/MM/yyyy' },
}),
]
Module scope remains the documented call-site pattern: factory results are fresh objects per call, so static config lives at module scope (referentially stable, keeping the §16 memo boundary intact) and genuinely dynamic overrides go in useMemo. (The no-arg call could return a memoized singleton as a later optimization.)
Naming
Factories are camelCase functions, replacing today's inconsistent *Definition / *NodeDefinition / *CustomNodeDefinition object exports with a uniform scheme:
hyperlinkDefinition (currently LinkCustomNodeDefinition), enhancedLinkDefinition, dateObjectDefinition, datePickerDefinition, booleanToggleDefinition, undefinedDefinition, nanDefinition, symbolDefinition, bigIntDefinition, markdownDefinition, imageDefinition, colorPickerDefinition.
(The internal object constants keep their existing names — only their export keyword goes.)
Component exports
The underlying components (and their props types) are exported alongside the factories: MarkdownComponent, DateTimePicker, etc. (PascalCase components vs camelCase factories — naming disambiguates itself.) Rationale:
- Hiding them buys no enforcement —
markdownDefinition().component hands the component straight back.
- Wrapping is a real pattern the factory can't express:
component: (props) => <MarkdownComponent {...props} /> with the consumer's own chrome around it.
- From-scratch authorship doesn't reopen the footgun: the danger is spreading an opaque prebuilt object whose invariant you don't know you're dropping. A definition written by hand with the component's props in view never had a built-in guard to lose.
CodeEditor and ReactSelect already export component-only (no meaningful default condition exists for "a code editor" or "a select"), so they get no factory. The components README should present them as their own category — components for replacing the standard editing UI, distinct from the pre-built custom-node definitions. Resulting mental model: every folder exports a component; most also export a factory that wires it into a safe definition.
Also
- Belt-and-braces for the fragile components (Markdown, DatePicker): render a graceful fallback + dev-mode
console.warn when handed a value the guard would reject, so even a too-loose guard replacement degrades instead of throwing inside react-markdown / the date parser.
- Replace the
// Over-ride this for specific cases comments in the definitions — the guard-doubling comment in the example above takes their place.
- Document in the components README: the three usage tiers, the module-scope pattern, and the UI-replacement category for
CodeEditor / ReactSelect.
Since @json-edit-react/components is unpublished, this is a free API change — no migration impact.
For the
@json-edit-react/componentspackage: export the pre-built custom-node definitions as factory functions rather than plainCustomNodeDefinitionobjects, so customizing them can never silently drop the built-in safety condition.The problem
Consumers import a pre-built definition and usually want to tweak it — most commonly the
condition, to target specific keys ("only treatdescriptionas Markdown"). The natural way to do that is to spread the definition and overridecondition— which silently drops the built-in guard.Markdown/definition.tscurrently invites exactly this:Follow that comment with
condition: ({ key }) => key === 'description'and the first non-string value under that key throws inside react-markdown.DatePickerhas the same shape (its ISO-regex condition is the guard that keeps the date parser safe). The footgun isn't hypothetical — it's the documented path.As long as the definitions are exported plain objects, this can't be prevented: spread always type-checks, branding doesn't survive it, and a merge helper is forever optional.
Proposal: definition factories
Three layers:
createDefinitionFactory— a package-private builder in_common/, the single place the merge policy is encoded. Changing the policy is a one-file edit; all factories behave identically.definition.tskeeps its existing definition object exactly as it is (shape, name, contents), drops theexportkeyword, and exports the factory built from it instead. The raw object never leaves the package, so there is nothing to spread the guard out of.The design splits the meaning of
conditioninto two concepts at the factory layer:conditionis the guard — conveniently, both current non-trivial conditions (Markdown'stypeofcheck, DatePicker's ISO regex) are pure guard, so the base objects need no changes at all.defaultTargetingsecond argument for any future definition that wants something narrower (no current definition does).The override surface (exported as a type, so consumers can type an overrides object they build separately):
The builder takes a plain
CustomNodeDefinitionand interprets itsconditionas the guard:So
Markdown/definition.tsbecomes:The builder stays private for now. Because it accepts any plain
CustomNodeDefinition, it would work just as well on consumer-authored or third-party definitions — so exporting it later as a general "make any definition safely customizable" wrapper is a compelling, purely additive option, but not part of this issue.Merge policy
conditionconditionphysically cannot drop the guard — no'and' | 'replace'mode flag needed.guardguard.componentProps{ showTime: true }while adding one prop.showOnView,name,defaultValue,component,fromStandardType, …)Every field remains overridable, and no behaviour is unreachable —
guard: () => trueplusconditionreproduces a bare full replacement, but as a named act rather than the default spelling. The factory output is a plainCustomNodeDefinitionwith a single fusedcondition; core is unchanged — guard/targeting exist only at the factory layer.Usage
Module scope remains the documented call-site pattern: factory results are fresh objects per call, so static config lives at module scope (referentially stable, keeping the §16 memo boundary intact) and genuinely dynamic overrides go in
useMemo. (The no-arg call could return a memoized singleton as a later optimization.)Naming
Factories are camelCase functions, replacing today's inconsistent
*Definition/*NodeDefinition/*CustomNodeDefinitionobject exports with a uniform scheme:hyperlinkDefinition(currentlyLinkCustomNodeDefinition),enhancedLinkDefinition,dateObjectDefinition,datePickerDefinition,booleanToggleDefinition,undefinedDefinition,nanDefinition,symbolDefinition,bigIntDefinition,markdownDefinition,imageDefinition,colorPickerDefinition.(The internal object constants keep their existing names — only their
exportkeyword goes.)Component exports
The underlying components (and their props types) are exported alongside the factories:
MarkdownComponent,DateTimePicker, etc. (PascalCase components vs camelCase factories — naming disambiguates itself.) Rationale:markdownDefinition().componenthands the component straight back.component: (props) => <MarkdownComponent {...props} />with the consumer's own chrome around it.CodeEditorandReactSelectalready export component-only (no meaningful default condition exists for "a code editor" or "a select"), so they get no factory. The components README should present them as their own category — components for replacing the standard editing UI, distinct from the pre-built custom-node definitions. Resulting mental model: every folder exports a component; most also export a factory that wires it into a safe definition.Also
console.warnwhen handed a value the guard would reject, so even a too-looseguardreplacement degrades instead of throwing inside react-markdown / the date parser.// Over-ride this for specific casescomments in the definitions — the guard-doubling comment in the example above takes their place.CodeEditor/ReactSelect.Since
@json-edit-react/componentsis unpublished, this is a free API change — no migration impact.