Skip to content

@deviltea/widget-vue ​

@deviltea/widget-vue is a thin Vue 3 integration over @deviltea/widget-core Runtime semantics. It owns exactly three things — a keyed renderer registry, a recursive root renderer, and the useWidget(Plugin) bridge — and reimplements no core semantics of its own.

This guide covers the full public surface. For installation and a minimal end-to-end example, see the package README. Cross-cutting Diagnostic/Result/Failure/Error conventions are maintained in the Widget API conventions. For an interactive playground built on this integration, see the Widget Lab.

Core model ​

text
createWidgetVueRenderer(system, build) -> WidgetRenderer component
    build: renderers => renderers.TypeA(ComponentA).TypeB(ComponentB)...

<WidgetRenderer :runtime="runtime" />
    -> recursive internal host tree, one host per rendered widget

useWidget(Plugin)  (called inside a registered renderer component)
    -> { useState, useProperties, useMethods,
         useStateDiagnostics, usePropertyDiagnostics, useMethodDiagnostics,
         useDiagnostics, WidgetSlot }
    each accessor present only if Plugin declares that capability

Renderer registry ​

createWidgetVueRenderer(system, build) uses the same keyed section-typestate model @deviltea/widget-core uses for state/properties/methods: the callback receives a keyed-chain builder whose Remaining type parameter starts as system's exact plugin-type union and shrinks by one literal per registered key. The callback can only type-check once every plugin type has been registered exactly once — there is no .register(type, component), .add(...), or mutable post-construction registry.

ts
const WidgetRenderer = createWidgetVueRenderer(
	system,
	renderers =>
		renderers
			.Section(SectionRenderer)
			.Text(TextRenderer)
			.Input(InputRenderer),
)
  • Builder keys cover the same arbitrary string-literal domain as registered plugin.type values, including non-identifier strings via bracket access (renderers['some-weird-type'](Component)) and collision-hazard names (__proto__, constructor).
  • A system whose plugin-type union has been broadened to plain string can never produce a complete registry: Exclude<string, AnyLiteral> stays string, so the callback's return type can never satisfy the required Remaining extends never completion.
  • Construction also validates coverage against the actual system instance at runtime (type-level completeness alone cannot stop any/untyped call sites): missing, unknown, or duplicate renderer registration all throw WidgetVueIntegrationError (code invalid-renderer-registry) — a programmer/configuration exception, never a Widget Diagnostic.
  • The returned component is bound to the exact WidgetSystem instance supplied; a WidgetSystemRuntime from a different (even structurally identical) WidgetSystem is rejected the same way.

Root renderer contract ​

vue
<WidgetRenderer :runtime="runtime" />
  • runtime: WidgetSystemRuntime<Plugins> is the only semantic prop. There is no raw source, Blueprint, root-widget, fallback-renderer, or loading/error-slot prop.
  • Mount/render time validates runtime.blueprint.system === boundSystem.
  • If the runtime prop's identity changes, the entire internal host/renderer tree fully unmounts and a fresh tree mounts — even when the root id/type are identical — so every Vue bridge subscription from the previous Runtime is guaranteed to be cleaned up.
  • WidgetRenderer never calls runtime.dispose(), on unmount or replacement. Runtime lifetime is owned by the caller.

Internally, the package owns a private recursive host tree: WidgetRenderer renders an internal host per widget, which provides the current RuntimeWidget through a private Vue injection context and renders the registered component for that widget's exact plugin type. Renderer components never receive a widget prop — they call useWidget(Plugin), and Plugin doubles as the compile-time type witness and a runtime exact-identity assertion (a mismatch throws WidgetVueIntegrationError with code widget-plugin-mismatch).

useWidget(Plugin) ​

ts
const {
	useState,
	useProperties,
	useMethods,
	useStateDiagnostics,
	usePropertyDiagnostics,
	useMethodDiagnostics,
	useDiagnostics,
	emit,
	WidgetSlot,
	widgetId,
	widgetType,
} = useWidget(SectionPlugin)

Every accessor is derived from Plugin's declared WidgetInterfaces: an absent capability drops the corresponding accessor entirely; an explicitly-declared-empty capability (for example state: Record<never, never> or the canonical explicit-empty slots spelling slots: never) keeps the accessor present with an empty (or, for WidgetSlot, never-typed) keyed surface. Presence is read from plugin.capabilities at runtime and from HasWidgetCapability<Interfaces, Key> at the type level — both declaration-presence facts, never a [Payload] extends [never] test, since a legitimately-present capability can itself have payload never. useDiagnostics() and WidgetSlot's gating aside, this mirrors exactly how @deviltea/widget-core's own RuntimeStateSurface/RuntimePropertySurface/ RuntimeMethodSurface gate state/properties/methods on the Runtime widget itself.

Every useXxx() call returns a typed keyed Proxy-like surface. Obtaining the surface, and accessing a member off it, never reads or subscribes the Runtime — only materializes/caches that member's Vue wrapper. Both the capability-surface object and each member wrapper are stable/cached within one useWidget(Plugin) call.

State ​

ts
const { budget } = useState()

budget.value // T | null
budget.value = 100_000
budget.value = null
  • Backed by a customRef-style projection with no authoritative mirrored cache. The first .value read activates exactly one Runtime subscription; the Runtime subscription's listener only trigger()s Vue — the next getter always re-reads the Runtime directly.
  • The setter delegates to RuntimeState.set() and performs no optimistic local write. null is a legal Vue-boundary candidate; the Runtime's own validate remains authoritative and may reject it.
  • If a candidate is rejected, the bridge explicitly invalidates Vue so consumers (in particular v-model) re-read the authoritative Runtime value instead of keeping the rejected candidate.

Properties ​

ts
const { estimatedCost } = useProperties()

estimatedCost.value // T | null, readonly

An ExecutionResult with ok: true projects its value; the ok: false branch projects to null, with no last-successful fallback. The ExecutionResult itself is never exposed through this surface.

Methods ​

ts
const { submit } = useMethods()

submit(...args) // ReturnType<Fn> | null

useMethods() exposes lazy, stable callable wrappers shaped (...args: Parameters<Fn>) => ReturnType<Fn> | null — not refs, and no subscription is created merely by obtaining or calling one. Semantic ok projects to the returned value; semantic failure projects to null. Implementation-contract exceptions and disposed-Runtime errors propagate unchanged. Arbitrary legal method names, including JavaScript-special ones such as then, receive no special handling.

Diagnostics ​

ts
const { budget: budgetDiagnostics } = useStateDiagnostics()
const { estimatedCost: estimatedCostDiagnostics } = usePropertyDiagnostics()
const { submit: submitDiagnostics } = useMethodDiagnostics()
const diagnostics = useDiagnostics()

useStateDiagnostics() / usePropertyDiagnostics() / useMethodDiagnostics() mirror the corresponding Runtime primitive's getDiagnostics()/subscribeDiagnostics() through the same lazy keyed-Proxy model as their value counterpart, independently lazy per member. useDiagnostics() mirrors the widget-level aggregate — RuntimeWidget.getDiagnostics()/subscribeDiagnostics() — never the Runtime-wide WidgetSystemRuntime.getDiagnostics(). Every projection preserves the core snapshot objects and order exactly: no message parsing, no reclassification, no invented aggregation.

Events ​

ts
const { emit } = useWidget(SectionPlugin)

emit.saved(...args) // void

emit is present only when the plugin declares an events capability. Each member is a callable typed from the declared event arguments and returning void. Looking up or materializing a member is passive: it neither subscribes nor acquires emit authority, and a name that is not a declared event yields undefined. The first call acquires the widget's Core event emitter and forwards the arguments to it.

Identity ​

widgetId and widgetType are always present on the useWidget() result, regardless of declared capabilities. They are plain readonly values (not refs) projected once from the current rendered widget: widgetId is its semantic/Blueprint id (string) and widgetType is its plugin type. The adapter itself stamps no DOM attributes from them; a renderer may project them onto its own markup.

Slots ​

vue
<WidgetSlot name="content" />

Present on the useWidget() surface only when the plugin declares a slots capability, typed to its exact declared slot-name union. WidgetSlot is syntactic sugar over one shared internal component identity — obtaining it repeatedly, across widgets or useWidget() calls, never allocates a new component definition. It renders the current widget's semantic slot children recursively, in declaration order, with no filtering, sorting, or fallback-renderer behavior of its own.

What useWidget() deliberately does not expose ​

No runtime, system, or unrestricted getWidget escape hatch. Every cross-widget interaction stays mediated by @deviltea/widget-core dependencies (registerDeps); renderer code cannot reach across the widget tree outside that mechanism.

Integration errors ​

Caller-reachable misuse throws WidgetVueIntegrationError (exported from the root). Discriminate by class and code; message is descriptive only. The code union is exported as the type WidgetVueIntegrationErrorCode.

codeCondition
invalid-renderer-registrycreateWidgetVueRenderer coverage is not exactly-once against the bound System
runtime-system-mismatchthe runtime prop was not created from the exact bound System
outside-widget-rendereruseWidget() or WidgetSlot is used without a current widget host
widget-plugin-mismatchuseWidget(Plugin) and the current widget's exact plugin instance differ
readonly-projection-writea Property or Diagnostics ref is written

invalid-renderer-registry reports every defect together in three fields: missingTypes (in System plugin order), unknownTypes and duplicateTypes (each in first-registration order). They are frozen arrays for this code (possibly empty) and null for every other code. The other codes carry no extra fields.

ts
import { createWidgetVueRenderer, WidgetVueIntegrationError } from '@deviltea/widget-vue'

try {
	createWidgetVueRenderer(system, build)
}
catch (error) {
	if (error instanceof WidgetVueIntegrationError
		&& error.code === 'invalid-renderer-registry') {
		// error.missingTypes, error.unknownTypes, error.duplicateTypes
	}
}

runtime-system-mismatch is checked before any other root-render check. Core errors (for example WidgetSystemRuntimeDisposedError and the /integration errors) and exceptions thrown by your own callbacks propagate unchanged.

States that the exact-plugin, registry and System-identity checks make impossible are internal invariants. They throw a plain Error, not WidgetVueIntegrationError, and are not part of the contract. Only an object that imitates a Runtime can reach them.

Server rendering ​

Server-side rendering of WidgetRenderer is unsupported under the current contract: do not render a long-lived or shared WidgetSystemRuntime through Vue's server renderer. This is a declared non-goal, not a bridge bug awaiting a fix; there is no SSR-specific code path, flag, or hydration guarantee. Client-side rendering is the supported mode, including renderers mounted only on the client inside an SSR application.

Why: the first .value read of a State, Property, or Diagnostics ref activates one Core subscription, and each useWidget() bridge releases all of its subscriptions together in onScopeDispose. Vue's server renderer runs setup() and render but never stops component effect scopes, so those cleanup callbacks never run. Every server render therefore leaves its activated subscriptions registered with the Runtime until runtime.dispose(). With a long-lived Runtime they accumulate on each request, growing memory without bound and fanning every later change out into stale customRef triggers.

Creating a Runtime per request and disposing it after rendering does release these subscriptions, because the Runtime owns their teardown. That pattern is still unsupported, because the open gaps below remain.

A future SSR design would need, and this guide does not decide:

  • an explicit render lifetime (snapshot-only reads with no Core subscription activation during SSR, or request-scoped cleanup owned by the adapter);
  • a Runtime ownership model (per request or shared) consistent with "WidgetRenderer never disposes the Runtime";
  • a hydration contract for how client Runtime state matches server output, given that Runtime state is never part of authored source, and how mismatches are handled;
  • defined emit and Method semantics during server render;
  • conformance tests under @vue/server-renderer, including subscription-count assertions across repeated renders against one Runtime.

Design constraints ​

  • No domain widgets. Survey/Product Prototype (or any other) domain widgets stay out of this package.
  • No editor-domain UI. Lab inspectors, source editors, and similar editor-domain operations stay out of this package.
  • No persistence. Persistence, versioning, and migration stay out of this package.
  • No showcase state. Showcase-specific application state or business rules stay out of this package; they belong to the downstream consumer (widget-lab).
  • Core stays authoritative. This package never reimplements or reinterprets @deviltea/widget-core semantics; it only projects the existing Runtime surface into Vue-native reactivity.

The current canonical Widget architecture authority is DevilTea/widget Discussion #12. It contains the migrated historical Vue integration (diagnostic #13) and Core (diagnostic #10) decisions together with current accepted amendments; those current Discussion #12 decisions are authoritative over this guide.

Released under the MIT License.