First-class $ref support#49
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>
- Add MAX_REF_PREVIEW_DEPTH=3 cap in RefEditor to prevent stack overflow on circular $ref cycles (A→B→A); shows a localised depth-limit message instead of recursing indefinitely - Strip leftover keywords (type, minLength, …) when switching a field to a $ref in createFieldSchema — only $ref and description are preserved - Document the WeakMap stable-reference requirement on resolveExternalDocument so callers know inline arrow functions defeat the per-resolver cache - Wrap validateJsonAsync in Promise.race with a 10 s timeout in ValidateJsonDialog; a slow external schema server now resolves the spinner with an error instead of hanging forever - Add refPreviewDepthLimit and refExternalTimeout translation keys to all 8 locale files (de, en, es, fr, pl, ru, uk, zh) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add test/components/SchemaEditor/DefinitionsEditor.test.tsx with 4 snapshot tests (write mode with definitions, read-only with definitions, write mode empty, read-only empty returns null) - Add depth-limit interaction test to RefEditor suite using fireEvent.click to verify the "Preview depth limit reached" message fires at depth=3 - Move demo address inline object into $defs.address and reference it via $ref so first-load visitors immediately see definitions and $ref in action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add test/components/SchemaEditor/DefinitionsEditor.test.tsx with 4 snapshot tests (write mode with definitions, read-only with definitions, write mode empty, read-only empty returns null) - Add depth-limit interaction test to RefEditor suite using fireEvent.click to verify the "Preview depth limit reached" message fires at depth=3 - Move demo address inline object into $defs.address and reference it via $ref so first-load visitors immediately see definitions and $ref in action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts: # test/components/SchemaEditor/DefinitionsEditor.test.tsx # test/components/SchemaEditor/types/RefEditor.test.tsx
- Change ExternalRefResolver import from bare "src" to explicit "src/index.ts" - Remove unnecessary line breaks in renderDefinitionsEditor function parameters
|
@shokurov if you commit to a branch in this repo instead of your fork, we should get a live preview from cloudflare |
|
All good with me, hopeful to see this get merged. Thanks for your review! |
|
@lovasoa Cloudflare publish action seems to be failing? I don't have access to diagnose |
|
Hello @lovasoa how do we proceed on this? Do you want to take time to fix the Cloudflare pipeline? Then I'll stop bugging you. |
|
here is the build log, sorry |
|
@lovasoa updated |
|
The preview works : https://ref-support.jsonjoy-builder.pages.dev/ To me it sounds like the human has not even specified to the agent what the UI should look like. It has buttons that do nothing, a huge "Street address" in the middle... I haven't looked at the huge PR code. |
|
Are you looking at the general design, or at the MUI adapter? I am not seeing the thing you attached on the screenshot
…-- Egor Shokurov
On 1 Jul 2026 at 18:55 +0600, Ophir LOJKINE ***@***.***>, wrote:
lovasoa left a comment (lovasoa/jsonjoy-builder#49)
The preview works : https://ref-support.jsonjoy-builder.pages.dev/
image.png (view on web)
To me it sounds like the human has not even specified to the agent what the UI should look like. It has buttons that do nothing, a huge "Street address" in the middle.
I haven't looked at the huge PR code.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you were mentioned.Message ID: ***@***.***>
|
|
I am looking at the UI and usability issues I see in the preview. The overall design and polish of the feature. There are small ui issues, like x But in general, I think this is not how references should work. I think we should just have a dropdown of available definitions, and make the chosen ref clickable to focus the the definition. |
|
@lovasoa I do not agree re: Obviously it is not my code, I only resolved the conflicts and added few tests, so I do not intend to redo the visuals. @andrewshawcare you may be interested in taking part in this discussion. If my opinion matters, I think the best way for questionable or experimental feature to not disturb majority of consumers would be a feature flag. |
|
@shokurov It's your code as much as mine now that you are a maintainer :) do what you feel is best! |
|
My judgement is not worth more than yours. Shipping this as optional (maybe disabled by default in this version and enabled by default in the next one) feels reasonable to me. Showing |
|
Fair enough. I still think the feature is valuable. If the original developer (@rice-as681) won't join, I'll likely eventually find time to clean it up. It would really help, if you describe each issue you found more specifically. For example, the first case you provided screenshot for (the bold text "Street address" under the field "street --- Street address") is totally repeating original read-only rendering at https://jsonjoy-builder.pages.dev/. In fact, the only major new element in main tree is the |
|
Hello @lovasoa , do you think you will be able to make a list of UI discrepancies with more precise location indicated? |
|
The two I found after a quick look are listed above:
|
|
Let's discuss repeated description first — it's neither new nor specific to the |
|
I see ! Okay, fixing it in a separate pr is better |




About this PR
This PR supersedes #47 including all its changes, resolving conflicts with upstream and adding unit tests and a demo example.
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 rootA → B → A) display a "Preview depth limit reached" notice instead of overflowing the stackDefinitions 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. Validation is wrapped in a 10-secondPromise.racetimeout — a slow or unresponsive external schema server resolves the spinner with a clear error rather than hanging indefinitely.resolveExternalRefargument must be a stable reference (module-level function oruseCallback) for the per-resolver cache to be effective; an inline arrow function on each render produces a new cache key every time.Known limitation (documented in the README): relative references inside externally loaded documents are not resolved against the document's base URI.
Keyword cleanup on type switch
When switching an existing field to a
$reftype,createFieldSchemanow carries forward only the$refvalue (anddescriptionif present) from the previous schema — straytype,minLength, and other incompatible keywords are dropped rather than left alongside the new$ref.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 renamerefPreviewDepthLimit,refExternalTimeout, and the existing ref/definitions keys)resolveExternalReffetchExternalRef, so external previews and validation can be tried directly on the demo site; the built-in example schema now placesaddressin$defsand references it with$ref: "#/$defs/address", so the Definitions panel and ref preview are visible on first load without any manual setup.editorconfigadded: LF line endings, 2-space indent, 80-char line width — matchesbiome.jsonand prevents editor-introduced CRLF from conflicting with theeol=lfrule in.gitattributesvar(--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/DefinitionsEditor.test.tsx(new): 4 snapshot tests — write mode with definitions, read-only with definitions (no editing controls), write mode with no definitions (add form visible), read-only with no definitions (component returns null)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; new interaction test usingfireEvent.clickverifying that the depth-limit message appears atdepth = 3instead of recursing into aTypeEditorpreviewtest/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 (107 tests, 105 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.