Skip to content

Add full $ref support to the visual editor#47

Open
rice-as681 wants to merge 4 commits into
lovasoa:mainfrom
rice-as681:ref-support
Open

Add full $ref support to the visual editor#47
rice-as681 wants to merge 4 commits into
lovasoa:mainfrom
rice-as681:ref-support

Conversation

@rice-as681

@rice-as681 rice-as681 commented Jun 12, 2026

Copy link
Copy Markdown

What

Adds first-class $ref support to the visual editor, as discussed in #10 ("adding support for refs would be cool").

Previously, a schema like {"$ref": "#/$defs/address"} displayed as a plain Object in the visual editor, and touching it (rename, toggle required) silently injected a stray "type": "object" next to the $ref. The only way to work with references was the raw JSON panel.

Changes

"Reference" as an editor type

$ref joins anyOf/oneOf/allOf as a virtual editor type: it appears in the type dropdown, the Add Field dialog, array item types, and combinator options. Switching a schema to a reference points it at the first definition in the document (or # when there is none yet).

New RefEditor

  • a picker listing every definition in the document (root and nested $defs/definitions), plus the document root
  • a free-text input for arbitrary targets (external URIs, anchors, any pointer)
  • a warning when a local reference does not resolve, and an info note for external references
  • a collapsible read-only preview of the referenced schema, rendered with the existing type editors

Definitions section

A new section at the bottom of the visual editor lists root-level definitions and lets you create, rename, edit, and delete them — so the inheritance pattern from #10 (Vehicle / Car / Plane via allOf + $ref) can be built entirely visually. Renaming a definition rewrites every $ref in the document that points at or below it, so references never break. The legacy definitions keyword is read and preserved; new definitions are created in $defs.

Combinator and $ref roots

The visual editor previously rendered only root-level properties, so a document whose root is a combinator or a reference — e.g. the common "extend an external schema" shape, {"allOf": [{"$ref": "https://…/base.json"}, {"properties": {…}}]} — displayed nothing but the "add your first field" hint. Such roots now render the corresponding combinator/reference editor for the whole document root (the Add Field button is hidden there, since fields live inside the combinator's object options). Root keywords like $schema, $id, title, and description are preserved when editing through it. Object roots are unchanged.

Resolution engine (src/lib/refUtils.ts)

  • # (document root), #/... JSON Pointers — including RFC 6901 ~0/~1 escapes and URI-encoded segments — and #name plain-name fragments declared with $anchor/$dynamicAnchor
  • anything not starting with # is an external reference, never loaded by default
  • traversal is keyword-aware, so schema-shaped data inside const/enum/default/examples is never mistaken for a schema or a ref target

External references (opt-in)

By default the editor never touches the network: external $ref targets are preserved exactly as written but not loaded. A new resolveExternalRef prop on SchemaBuilder, SchemaFieldsEditor, and ValidateJsonDialog opts in. The exported fetchExternalRef is a plain fetch-based resolver; anything (documentUri: string) => Promise<JsonSchema> works for registries, bundles, or authenticated APIs.

With a resolver configured:

  • RefEditor loads the external document and shows the same read-only preview as for local targets, including URI fragments (…/schema.json#/definitions/x, $anchor names) resolved within the loaded document; nested refs inside the preview resolve against the external document rather than the edited one. Loading and load-failure states are shown inline, and a fragment missing from the loaded document gets the same broken-reference warning as a dangling local pointer.
  • ValidateJsonDialog validates against schemas that reference external documents: validation goes through a new validateJsonAsync, which compiles with Ajv's compileAsync and loads missing schemas (recursively) through the resolver. Without a resolver, behavior is unchanged.
  • Documents are cached per resolver — loaded at most once per session across all editors and validation runs; failed loads are evicted and retried.

Known limitation (documented in the README): relative references inside externally loaded documents are not resolved against the document's base URI.

Supporting changes

  • RootSchemaContext gives deeply nested editors access to the document root for listing/resolving definitions (provided by SchemaFieldsEditor, with a graceful fallback when components are rendered standalone)
  • SchemaField now derives the editor type via getEditorType when renaming or toggling required — this also fixes a pre-existing issue where combinator schemas gained a stray "type": "object" on rename
  • translations for all new UI strings in all eight locales
  • README: feature list updated and a new "External References" section documenting resolveExternalRef
  • the demo app passes fetchExternalRef, so external previews and validation can be tried directly on the demo site
  • fixed the Monaco editors rendering in a sans-serif font: the configured var(--font-sans) never resolved (theme variables are compiled away), which invalidated the whole font-family declaration; they now use the same monospaced stack as the Tailwind font-mono utility

Tests

  • test/lib/refUtils.test.ts: pointer/anchor/external resolution, escaped segments, data-vs-schema traversal, target collection, definition add/remove/rename incl. ref rewriting and prefix-collision safety (#/$defs/address vs #/$defs/addressBook), URI splitting, and the per-resolver document cache (single load, retry after failure)
  • test/jsonValidatorAsync.test.ts: async validation through a stub resolver — nested external documents enforced, fragments in external refs, resolver failures reported as validation errors, no-resolver fallback
  • test/components/SchemaEditor/types/RefEditor.test.tsx: snapshots for write mode, read-only mode, broken refs, external refs without a resolver, and external refs loaded/failed through a stub resolver
  • test/components/SchemaEditor/SchemaFieldsEditor.test.tsx: snapshots for root-level allOf (ref + object options) and root-level $ref rendering
  • existing SchemaFieldsEditor snapshot updated (it now contains the Definitions section)

npm run typecheck, npm run check, and npm test all pass (83 tests, 81 pass, 2 pre-existing skips). Verified end-to-end in the demo app: local definitions (create, reference, preview, rename with automatic $ref rewriting), and external references against the CloudEvents 1.0.2 schema — full-document preview, #/definitions/specversiondef fragment preview, broken-fragment warning for a non-existent fragment, and validation enforcing the remote schema's required properties and types.

🤖 Generated with Claude Code

Andrew Shaw Care and others added 4 commits June 12, 2026 11:20
The visual editor previously had no notion of $ref: a schema like
{"$ref": "#/$defs/address"} displayed as a plain Object, and editing it
injected a stray "type": "object" next to the $ref. The only way to work
with references was the raw JSON panel (discussed in lovasoa#10).

References are now a first-class editor type:

- "Reference" appears in the type dropdown and the Add Field dialog,
  alongside the existing anyOf/oneOf/allOf virtual types. Switching a
  schema to a reference points it at the first definition in the
  document (or "#" when there is none).
- A new RefEditor edits the $ref target: a picker listing every
  definition in the document, a free-text input for arbitrary targets,
  a warning for references that do not resolve, an indicator for
  external references, and a collapsible read-only preview of the
  referenced schema.
- A Definitions section at the root of the visual editor creates,
  renames, edits, and deletes reusable definitions in $defs (the legacy
  definitions keyword is supported too). Renaming a definition rewrites
  every $ref in the document that points at or below it, so references
  never break.
- Resolution lives in src/lib/refUtils.ts and supports "#", "#/..."
  JSON Pointers (with RFC 6901 ~0/~1 escapes and URI-encoded segments),
  and "#name" plain-name fragments declared with $anchor or
  $dynamicAnchor. Anything not starting with "#" is treated as an
  external reference. Traversal is keyword-aware, so schema-shaped data
  inside const/enum/default/examples is never mistaken for a schema.
- Deeply nested editors reach the document root through a new
  RootSchemaContext provided by SchemaFieldsEditor.
- SchemaField now derives the editor type via getEditorType when
  renaming or toggling required, which also stops combinator schemas
  from gaining a stray "type": "object" on rename.
- New translation keys are provided for all eight locales.

Closes lovasoa#10's request for ref support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
By default the editor still never touches the network: external $ref
targets are preserved but not loaded. Applications can now opt in by
passing a resolver to the new resolveExternalRef prop on SchemaBuilder,
SchemaFieldsEditor, and ValidateJsonDialog. The exported fetchExternalRef
is a plain fetch-based resolver; custom resolvers (registry, bundle,
authenticated API) are any (documentUri) => Promise<JsonSchema>.

With a resolver configured:

- RefEditor loads the external document and shows the same read-only
  preview as for local targets, including URI fragments (JSON Pointers
  and $anchor names) resolved within the loaded document. Nested refs
  inside the preview resolve against the external document, not the
  edited one. Loading and load-failure states are shown inline, and a
  fragment that does not exist in the loaded document gets the same
  broken-reference warning as a dangling local pointer.
- ValidateJsonDialog validates documents against schemas that reference
  external documents: validation now goes through validateJsonAsync,
  which compiles with Ajv's compileAsync and loads missing schemas
  (recursively) through the resolver. Without a resolver the behavior
  is unchanged.
- Documents are cached per resolver, so each one is loaded at most once
  per session across all editors and validation runs; failed loads are
  evicted and retried on the next attempt.

Verified in the demo app against the CloudEvents 1.0.2 schema: full
document preview, #/definitions/specversiondef fragment preview, and
validation enforcing the remote schema's required properties and types.

Known limitation, documented in the README: relative references inside
externally loaded documents are not resolved against the document's
base URI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The visual editor only ever rendered root-level properties, so a
document whose root is a combinator or a reference — like the common
"extend an external schema" shape:

  { "type": "object",
    "allOf": [
      { "$ref": "https://example.com/base.json" },
      { "properties": { "data": {} } } ] }

showed nothing but the "add your first field" hint, as if the JSON
panel edit had been ignored.

When the root is anyOf/oneOf/allOf or a $ref, the visual editor now
renders the corresponding editor for the whole root instead of the
field list, so the options (including external references and their
previews) are visible and editable. The Add Field button is hidden for
such roots: fields live inside the combinator's object options, and
adding sibling properties next to the combinator would be confusing.
Root keywords like $schema, $id, title, and description are preserved
when editing through the combinator editor.

Object roots are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default editor options set fontFamily to "var(--font-sans), 'SF
Mono', Monaco, ...". The theme variables are compiled away by Tailwind,
so the var() never resolves — and an unresolvable var() invalidates the
whole font-family declaration, making the JSON Schema Source editor
(and the validation dialog's editors) silently inherit Monaco's
sans-serif UI font instead of any of the listed fallbacks.

Use the same monospaced stack the Tailwind font-mono utility resolves
to, matching the font already used for pattern-property names, $ref
targets, and definition names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lovasoa

lovasoa commented Jun 14, 2026

Copy link
Copy Markdown
Owner

this is hard to review. Did you review this, @rice-as681 ? the pr description is very verbose and not very helpful

@shokurov shokurov self-assigned this Jun 19, 2026
@shokurov shokurov added the enhancement New feature or request label Jun 19, 2026
@shokurov shokurov linked an issue Jun 19, 2026 that may be closed by this pull request
@shokurov

Copy link
Copy Markdown
Collaborator

Hello @rice-as681

This is a genuinely impressive piece of work. Bringing $ref into the visual editor as a first-class concept, and the implementation is remarkably well-structured. The separation between the resolution engine in refUtils.ts and the React layer is clean, the keyword-aware schema walker is a thoughtful touch (avoiding the classic trap of mistaking const/enum data for subschemas), and the opt-in external loading model is exactly the right security default. The race-condition guard in ValidateJsonDialog is also rather elegant.

I've gone through the changes in some detail and put together a few thoughts below. Nothing here is a blocker — these are offered as suggestions for consideration, in the spirit of polishing an already strong contribution.

Cycle detection. JSON Schema explicitly permits circular references (ABA), and resolveRef currently resolves both ends without complaint. The collapsible preview in RefEditor would then render TypeEditor recursively until the stack gives out. A visited-set or a depth cap in the preview would be a rather sensible safeguard, and not a terribly invasive one at that.

Definition name validation. The DefinitionsEditor happily accepts names containing / or ~, or even empty strings. escapePointerSegment handles the escaping correctly, so the pointers remain valid — but it might be worth giving the user a gentle nudge (a tooltip or inline hint) rather than letting them discover the consequences in the JSON source. The duplicate-name case is already handled for the "add" flow; extending that same feedback to renames would be a lovely consistency win.

Leftover keywords on type switch. When a field is switched to a reference, the existing type/minLength/etc. are carried along by the spread operator alongside the new $ref. It doesn't break validation ($ref takes precedence), but it does make the JSON source rather noisy. Stripping incompatible keywords — or at minimum type — on the switch would tidy things up nicely.

Test coverage. The resolution engine and definition-editing functions are thoroughly tested, which is grand. The gap is mostly on the component side: DefinitionsEditor (an entire new component with real interaction logic) has no test file, RefEditor is snapshot-only with no interaction tests, and ValidateJsonDialog's race-condition guard is unverified. Adding tests for these would bring the coverage in line with the quality of the rest of the work.

On a related note, the project doesn't currently measure code coverage at all — introducing c8 or similar would make these gaps visible going forward, and would be a worthwhile investment quite apart from this PR. Not a demand, just a note to all contributors.

A spot of documentation. The External References section in the README is well-written and the feature list has been updated, which is appreciated. A thing that might be worth adding: a dedicated section on the known limitations — $dynamicRef isn't supported, circular refs aren't handled yet. Being upfront about these helps set expectations.

Resolver cache note. The per-resolver WeakMap cache is a clever approach, but it's the sort of thing that could trip someone up: an inline arrow function passed as resolveExternalRef would defeat caching entirely, with no visible signal. A JSDoc note on resolveExternalDocument mentioning that stable resolver references are needed for cache hits would save a future consumer a rather puzzling afternoon.

Rebasing onto the slot registry. I notice this branch was created before #46 (the design-system registry infrastructure) landed on main, and the PR is currently showing merge conflicts. That's rather unavoidable given the timing — #46 restructured the component layer quite substantially with the slot and adapter pattern, and several files touched here (SchemaFieldsEditor, SchemaPropertyEditor, CombinatorEditor, ArrayEditor, and friends) are exactly the ones that were refactored. It might be worth rebasing onto the current main sooner rather than later, so the conflicts are resolved while the changes are still fresh — and so the $ref editor components can be wired through the new registry pattern from the outset rather than retrofitted later. The DefinitionsEditor and RefEditor are new components that will need slot adapters too, which is a natural thing to do during the rebase.

Demo examples. The demo app already wires up fetchExternalRef, which is a great start — but it would be lovely to see a ready-made example schema that actually exercises the new $ref features. A simple "Definitions & References" preset (perhaps the Vehicle / Car / Plane inheritance pattern from issue #10, or a schema with a $defs section and a few $ref fields) would let visitors try the definition picker, preview, and rename-with-rewriting without having to construct one from scratch. The external reference path could also benefit from a button that loads a well-known public schema (the CloudEvents one used in testing would do nicely) so the external preview and validation are just a click away rather than requiring the user to know a URL.

All in all, this is a substantial and well-executed addition. With a handful of the above addressed — particularly the rebase onto the slot registry, cycle detection, and a test or two for DefinitionsEditor — it would be in excellent shape. Thank you for the work that went into this; it's a meaningful step forward for the project.

Comment thread src/lib/refUtils.ts
Comment on lines +265 to +268
const externalDocumentCaches = new WeakMap<
ExternalRefResolver,
Map<string, Promise<JsonSchema>>
>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The externalDocumentCaches is a WeakMap keyed on the resolver function reference.

This works fine when consumers pass a stable named function like fetchExternalRef, but if someone writes an inline arrow each render

(resolveExternalRef={(uri) => fetch(uri).then(r => r.json())})

every render produces a brand-new function identity and the cache is silently bypassed — every editor instance and validation run re-fetches every external document.

A Map<string, Promise<JsonSchema>> keyed on the document URI would sidestep this entirely, or at least a JSDoc note on resolveExternalDocument explaining that stable resolver references are needed for cache hits would help future consumers avoid the trap.

Comment thread src/lib/refUtils.ts
Comment on lines +411 to +414
if (schema.$ref === oldPointer) {
schema.$ref = newPointer;
} else if (schema.$ref.startsWith(`${oldPointer}/`)) {
schema.$ref = newPointer + schema.$ref.slice(oldPointer.length);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is safe today because newRoot is a structuredClone and walkSchema passes live object references to its visitor. But the pattern is fragile — if a future refactor to walkSchema introduces copy-on-visit or a different traversal strategy, the mutations silently stop taking effect and refs would break on rename with no error.

A more defensive approach would be to have the visitor return a (possibly modified) schema, though that's a bigger change.

Have you considered protective comments guarding the current deep clone logic?

Comment thread src/lib/schemaEditor.ts
: { $ref: "#" };

return {
...schema,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When switching an existing field to a reference, the spread of validation carries forward whatever properties were there before — so a field previously typed as { type: "string", minLength: 3 } becomes { type: "string", minLength: 3, $ref: "#/$defs/address" }. The $ref keyword takes precedence at validation time, but the leftover keywords are semantically confusing and show up in the JSON source. It would be cleaner to extract only $ref (and maybe $comment/description) rather than spreading the whole object.

Comment on lines +85 to +87
validationTree.children[
`${definition.container}:${definition.name}`
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation tree lookup uses ${definition.container}:${definition.name} as the key, which matches how buildValidationTree writes its children. That's correct. However, buildValidationTree processes $defs and definitions as separate passes — if a document happens to define the same name in both containers (e.g. $defs.address and definitions.address), the definitions entry silently overwrites the $defs entry in the tree, and only one of them gets validation feedback. Unlikely in practice, but a latent collision.

Comment on lines +101 to +106
renameDefinition(
asObjectSchema(schema),
definition.container,
definition.name,
name,
),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user types a name that already exists, the rename is silently swallowed — no toast, no inline error, no tooltip. The user sees the field revert to its old name and may not understand why. The add flow already has a nameError state with a visible message (definitionNameExists); it would be a nice touch to surface the same feedback for renames.

Comment on lines +166 to +167
: (DEFAULT_SCHEMAS[newType as SchemaType] ??
DEFAULT_SCHEMAS[newType as Combinator] ?? { type: "string" });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newType is SchemaEditorType (a union of SchemaType | Combinator | "ref"), and both SchemaType and Combinator are subsets of it. The double as cast works because the ref case is already handled by the newType === "ref" branch just above, but the casts are inherently lossy — TypeScript can't verify that newType is actually a member of SchemaType or Combinator at this point. A single DEFAULT_SCHEMAS[newType] lookup (since DEFAULT_SCHEMAS is already Record<SchemaEditorType, …>) would be both safer and simpler, with the { type: "string" } fallback removed since the record is now exhaustive.

Comment on lines +200 to +206
{(resolution.kind === "unresolved" ||
externalSchema.status === "broken") && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<TriangleAlert size={14} className="shrink-0" />
{t.refBrokenWarning}
</p>
)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broken-reference warning fires for resolution.kind === "unresolved" or externalSchema.status === "broken", which is good. However, there's no cycle detection anywhere in the resolution path. If definition A references B and B references A (or a longer cycle), resolveRef happily returns { kind: "resolved" } for both, and the collapsible preview renders TypeEditor recursively — A's preview shows B, whose preview shows A, whose preview shows B… until the stack overflows. JSON Schema spec explicitly allows circular $ref, so this isn't an exotic edge case. A visited-set in resolveRef or a depth cap in the preview would prevent the crash.

Comment on lines +75 to +77
if (seq === validationSeqRef.current) {
setValidationResult(result);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validationSeqRef pattern is a clean way to discard stale async results — nice work. One gap: there's no timeout around validateJsonAsync. If an external schema server is slow or unresponsive, the promise hangs indefinitely and the validation spinner never resolves. The user can type more (superseding the stale call), but the hanging promise lingers. A Promise.race with a timeout, or an AbortController passed through to the resolver, would give the user a clear "external schema load timed out" error rather than an infinite spinner.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Is there the possibility to have something like inherited types?

3 participants