schema-components 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,6 +117,41 @@ For yarn, use the `resolutions` field with the same value.
117
117
 
118
118
  The test suite is parametrised with a `unit-preact` Vitest project that runs the same files under `preact/compat` aliasing. Run it via `pnpm test:preact` from the repo root, or directly with `pnpm exec vitest run --project=unit-preact` from `packages/core`. A small set of tests (the ones bound to the three known limitations above) fail intentionally; the remaining ~99% pass under both runtimes and establish the cross-runtime regression boundary.
119
119
 
120
+ ### Vue support
121
+
122
+ Vue 3.5+ is supported as an optional peer dependency. The Vue adapter ships as **source** under `schema-components/vue/*` — the `.vue` Single File Components are not pre-compiled into the published tarball. Consumers need a Vite or webpack toolchain with `@vitejs/plugin-vue` (or equivalent) to compile the SFCs at their own build step.
123
+
124
+ ```ts
125
+ // vite.config.ts
126
+ import { defineConfig } from "vite";
127
+ import vue from "@vitejs/plugin-vue";
128
+
129
+ export default defineConfig({
130
+ plugins: [vue()],
131
+ });
132
+ ```
133
+
134
+ ```vue
135
+ <script setup lang="ts">
136
+ import { ref } from "vue";
137
+ import SchemaComponent from "schema-components/vue/SchemaComponent.vue";
138
+ import { z } from "zod";
139
+
140
+ const userSchema = z.object({
141
+ name: z.string(),
142
+ email: z.email(),
143
+ });
144
+
145
+ const user = ref({ name: "Ada", email: "ada@example.com" });
146
+ </script>
147
+
148
+ <template>
149
+ <SchemaComponent :schema="userSchema" v-model="user" />
150
+ </template>
151
+ ```
152
+
153
+ The `./vue/*` export subpath resolves to the source tree (`src/vue/*.ts`) and `./vue/*.vue` resolves to the `.vue` SFCs under `src/vue/`. The same pattern is used by the Svelte adapter — both rely on the consumer's bundler to handle the framework-specific compilation step.
154
+
120
155
  ## `SchemaComponent`
121
156
 
122
157
  The single entry point. Accepts Zod schemas, JSON Schema objects, or OpenAPI documents:
@@ -6,13 +6,14 @@ import { SchemaRenderError } from "./errors.mjs";
6
6
  *
7
7
  * Centralises the dispatch loop shared by the React `SchemaComponent` /
8
8
  * `SchemaView` renderers, the synchronous HTML renderer in
9
- * `renderToHtml`, and (in the future) Vue / Solid / Svelte / Lit
10
- * adapters. The dispatcher is intentionally framework-agnostic: it
11
- * neither imports React nor produces HTML strings directly. Each
12
- * adapter supplies a small {@link DispatchConfig} describing how to
13
- * build per-field props, how to handle a successful or absent resolver
14
- * lookup, and (optionally) how to handle widget overrides and the
15
- * recursion-depth cap.
9
+ * `renderToHtml`, the streaming HTML renderer in `streamRenderers.ts`
10
+ * (for its leaf path see "Streaming integration" below), and (in
11
+ * the future) Vue / Solid / Svelte / Lit adapters. The dispatcher is
12
+ * intentionally framework-agnostic: it neither imports React nor
13
+ * produces HTML strings directly. Each adapter supplies a small
14
+ * {@link DispatchConfig} describing how to build per-field props, how
15
+ * to handle a successful or absent resolver lookup, and (optionally)
16
+ * how to handle widget overrides and the recursion-depth cap.
16
17
  *
17
18
  * The dispatch order is fixed and matches the historic React-side
18
19
  * behaviour so the React, HTML, and future adapters all observe the
@@ -32,6 +33,39 @@ import { SchemaRenderError } from "./errors.mjs";
32
33
  * The helpers that find render functions, merge resolvers, and build
33
34
  * the per-field props live in {@link "./renderer.ts"} and are reused
34
35
  * here — `core/renderField.ts` is purely the dispatch shell.
36
+ *
37
+ * # Streaming integration (design choice B)
38
+ *
39
+ * The streaming HTML renderer (`html/streamRenderers.ts` +
40
+ * `html/renderToHtmlStream.ts`) consumes this dispatcher for leaf
41
+ * field types — `string`, `number`, `boolean`, `enum`, `literal`,
42
+ * `file`, `unknown` — and for variants without a dedicated streaming
43
+ * generator (`null`, `tuple`, `conditional`, `negation`, `never`).
44
+ * Container types (`object`, `array`, `record`, `union`,
45
+ * `discriminatedUnion`) keep bespoke generator implementations
46
+ * because the dispatcher's single-output contract cannot express the
47
+ * "yield opening tag → recurse into children → yield closing tag"
48
+ * chunk-boundary semantics that streaming depends on.
49
+ *
50
+ * We deliberately chose this approach (the Phase 1 agent's "option
51
+ * B" — leaves dispatch through the shared loop, containers keep their
52
+ * own iteration) over the alternative of building a generator-output
53
+ * mode into the dispatcher itself. Approach B preserves the existing
54
+ * chunk boundaries byte-for-byte while still eliminating the duplicate
55
+ * resolver-lookup logic that previously lived in `renderLeaf`. A
56
+ * generator-aware dispatcher would require either a parallel "stream
57
+ * resolver" shape or a unified return type wide enough to cover both
58
+ * single-output and iterable cases — neither of which is justified by
59
+ * the small amount of dispatch logic the leaf path needs.
60
+ *
61
+ * The streaming `streamField` function performs its own depth check
62
+ * before invoking the dispatcher for leaf paths. The check appears
63
+ * textually in both places (streamField and this dispatcher) but at
64
+ * runtime fires exactly once per recursion step: streamField's guard
65
+ * filters the streaming path, and the dispatcher's guard remains in
66
+ * place for the sync HTML and React callers that do not pre-filter
67
+ * depth themselves. See `html/streamRenderers.ts` for the matching
68
+ * commentary.
35
69
  */
36
70
  /**
37
71
  * Framework-agnostic dispatch loop shared by the React, HTML, and
@@ -19,10 +19,19 @@ declare function yieldClose(el: HtmlElement): string;
19
19
  /**
20
20
  * Render a leaf {@link WalkedField} entirely as a single HTML chunk.
21
21
  * Used inside the streaming generators when descent into containers is
22
- * complete. Falls back to a `<span>`-wrapped value when no renderer is
23
- * registered for the field type.
22
+ * complete.
23
+ *
24
+ * Delegates to the framework-agnostic {@link dispatchRenderField}
25
+ * dispatcher so resolver lookup, the (unused) widget step, error
26
+ * wrapping, and the unresolved-type fallback share one implementation
27
+ * with the sync HTML renderer and the React adapter.
28
+ *
29
+ * @param depth - Current recursion depth — defaults to `0` so the
30
+ * public signature stays additive. Callers inside the streaming
31
+ * pipeline thread the live `currentDepth` so the dispatcher's
32
+ * internal depth check is consistent with the streamField gate.
24
33
  */
25
- declare function renderLeaf(tree: WalkedField, value: unknown, mergedResolver: HtmlResolver, path: string): string;
34
+ declare function renderLeaf(tree: WalkedField, value: unknown, mergedResolver: HtmlResolver, path: string, depth?: number): string;
26
35
  /**
27
36
  * Drain {@link streamField} into a single string. Used when a streamed
28
37
  * sub-tree needs to be embedded inside a non-streaming chunk (e.g. as
@@ -3,6 +3,7 @@ import "../core/limits.mjs";
3
3
  import { emitDiagnostic } from "../core/diagnostics.mjs";
4
4
  import { SC_CLASSES } from "../core/cssClasses.mjs";
5
5
  import { panelIdFor, tabIdFor } from "../core/idPath.mjs";
6
+ import { dispatchRenderField } from "../core/renderField.mjs";
6
7
  import { getHtmlRenderFn } from "../core/renderer.mjs";
7
8
  import { matchUnionOption, resolveDiscriminatedActive } from "../core/unionMatch.mjs";
8
9
  import { VOID_ELEMENTS, h, raw, serialize, serializeAttributes } from "./html.mjs";
@@ -29,14 +30,15 @@ function yieldClose(el) {
29
30
  return `</${el.tag}>`;
30
31
  }
31
32
  /**
32
- * Render a leaf {@link WalkedField} entirely as a single HTML chunk.
33
- * Used inside the streaming generators when descent into containers is
34
- * complete. Falls back to a `<span>`-wrapped value when no renderer is
35
- * registered for the field type.
33
+ * Build the per-leaf {@link HtmlRenderProps} bundle handed to resolver
34
+ * render functions and (in future) to widget renderers. The streaming
35
+ * pipeline never recurses through `renderChild` for leaves container
36
+ * recursion happens through the streamField generators — so the
37
+ * supplied `renderChild` is the constant `() => ""` stub matching the
38
+ * historic streaming-leaf shape.
36
39
  */
37
- function renderLeaf(tree, value, mergedResolver, path) {
38
- const renderFn = getHtmlRenderFn(tree.type, mergedResolver);
39
- if (renderFn !== void 0) return renderFn({
40
+ function buildLeafProps(tree, value, path) {
41
+ const props = {
40
42
  value,
41
43
  readOnly: tree.editability === "presentation",
42
44
  writeOnly: tree.editability === "input",
@@ -45,11 +47,55 @@ function renderLeaf(tree, value, mergedResolver, path) {
45
47
  path,
46
48
  tree,
47
49
  renderChild: () => ""
48
- });
50
+ };
51
+ if (tree.examples !== void 0) props.examples = tree.examples;
52
+ return props;
53
+ }
54
+ /**
55
+ * Build the streaming-friendly placeholder a {@link dispatchRenderField}
56
+ * fallback emits when no resolver handled the leaf type. The sync HTML
57
+ * dispatcher throws in this position — streaming must keep producing
58
+ * output, so the streaming adapter renders the same `<span>` shape
59
+ * `renderLeaf` historically produced for unresolved types.
60
+ */
61
+ function leafFallbackHtml(value) {
49
62
  if (value === void 0 || value === null) return serialize(h("span", { class: SC_CLASSES.valueEmpty }, "—"));
50
63
  return serialize(h("span", { class: SC_CLASSES.value }, typeof value === "string" ? value : JSON.stringify(value)));
51
64
  }
52
65
  /**
66
+ * Render a leaf {@link WalkedField} entirely as a single HTML chunk.
67
+ * Used inside the streaming generators when descent into containers is
68
+ * complete.
69
+ *
70
+ * Delegates to the framework-agnostic {@link dispatchRenderField}
71
+ * dispatcher so resolver lookup, the (unused) widget step, error
72
+ * wrapping, and the unresolved-type fallback share one implementation
73
+ * with the sync HTML renderer and the React adapter.
74
+ *
75
+ * @param depth - Current recursion depth — defaults to `0` so the
76
+ * public signature stays additive. Callers inside the streaming
77
+ * pipeline thread the live `currentDepth` so the dispatcher's
78
+ * internal depth check is consistent with the streamField gate.
79
+ */
80
+ function renderLeaf(tree, value, mergedResolver, path, depth = 0) {
81
+ return dispatchRenderField({
82
+ tree,
83
+ value,
84
+ path,
85
+ depth,
86
+ resolver: mergedResolver,
87
+ config: {
88
+ buildProps: (fieldTree, fieldPath) => buildLeafProps(fieldTree, value, fieldPath),
89
+ lookupRenderFn: (type, htmlResolver) => getHtmlRenderFn(type, htmlResolver),
90
+ recursionSentinel: (fieldTree) => {
91
+ return recursionSentinelHtml(typeof fieldTree.meta.description === "string" ? fieldTree.meta.description : "schema");
92
+ },
93
+ fallback: (_fieldTree, fieldValue) => leafFallbackHtml(fieldValue),
94
+ coerceResult: (result) => typeof result === "string" ? result : void 0
95
+ }
96
+ });
97
+ }
98
+ /**
53
99
  * Drain {@link streamField} into a single string. Used when a streamed
54
100
  * sub-tree needs to be embedded inside a non-streaming chunk (e.g. as
55
101
  * children of a parent element).
@@ -87,7 +133,7 @@ function* streamField(tree, value, mergedResolver, path, rawResolver, currentDep
87
133
  const effectiveValue = value ?? tree.defaultValue;
88
134
  const type = tree.type;
89
135
  if (type === "string" || type === "number" || type === "boolean" || type === "enum" || type === "literal" || type === "file" || type === "unknown") {
90
- yield renderLeaf(tree, effectiveValue, mergedResolver, path);
136
+ yield renderLeaf(tree, effectiveValue, mergedResolver, path, currentDepth);
91
137
  return;
92
138
  }
93
139
  if (type === "union") {
@@ -110,7 +156,7 @@ function* streamField(tree, value, mergedResolver, path, rawResolver, currentDep
110
156
  yield* streamRecord(tree, value, mergedResolver, path, rawResolver, currentDepth, diagnostics);
111
157
  return;
112
158
  }
113
- yield renderLeaf(tree, value, mergedResolver, path);
159
+ yield renderLeaf(tree, value, mergedResolver, path, currentDepth);
114
160
  }
115
161
  function* streamObject(tree, value, mergedResolver, path, rawResolver, currentDepth, diagnostics) {
116
162
  if (tree.type !== "object") return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schema-components",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "React components that render UI from Zod schemas, JSON Schema, and OpenAPI documents",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,9 +29,10 @@
29
29
  "types": "./dist/themes/*.d.mts",
30
30
  "import": "./dist/themes/*.mjs"
31
31
  },
32
+ "./vue/*.vue": "./src/vue/*.vue",
32
33
  "./vue/*": {
33
- "types": "./dist/vue/*.d.mts",
34
- "import": "./dist/vue/*.mjs"
34
+ "types": "./src/vue/*.ts",
35
+ "import": "./src/vue/*.ts"
35
36
  },
36
37
  "./svelte/*.svelte": "./src/svelte/*.svelte",
37
38
  "./svelte/renderers/*.svelte": "./src/svelte/renderers/*.svelte",
@@ -52,6 +53,7 @@
52
53
  "files": [
53
54
  "dist",
54
55
  "src/svelte",
56
+ "src/vue",
55
57
  "LICENSE",
56
58
  "README.md",
57
59
  "CHANGELOG.md"
@@ -0,0 +1,274 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<SchemaComponent>` — Vue counterpart of the React `<SchemaComponent>`.
4
+ *
5
+ * Auto-detects the input format, normalises to JSON Schema via the
6
+ * adapter, walks the JSON Schema tree, and delegates per-field
7
+ * rendering to the {@link VueComponentResolver} supplied via
8
+ * `<SchemaProvider>` — falling back to the headless renderer when no
9
+ * provider is present.
10
+ *
11
+ * `onChange` semantics: the component accepts BOTH a `v-model`
12
+ * binding (Vue-idiomatic — `modelValue` prop +
13
+ * `update:modelValue` emit) and an explicit `onChange` callback prop
14
+ * (matching the React adapter). It also emits a `change` event for
15
+ * Vue authors who prefer event listeners. All three surfaces fire
16
+ * together so consumers may use whichever idiom suits them.
17
+ *
18
+ * @group Components
19
+ */
20
+ import { computed, h, toRaw, type VNode } from "vue";
21
+ import { walk } from "../core/walker.ts";
22
+ import type { WalkOptions } from "../core/walkBuilders.ts";
23
+ import { normaliseSchema } from "../core/adapter.ts";
24
+ import type { SchemaMeta, WalkedField } from "../core/types.ts";
25
+ import type { Diagnostic, DiagnosticsOptions } from "../core/diagnostics.ts";
26
+ import { SchemaNormalisationError } from "../core/errors.ts";
27
+ import { toRecordOrUndefined } from "../core/guards.ts";
28
+ import { VueResolverContext, VueWidgetsContext } from "./contexts.ts";
29
+ import { vueRenderField } from "./renderField.ts";
30
+ import { deriveIdPrefix, joinPath } from "./idPrefix.ts";
31
+ import type {
32
+ VueComponentResolver,
33
+ VueRenderProps,
34
+ VueWidgetMap,
35
+ } from "./types.ts";
36
+ import { VNodeHost } from "./VNodeHost.ts";
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ /** Zod schema, JSON Schema object, or OpenAPI document. */
41
+ schema: unknown;
42
+ /** For OpenAPI: a ref string like `#/components/schemas/User`. */
43
+ refPath?: string;
44
+ /** v-model binding for the current value. */
45
+ modelValue?: unknown;
46
+ /**
47
+ * Explicit `onChange` callback — wired in parallel with
48
+ * `update:modelValue` so consumers may use either surface.
49
+ */
50
+ onChange?: (value: unknown) => void;
51
+ /** Convenience: sets `readOnly` on all fields. */
52
+ readOnly?: boolean;
53
+ /** Convenience: sets `writeOnly` on all fields. */
54
+ writeOnly?: boolean;
55
+ /** Convenience: sets `description` on the root. */
56
+ description?: string;
57
+ /** Meta overrides applied to the root schema. */
58
+ meta?: SchemaMeta;
59
+ /** Per-field meta overrides — nested object mirroring schema shape. */
60
+ fields?: Record<string, unknown>;
61
+ /** Theme resolver. Overrides the context resolver when supplied. */
62
+ resolver?: VueComponentResolver;
63
+ /** Instance-scoped widgets — override context and global widgets. */
64
+ widgets?: VueWidgetMap;
65
+ /** Deterministic id prefix. Defaults to a per-instance `useId()` value. */
66
+ idPrefix?: string;
67
+ /** Called with each diagnostic emitted during schema processing. */
68
+ onDiagnostic?: (diagnostic: Diagnostic) => void;
69
+ /** When `true`, any diagnostic becomes a thrown error. */
70
+ strict?: boolean;
71
+ /** Called when schema normalisation fails. */
72
+ onError?: (error: SchemaNormalisationError) => void;
73
+ }>(),
74
+ {
75
+ refPath: undefined,
76
+ modelValue: undefined,
77
+ onChange: undefined,
78
+ readOnly: false,
79
+ writeOnly: false,
80
+ description: undefined,
81
+ meta: () => ({}),
82
+ fields: undefined,
83
+ resolver: undefined,
84
+ widgets: undefined,
85
+ idPrefix: undefined,
86
+ onDiagnostic: undefined,
87
+ strict: false,
88
+ onError: undefined,
89
+ }
90
+ );
91
+
92
+ const emit = defineEmits<{
93
+ "update:modelValue": [value: unknown];
94
+ change: [value: unknown];
95
+ }>();
96
+
97
+ // Consume the resolver and widget contexts. Both ports return
98
+ // `undefined` when no provider is mounted in scope — the dispatcher
99
+ // then falls through to the headless resolver.
100
+ const contextResolver = VueResolverContext.consume();
101
+ const contextWidgets = VueWidgetsContext.consume();
102
+
103
+ const rootPath = computed(() => deriveIdPrefix(props.idPrefix));
104
+
105
+ const mergedMeta = computed<SchemaMeta>(() => {
106
+ const merged: SchemaMeta = { ...props.meta };
107
+ if (props.readOnly) merged.readOnly = true;
108
+ if (props.writeOnly) merged.writeOnly = true;
109
+ if (props.description !== undefined) merged.description = props.description;
110
+ return merged;
111
+ });
112
+
113
+ const diagnostics = computed<DiagnosticsOptions | undefined>(() => {
114
+ if (props.onDiagnostic === undefined && !props.strict) return undefined;
115
+ const opts: DiagnosticsOptions = {};
116
+ if (props.onDiagnostic !== undefined) opts.diagnostics = props.onDiagnostic;
117
+ if (props.strict) opts.strict = true;
118
+ return opts;
119
+ });
120
+
121
+ interface Normalised {
122
+ jsonSchema: Record<string, unknown>;
123
+ rootMeta: SchemaMeta | undefined;
124
+ rootDocument: Record<string, unknown>;
125
+ error?: SchemaNormalisationError;
126
+ }
127
+
128
+ const normalised = computed<Normalised>(() => {
129
+ try {
130
+ const opts =
131
+ diagnostics.value !== undefined
132
+ ? { diagnostics: diagnostics.value }
133
+ : undefined;
134
+ // Vue wraps every reactive prop in a Proxy. Zod 4 schemas
135
+ // carry non-configurable internal data members (`_zod`) that
136
+ // the default reactivity proxy cannot mirror — accessing them
137
+ // through the proxy throws. `toRaw` recovers the original
138
+ // object before passing it into `normaliseSchema`, which does
139
+ // not need (and should not see) Vue's reactivity layer. The
140
+ // same fix is applied to `props.modelValue` further down for
141
+ // consistency with the React adapter, which receives raw
142
+ // values directly.
143
+ const rawSchema = toRaw(props.schema);
144
+ const result = normaliseSchema(rawSchema, props.refPath, opts);
145
+ return {
146
+ jsonSchema: result.jsonSchema,
147
+ rootMeta: result.rootMeta,
148
+ rootDocument: result.rootDocument,
149
+ };
150
+ } catch (err) {
151
+ const error =
152
+ err instanceof SchemaNormalisationError
153
+ ? err
154
+ : new SchemaNormalisationError(
155
+ err instanceof Error
156
+ ? err.message
157
+ : "Failed to normalise schema",
158
+ toRaw(props.schema),
159
+ "unknown"
160
+ );
161
+ return {
162
+ jsonSchema: {},
163
+ rootMeta: undefined,
164
+ rootDocument: {},
165
+ error,
166
+ };
167
+ }
168
+ });
169
+
170
+ const tree = computed<WalkedField | undefined>(() => {
171
+ const n = normalised.value;
172
+ if (n.error !== undefined) return undefined;
173
+ const walkOptions: WalkOptions = {
174
+ componentMeta: mergedMeta.value,
175
+ rootDocument: n.rootDocument,
176
+ };
177
+ if (n.rootMeta !== undefined) walkOptions.rootMeta = n.rootMeta;
178
+ const fieldsRecord = toRecordOrUndefined(props.fields);
179
+ if (fieldsRecord !== undefined) walkOptions.fieldOverrides = fieldsRecord;
180
+ if (diagnostics.value !== undefined)
181
+ walkOptions.diagnostics = diagnostics.value;
182
+ return walk(n.jsonSchema, walkOptions);
183
+ });
184
+
185
+ const effectiveValue = computed<unknown>(() => {
186
+ if (props.modelValue !== undefined) return props.modelValue;
187
+ return tree.value?.defaultValue;
188
+ });
189
+
190
+ function handleChange(next: unknown): void {
191
+ emit("update:modelValue", next);
192
+ // Vue auto-wires any `onChange="…"` template attribute as a
193
+ // `change` event listener (the same `on<Event>` convention React
194
+ // uses for synthetic events). To avoid invoking the same handler
195
+ // twice — once via `emit("change", …)` and once via
196
+ // `props.onChange(…)` — we emit the event when no explicit prop
197
+ // handler was supplied, and call the prop directly otherwise.
198
+ // Both paths are observable to consumers but never overlap.
199
+ if (props.onChange !== undefined) {
200
+ props.onChange(next);
201
+ } else {
202
+ emit("change", next);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Build the recursive `renderChild` closure. Each invocation increments
208
+ * the depth counter so the dispatcher's `MAX_RENDER_DEPTH` cap fires
209
+ * on truly recursive structures rather than on shallow trees.
210
+ */
211
+ function makeRenderChild(
212
+ currentDepth: number,
213
+ parentPath: string
214
+ ): VueRenderProps["renderChild"] {
215
+ return (
216
+ childTree: WalkedField,
217
+ childValue: unknown,
218
+ childOnChange: (v: unknown) => void,
219
+ pathSuffix?: string
220
+ ) => {
221
+ const childPath = joinPath(parentPath, pathSuffix);
222
+ return vueRenderField(
223
+ childTree,
224
+ childValue,
225
+ childOnChange,
226
+ props.resolver ?? contextResolver,
227
+ makeRenderChild(currentDepth + 1, childPath),
228
+ childPath,
229
+ props.widgets,
230
+ contextWidgets,
231
+ currentDepth + 1
232
+ );
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Reactive root VNode. Recomputed whenever any reactive dependency
238
+ * (props, contexts, tree) changes. The template renders it via
239
+ * `<component :is="rootVNode">` — Vue accepts a VNode object as the
240
+ * target of `:is`, which lets us drive the render from a render
241
+ * function while keeping the SFC `<template>` block as the single
242
+ * mount point.
243
+ */
244
+ const rootVNode = computed<VNode>(() => {
245
+ const t = tree.value;
246
+ if (t === undefined) {
247
+ const err = normalised.value.error;
248
+ if (err !== undefined) {
249
+ if (props.onError !== undefined) {
250
+ props.onError(err);
251
+ return h("span", { style: { display: "none" } });
252
+ }
253
+ throw err;
254
+ }
255
+ return h("span", { style: { display: "none" } });
256
+ }
257
+ const renderChild = makeRenderChild(0, rootPath.value);
258
+ return vueRenderField(
259
+ t,
260
+ effectiveValue.value,
261
+ handleChange,
262
+ props.resolver ?? contextResolver,
263
+ renderChild,
264
+ rootPath.value,
265
+ props.widgets,
266
+ contextWidgets,
267
+ 0
268
+ );
269
+ });
270
+ </script>
271
+
272
+ <template>
273
+ <VNodeHost :node="rootVNode" />
274
+ </template>
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<SchemaErrorBoundary>` — Vue counterpart of the React
4
+ * `SchemaErrorBoundary`.
5
+ *
6
+ * Uses Vue 3's `onErrorCaptured` lifecycle hook
7
+ * (https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured)
8
+ * to catch render-time errors thrown by any descendant — including
9
+ * the dispatcher-wrapped `SchemaRenderError` from theme adapters that
10
+ * throw inside their render function. Returning `false` from
11
+ * `onErrorCaptured` halts further propagation up the component tree.
12
+ *
13
+ * The fallback slot is invoked with the captured error and a `reset`
14
+ * callback. Calling `reset()` clears the captured error so the
15
+ * children re-render (e.g. after fixing a bad schema prop).
16
+ *
17
+ * Like the React boundary, this captures render-time and lifecycle
18
+ * errors but NOT errors thrown from event handlers (Vue routes those
19
+ * through a separate `errorHandler` on the app instance) or async
20
+ * code that escapes the component tree.
21
+ *
22
+ * @group Components
23
+ */
24
+ import { onErrorCaptured, ref } from "vue";
25
+
26
+ const captured = ref<Error | undefined>(undefined);
27
+
28
+ defineSlots<{
29
+ /**
30
+ * Default slot — rendered when no error has been captured.
31
+ */
32
+ default(): unknown;
33
+ /**
34
+ * Fallback slot — invoked with the captured error and a `reset`
35
+ * callback. Use it to render an error UI; call `reset` to clear
36
+ * the captured state and let the children re-render.
37
+ */
38
+ fallback(props: { error: Error; reset: () => void }): unknown;
39
+ }>();
40
+
41
+ onErrorCaptured((err) => {
42
+ captured.value =
43
+ err instanceof Error ? err : new Error("Unknown render error");
44
+ return false;
45
+ });
46
+
47
+ function reset(): void {
48
+ captured.value = undefined;
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <slot
54
+ v-if="captured !== undefined"
55
+ name="fallback"
56
+ :error="captured"
57
+ :reset="reset"
58
+ />
59
+ <slot v-else />
60
+ </template>