Add full $ref support to the visual editor#47
Conversation
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>
|
this is hard to review. Did you review this, @rice-as681 ? the pr description is very verbose and not very helpful |
|
Hello @rice-as681 This is a genuinely impressive piece of work. Bringing 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 ( Definition name validation. The Leftover keywords on type switch. When a field is switched to a reference, the existing Test coverage. The resolution engine and definition-editing functions are thoroughly tested, which is grand. The gap is mostly on the component side:
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 — Resolver cache note. The per-resolver Rebasing onto the slot registry. I notice this branch was created before #46 (the design-system registry infrastructure) landed on Demo examples. The demo app already wires up 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 |
| const externalDocumentCaches = new WeakMap< | ||
| ExternalRefResolver, | ||
| Map<string, Promise<JsonSchema>> | ||
| >(); |
There was a problem hiding this comment.
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.
| if (schema.$ref === oldPointer) { | ||
| schema.$ref = newPointer; | ||
| } else if (schema.$ref.startsWith(`${oldPointer}/`)) { | ||
| schema.$ref = newPointer + schema.$ref.slice(oldPointer.length); |
There was a problem hiding this comment.
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?
| : { $ref: "#" }; | ||
|
|
||
| return { | ||
| ...schema, |
There was a problem hiding this comment.
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.
| validationTree.children[ | ||
| `${definition.container}:${definition.name}` | ||
| ] |
There was a problem hiding this comment.
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.
| renameDefinition( | ||
| asObjectSchema(schema), | ||
| definition.container, | ||
| definition.name, | ||
| name, | ||
| ), |
There was a problem hiding this comment.
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.
| : (DEFAULT_SCHEMAS[newType as SchemaType] ?? | ||
| DEFAULT_SCHEMAS[newType as Combinator] ?? { type: "string" }); |
There was a problem hiding this comment.
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.
| {(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> | ||
| )} |
There was a problem hiding this comment.
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.
| if (seq === validationSeqRef.current) { | ||
| setValidationResult(result); | ||
| } |
There was a problem hiding this comment.
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.
What
Adds first-class
$refsupport 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
$refjoinsanyOf/oneOf/allOfas 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$defs/definitions), plus the document rootDefinitions 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/PlaneviaallOf+$ref) can be built entirely visually. Renaming a definition rewrites every$refin the document that points at or below it, so references never break. The legacydefinitionskeyword is read and preserved; new definitions are created in$defs.Combinator and
$refrootsThe 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, anddescriptionare preserved when editing through it. Object roots are unchanged.Resolution engine (
src/lib/refUtils.ts)#(document root),#/...JSON Pointers — including RFC 6901~0/~1escapes and URI-encoded segments — and#nameplain-name fragments declared with$anchor/$dynamicAnchor#is an external reference, never loaded by defaultconst/enum/default/examplesis never mistaken for a schema or a ref targetExternal references (opt-in)
By default the editor never touches the network: external
$reftargets are preserved exactly as written but not loaded. A newresolveExternalRefprop onSchemaBuilder,SchemaFieldsEditor, andValidateJsonDialogopts in. The exportedfetchExternalRefis a plainfetch-based resolver; anything(documentUri: string) => Promise<JsonSchema>works for registries, bundles, or authenticated APIs.With a resolver configured:
RefEditorloads the external document and shows the same read-only preview as for local targets, including URI fragments (…/schema.json#/definitions/x,$anchornames) 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.ValidateJsonDialogvalidates against schemas that reference external documents: validation goes through a newvalidateJsonAsync, which compiles with Ajv'scompileAsyncand loads missing schemas (recursively) through the resolver. Without a resolver, behavior is unchanged.Known limitation (documented in the README): relative references inside externally loaded documents are not resolved against the document's base URI.
Supporting changes
RootSchemaContextgives deeply nested editors access to the document root for listing/resolving definitions (provided bySchemaFieldsEditor, with a graceful fallback when components are rendered standalone)SchemaFieldnow derives the editor type viagetEditorTypewhen renaming or toggling required — this also fixes a pre-existing issue where combinator schemas gained a stray"type": "object"on renameresolveExternalReffetchExternalRef, so external previews and validation can be tried directly on the demo sitevar(--font-sans)never resolved (theme variables are compiled away), which invalidated the wholefont-familydeclaration; they now use the same monospaced stack as the Tailwindfont-monoutilityTests
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/addressvs#/$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 fallbacktest/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 resolvertest/components/SchemaEditor/SchemaFieldsEditor.test.tsx: snapshots for root-levelallOf(ref + object options) and root-level$refrenderingSchemaFieldsEditorsnapshot updated (it now contains the Definitions section)npm run typecheck,npm run check, andnpm testall 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$refrewriting), and external references against the CloudEvents 1.0.2 schema — full-document preview,#/definitions/specversiondeffragment preview, broken-fragment warning for a non-existent fragment, and validation enforcing the remote schema's required properties and types.🤖 Generated with Claude Code