Skip to content

First-class $ref support#49

Open
shokurov wants to merge 12 commits into
lovasoa:mainfrom
shokurov:ref-support
Open

First-class $ref support#49
shokurov wants to merge 12 commits into
lovasoa:mainfrom
shokurov:ref-support

Conversation

@shokurov

@shokurov shokurov commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

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 $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; preview depth is capped at 3 levels so circular references (A → B → A) display a "Preview depth limit reached" notice instead of overflowing the stack

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. Validation is wrapped in a 10-second Promise.race timeout — a slow or unresponsive external schema server resolves the spinner with a clear error rather than hanging indefinitely.
  • Documents are cached per resolver — loaded at most once per session across all editors and validation runs; failed loads are evicted and retried. The resolveExternalRef argument must be a stable reference (module-level function or useCallback) 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 $ref type, createFieldSchema now carries forward only the $ref value (and description if present) from the previous schema — stray type, minLength, and other incompatible keywords are dropped rather than left alongside the new $ref.

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 (refPreviewDepthLimit, refExternalTimeout, and the existing ref/definitions keys)
  • 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; the built-in example schema now places address in $defs and references it with $ref: "#/$defs/address", so the Definitions panel and ref preview are visible on first load without any manual setup
  • .editorconfig added: LF line endings, 2-space indent, 80-char line width — matches biome.json and prevents editor-introduced CRLF from conflicting with the eol=lf rule in .gitattributes
  • 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/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 using fireEvent.click verifying that the depth-limit message appears at depth = 3 instead of recursing into a TypeEditor preview
  • 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 (107 tests, 105 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.

Andrew Shaw Care and others added 11 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>
- 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
@lovasoa

lovasoa commented Jun 25, 2026

Copy link
Copy Markdown
Owner

@shokurov if you commit to a branch in this repo instead of your fork, we should get a live preview from cloudflare

@andrewshawcare

Copy link
Copy Markdown

All good with me, hopeful to see this get merged. Thanks for your review!

@shokurov

Copy link
Copy Markdown
Collaborator Author

@shokurov if you commit to a branch in this repo instead of your fork, we should get a live preview from cloudflare

@lovasoa np, pushed to ref-support in this repo

@shokurov

Copy link
Copy Markdown
Collaborator Author

@lovasoa Cloudflare publish action seems to be failing? I don't have access to diagnose

@shokurov

Copy link
Copy Markdown
Collaborator Author

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.

@lovasoa

lovasoa commented Jun 30, 2026

Copy link
Copy Markdown
Owner

here is the build log, sorry

2026-06-25T13:31:51.1778Z	Cloning repository...
2026-06-25T13:31:52.041252Z	From https://github.com/lovasoa/jsonjoy-builder
2026-06-25T13:31:52.041567Z	 * branch            945539106e420bce796de8a9c3648a0e0673529c -> FETCH_HEAD
2026-06-25T13:31:52.041635Z	
2026-06-25T13:31:52.066841Z	HEAD is now at 9455391 test: fix import path and reformat function signature
2026-06-25T13:31:52.067344Z	
2026-06-25T13:31:52.113582Z	
2026-06-25T13:31:52.114005Z	Using v2 root directory strategy
2026-06-25T13:31:52.13793Z	Success: Finished cloning repository files
2026-06-25T13:31:52.844149Z	Restoring from dependencies cache
2026-06-25T13:31:52.864756Z	Restoring from build output cache
2026-06-25T13:31:53.850784Z	Checking for configuration in a Wrangler configuration file (BETA)
2026-06-25T13:31:53.851358Z	
2026-06-25T13:31:54.015105Z	No Wrangler configuration file found. Continuing.
2026-06-25T13:31:54.290969Z	Detected the following tools from environment: nodejs@22.16.0, npm@10.9.2
2026-06-25T13:31:54.291373Z	Installing project dependencies: npm clean-install --progress=false
2026-06-25T13:31:56.325418Z	npm error code EUSAGE
2026-06-25T13:31:56.325743Z	npm error
2026-06-25T13:31:56.325829Z	npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
2026-06-25T13:31:56.325876Z	npm error
2026-06-25T13:31:56.325919Z	npm error Missing: @emnapi/core@1.11.1 from lock file
2026-06-25T13:31:56.325955Z	npm error Missing: @emnapi/runtime@1.11.1 from lock file
2026-06-25T13:31:56.32599Z	npm error
2026-06-25T13:31:56.326025Z	npm error Clean install a project
2026-06-25T13:31:56.326055Z	npm error
2026-06-25T13:31:56.326083Z	npm error Usage:
2026-06-25T13:31:56.326116Z	npm error npm ci
2026-06-25T13:31:56.326143Z	npm error
2026-06-25T13:31:56.326174Z	npm error Options:
2026-06-25T13:31:56.326283Z	npm error [--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
2026-06-25T13:31:56.326372Z	npm error [--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
2026-06-25T13:31:56.326439Z	npm error [--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
2026-06-25T13:31:56.326497Z	npm error [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit]
2026-06-25T13:31:56.32655Z	npm error [--no-bin-links] [--no-fund] [--dry-run]
2026-06-25T13:31:56.326659Z	npm error [-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
2026-06-25T13:31:56.326742Z	npm error [-ws|--workspaces] [--include-workspace-root] [--install-links]
2026-06-25T13:31:56.326861Z	npm error
2026-06-25T13:31:56.326915Z	npm error aliases: clean-install, ic, install-clean, isntall-clean
2026-06-25T13:31:56.326987Z	npm error
2026-06-25T13:31:56.327048Z	npm error Run "npm help ci" for more info
2026-06-25T13:31:56.327153Z	npm error A complete log of this run can be found in: /opt/buildhome/.npm/_logs/2026-06-25T13_31_55_117Z-debug-0.log
2026-06-25T13:31:56.376807Z	Error: Exit with error code: 1
2026-06-25T13:31:56.377125Z	    at ChildProcess.<anonymous> (/snapshot/dist/run-build.js)
2026-06-25T13:31:56.377267Z	    at Object.onceWrapper (node:events:652:26)
2026-06-25T13:31:56.377518Z	    at ChildProcess.emit (node:events:537:28)
2026-06-25T13:31:56.377624Z	    at ChildProcess._handle.onexit (node:internal/child_process:291:12)
2026-06-25T13:31:56.382996Z	Failed: build command exited with code: 1
2026-06-25T13:31:57.142682Z	Failed: error occurred while running build command

@shokurov

shokurov commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

@lovasoa updated package-lock.json on Linux machine, should be working now

@lovasoa

lovasoa commented Jul 1, 2026

Copy link
Copy Markdown
Owner

The preview works : https://ref-support.jsonjoy-builder.pages.dev/
image

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.

@shokurov

shokurov commented Jul 2, 2026 via email

Copy link
Copy Markdown
Collaborator Author

@lovasoa

lovasoa commented Jul 2, 2026

Copy link
Copy Markdown
Owner

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

  • alignment image
  • readonly forms image

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. #/$defs/ should not appear anywhere in the UI, (except in the JSON Schema source).

@shokurov

shokurov commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

@lovasoa I do not agree re: #/$defs/ should not appear in the UI, but its your call. What do you think is the best way to proceed?

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.

@lovasoa

lovasoa commented Jul 3, 2026

Copy link
Copy Markdown
Owner

@shokurov It's your code as much as mine now that you are a maintainer :)

do what you feel is best!

@lovasoa

lovasoa commented Jul 3, 2026

Copy link
Copy Markdown
Owner

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 #/$defs/ is defendable too (I see the tool as a way for non-technical people to edit schemas, but I understand why more tech-minded people would want to see it).

@shokurov

shokurov commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

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/.
image

In fact, the only major new element in main tree is the $def selector itself, everything else is reused.

@shokurov

shokurov commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Hello @lovasoa , do you think you will be able to make a list of UI discrepancies with more precise location indicated?

@lovasoa

lovasoa commented Jul 9, 2026

Copy link
Copy Markdown
Owner

The two I found after a quick look are listed above:

  • clipped border, and
  • repeated description

@shokurov

shokurov commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Let's discuss repeated description first — it's neither new nor specific to the $ref change. In the screenshot above I show current state of read only rendering. Do you think we need to fix it in this PR?

@lovasoa

lovasoa commented Jul 9, 2026

Copy link
Copy Markdown
Owner

I see ! Okay, fixing it in a separate pr is better

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants