@runic-artifex/vue

Vue composables for generated Runic Views clients
2,081
Install
npm install @runic-artifex/vue
Latest Version:0.10.0
Vue:3
License:MIT
Last Updated:Oct 22, 2026
Links: GitHub  ·  npm
Maintainer: viktorjannicke

@runic-artifex/vue

Vue composables and outlet for clients generated by Runic Views. The generated modules import @runic-artifex/views, which this package also builds on, so install both:

npm install @runic-artifex/vue@preview @runic-artifex/views@preview
pnpm add @runic-artifex/vue@preview @runic-artifex/views@preview
bun add @runic-artifex/vue@preview @runic-artifex/views@preview

Previews are published under the preview dist-tag. Install the versions that match your Runic SDK packages. Vue 3.5.30 or a later 3.x release is supported.

<script setup lang="ts">
import { useCommand, useView } from "@runic-artifex/vue";
import type { CounterPageReference } from "./generated/counter.js";

const props = defineProps<{ page: CounterPageReference }>();
const { state, client, error } = useView(() => props.page);
const { run: increment, pending, error: incrementError } = useCommand(() => client.value?.increment());
</script>

<template>
  <button :disabled="!client || pending" @click="increment()">{{ state?.count ?? "…" }}</button>
  <p v-if="(incrementError ?? error) !== undefined" role="alert">{{ String(incrementError ?? error) }}</p>
</template>

Every composable returns its state as shallow refs, so a handle can be destructured in <script setup> and its refs unwrap in the template.

useView(source, options?)

Returns shallow refs { state, client, error, pending } and retry(). options.release(client) replaces client.dispose() for a client the composable connected:

  • A generated page reference, or { connect: connect<Name> } for a root ViewModel, is connected while the current effect scope (normally the component) is active. Disposing the scope or changing the reference disposes the client. A reference is identified by its connect function, so a getter that returns a new { connect: connectWorkspace } object does not reconnect.
  • An already connected client is observed and left to its owner.
  • The source can be a value, a ref or a getter such as () => props.page.
  • pending is true while a reference connects. error holds a failed connection; retry() connects again.

All four composables release their controller when the current effect scope is disposed. Call them in a component's setup() (or <script setup>) or inside effectScope().run(), and stop that scope to release them. Called outside an effect scope, for example from a plain module or an event handler, nothing ever releases them: useView keeps its client connected. Development builds warn in the console when that happens.

Read-only state and v-model

As in any MVVM View, the ViewModel's C# setters decide what the page may set. Read state one way from state. A property with a public C# setter also gets a set<Property> client method; wrap it in a writable computed to use v-model. Status with a private set, such as IsDirty or Error, has no setter and is only rendered:

<script setup lang="ts">
// C#: public partial int Count { get; private set; }  public partial int Step { get; set; }
import { computed } from "vue";
import { useCommand, useView } from "@runic-artifex/vue";
import type { CounterPageReference } from "./generated/counter.js";

const props = defineProps<{ page: CounterPageReference }>();
const { state, client } = useView(() => props.page);
const { run: setStep } = useCommand((value: number) => client.value?.setStep(value));
const step = computed({ get: () => state.value?.step ?? 1, set: value => void setStep(value) });
</script>

<template>
  <p>{{ state?.count ?? "…" }}</p>
  <select v-model.number="step">
    <option :value="1">1</option>
    <option :value="5">5</option>
  </select>
</template>

ViewOutlet

Renders the component registered for a generated reference's kind and passes the reference as its page prop. A different reference remounts the component, a kind without a component renders an alert, and the default slot renders while content is empty:

<script setup lang="ts">
import { useView, ViewOutlet, type ViewRegistry } from "@runic-artifex/vue";
import { connectWorkspace, type WorkspaceState } from "./generated/workspace.js";
import CounterPage from "./pages/CounterPage.vue";
import WelcomePage from "./pages/WelcomePage.vue";

const pages = { counter: CounterPage, welcome: WelcomePage } satisfies ViewRegistry<WorkspaceState["main"]>;
const { state } = useView({ connect: connectWorkspace });
</script>

<template>
  <ViewOutlet :content="state?.main" :registry="pages"><p>Connecting…</p></ViewOutlet>
</template>

satisfies ViewRegistry<…> checks that every kind has a component whose page prop accepts that reference.

useCommand(command)

Returns { run, pending, error, failure, reset }, with pending, error and failure as shallow refs. run(...args) resolves to the command's result, or to undefined after a failure, which error then holds until the next run. It never rejects, so handlers need no try/catch. One command can serve several buttons:

const { run: navigate } = useCommand((name: "showWelcome" | "showCounter") => client.value?.[name]());
// <button @click="navigate('showWelcome')">Welcome</button>

For a command that declares a failure ([RunicFailure] in .NET), run resolves the BridgeOutcome, and failure holds the declared failure of the latest run, typed from the outcome, while error keeps unexpected failures. Starting a run and reset() clear both, and a run superseded by a later run or by reset() sets neither.

useOperation(start, options?)

Observes a generated start<Command>() operation through the framework-neutral operation controller and returns each state field as a shallow ref, with run, cancel and reset:

<script setup lang="ts">
const { run: push, cancel, pending, cancellationRequested, cancelled, error } =
  useOperation((remote: string) => client.value?.startPush(remote));
</script>

<template>
  <button :disabled="!client || pending" @click="push('origin')">Push</button>
  <button v-if="pending" :disabled="cancellationRequested" @click="cancel()">Cancel</button>
  <p v-if="cancelled" role="status">Push cancelled.</p>
  <p v-if="error !== undefined" role="alert">{{ String(error) }}</p>
</template>

The result has pending, admitting, operation, status, outcome, failure, error, cancelled, cancellationRequested, cancelling, cancellation and cancelError, with run(...args), cancel() and reset(), which never reject. A run that ends with a cancelled status sets cancelled and resolves to undefined without an error; failed and timed-out statuses are still error. pending stays true after a Cancel response until the operation reaches its terminal status. Disposing the effect scope settles run promises without cancelling accepted work.

useLatestOperation()

Admits only the newest of several reads, for example loading the item the user selected last, through the framework-neutral latest-operation controller, and returns each state field as a shallow ref, with run and clear. Each run(intent) replaces the queued intent and cancels the active read; the next read starts only after the previous one reaches its terminal status and the intent's optional waitForCompletion barrier:

<script setup lang="ts">
const { run: load, outcome, blocked } = useLatestOperation<Item>();
function select(id: string) {
  const current = client.value;
  if (current) void load({ start: () => current.startLoad(id), isCurrent: () => client.value === current });
}
</script>

<template>
  <ItemView v-if="outcome?.ok" :item="outcome.value" />
  <p v-if="blocked" role="alert">Reconnect before selecting again.</p>
</template>

The result has the useOperation fields plus blocked, with run(intent) and clear(mode?), which never reject. The intent carries start and the optional cancel, waitForCompletion and isCurrent callbacks, so it captures the client or session it belongs to. blocked means admission is uncertain, for example after a lost Start reply; only clear("replace"), called after the application has established a fresh session, clears it. clear() drops the queued intent and cancels the active read. Disposing the effect scope settles run promises without cancelling accepted work.

Errors

The error of useView and of a command or operation handle is typed unknown: it holds whatever the connection or the call rejected with. A Bridge failure is a BridgeError from @runic-artifex/views; narrow it with instanceof before reading kind, route or detail:

import { BridgeError } from "@runic-artifex/views";

const offline = error.value instanceof BridgeError && error.value.kind === "disconnected";

instanceof holds for errors from every copy of the package on the page. Errors and diagnostics lists the kinds. Any other value, such as an exception that your own connect function or command callback throws, arrives unchanged.

The composables run in the browser or webview that a Runic host serves. useView connects during setup(), which also runs in a server render: render the components that use it on the client only.

Interactions

A ReactiveUI Interaction<TInput, TOutput> on the ViewModel, such as the question of a ReactiveLeaveConfirmation.WithInteraction leave guard, is client.interactions.<name> on the generated client. Register the page's answer in watchEffect: handle((prompt, { signal }) => …) returns the function that removes it, so pass that to onCleanup, and Vue removes the handler when the client changes or the component unmounts:

<script setup lang="ts">
import { watchEffect } from "vue";
import { useView } from "@runic-artifex/vue";
import type { EditPageReference } from "./generated/edit.js";

const props = defineProps<{ page: EditPageReference }>();
const { client } = useView(() => props.page);
watchEffect(onCleanup => {
  const remove = client.value?.interactions.confirmDiscard.handle(
    async (prompt, { signal }) => confirmDialog(`Discard the changes to ${prompt.itemName}?`, signal),
  );
  if (remove) onCleanup(remove);
});
</script>

prompt and the answer are typed from the ViewModel's interaction. The handler may return the answer or a promise of it; confirmDialog stands for the application's own dialog.

  • The page is asked for a departure that its own window starts: its Close, the shell's Back in the same window, or a guarded window close.
  • signal aborts when .NET no longer needs the answer, for example when the departure is superseded or the window closes. Close the dialog then; a late answer is ignored.
  • The handler exists only after the client connected and the effect ran. A departure before that is answered by the ViewModel's .NET fallback handler, or fails closed without one.

The ReactiveUI leave confirmation has the .NET side and which View answers which departure. In a frontend test, the generated mock's interactions.<name>.request(input) resolves with the registered handler's answer; see the Testing guide.

useCollectionViewport(options)

Follows a fixed-row-height scroll container and returns { viewport, attach }. viewport is a shallow ref with the collectionViewport range; attach is a function ref for the container. Bind it to a native element such as a <div>: a component instance (:ref="attach" on a component) or a non-HTML element detaches the viewport, and development builds warn. Pass a getter for the options and watch the range to send it to the ViewModel:

<script setup lang="ts">
const { state, client } = useView(() => props.page);
const { viewport, attach } = useCollectionViewport(() => ({ totalCount: state.value?.totalCount ?? 0, rowHeight: 32 }));
watch(() => [viewport.value.start, viewport.value.size] as const, ([start, size]) => {
  if (size !== 0) void client.value?.setViewport({ start, size });
});
</script>

<template>
  <div :ref="attach" style="height: 480px; overflow: auto">
    <div :style="{ height: `${viewport.totalSize}px` }"><!-- state.rows --></div>
  </div>
</template>

Related Packages

druxt-search

Provides JSON API Search integration for a Druxt (DRUpal nuXT) project.

982 0
@dokebi/template-inertia

Inertia v3 server adapter for Dokebi: page JSON, Vite manifest assets and option...

627
@nativescript-dom/vue-types

TypeScript definitions for @nativescript/core views exposed as JSX intrinsic ele...

11,577 28
druxt-views

Drupal Views components for Druxt with support for filters, pagination and sorti...

16,350 115

Version History

Version Released License
0.10.0 MIT
0.9.1 MIT
0.9.0 MIT
Pre-releases (17)
Version Released License
0.8.0-preview.6 MIT
0.8.0-preview.5 MIT
0.8.0-preview.4 MIT
0.8.0-preview.3 MIT
0.8.0-preview.2 MIT