Skip to content

Definition factories — safely customize pre-built custom-node definitions (/components) #350

Description

@CarlosNZ

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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    V2To include in Version 2utilsPossible "utility" helpers for the `@json-edit-react/utils` pacakge

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions