Skip to content

Vue-to-Gluon cutover playbook ​

This playbook moves an existing Vue 3 application through a reversible Custom Element boundary toward Gluon application ownership. It uses the production gluon-product-configurator from GLUON GOODS throughout; it does not introduce a second demonstration component.

Gluon does not parse or transform Vue source at this stage. The steps below are manual changes against public browser and Gluon contracts. They do not promise Vue runtime, directive, lifecycle, Router, Store, scoped-CSS, SFC, or SSR compatibility.

Rules that apply to every stage ​

  1. One framework owns a DOM subtree. A Vue render and a Gluon render never update the same DOM subtree.
  2. Structured input crosses a Custom Element boundary as a DOM property, not a string attribute. Output crosses it as a native CustomEvent with a typed detail payload.
  3. The host owns light DOM passed to native slots. The Custom Element owns its Shadow DOM, lifecycle, constructed stylesheet, and public form contract.
  4. State crosses the boundary as serializable snapshots or domain events. Do not share a process-global live store between frameworks or server requests.
  5. Exactly one Router owns a URL at a time. Exactly one renderer owns the server markup and hydration of a route at a time.
  6. A stage is complete only after its entry evidence remains green, its exit evidence is automated, and its rollback was identified before deployment.

Running production case ​

The Vue fixture below is built by Vite with Vue 3.5.39 and @vitejs/plugin-vue 6.0.7. The plugin classifies gluon-product-configurator as a Custom Element. Vue owns the product snapshot, the surrounding form, the named and default light-DOM slots, and the evidence panel. The Gluon element owns its product controls, configuration rules, Shadow DOM, adopted stylesheet, and form-associated value.

VueProductHost.vue is the actual compiled host source:

vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { products, type Product } from '../../examples/shop/src/data.js';
import {
  createDefaultProductConfiguration,
  type ProductConfiguration,
} from '../../examples/shop/src/product-configuration.js';
import type {
  ProductConfiguratorEvent,
} from '../../examples/shop/src/product-configurator.js';

const product = ref<Product>(products[0]!);
const configuration = ref<ProductConfiguration>(createDefaultProductConfiguration());
const addedLine = ref('No configured item has been added.');
const submittedValue = ref('The host form has not been submitted.');

const alternateProduct = computed(() => (
  product.value.slug === 'orbit-lamp' ? products[2]! : products[0]!
));

function changeProduct(): void {
  product.value = alternateProduct.value;
  configuration.value = createDefaultProductConfiguration();
  addedLine.value = `Vue changed the structured product property to ${product.value.name}.`;
}

function updateConfiguration(
  event: ProductConfiguratorEvent<'configuration-change'>,
): void {
  configuration.value = event.detail.configuration;
}

function addConfiguredProduct(event: ProductConfiguratorEvent<'add-to-bag'>): void {
  const { product: selectedProduct, configuration: selected } = event.detail;
  addedLine.value = [
    selectedProduct.name,
    selected.finish,
    selected.temperature,
    selected.cable,
  ].join(' · ');
}

function submitConfiguration(event: Event): void {
  const form = event.currentTarget as HTMLFormElement;
  submittedValue.value = String(new FormData(form).get('configuration'));
}
</script>

<template>
  <main class="vue-migration-host">
    <header class="vue-host-header">
      <div>
        <p class="eyebrow">Vue 3 host · incremental migration</p>
        <h1>One production Gluon boundary, two application owners.</h1>
      </div>
      <button class="host-action" type="button" data-use-product @click="changeProduct">
        Use {{ alternateProduct.name }}
      </button>
    </header>

    <section class="vue-host-layout" aria-label="Vue and Gluon coexistence fixture">
      <form @submit.prevent="submitConfiguration">
        <label class="visually-hidden" for="vue-product-configurator">Product configuration</label>
        <gluon-product-configurator
          id="vue-product-configurator"
          class="product-configurator"
          name="configuration"
          required
          :product.prop="product"
          :configuration.prop="configuration"
          @configuration-change="updateConfiguration"
          @add-to-bag="addConfiguredProduct"
        >
          <div slot="title" class="product-title-row">
            <div><h2 id="vue-product-title">{{ product.name }}</h2><p>{{ product.description }}</p></div>
            <strong>€{{ product.price }}</strong>
          </div>
          <ul class="product-facts">
            <li>Vue owns this light DOM</li>
            <li>Gluon owns the controls</li>
            <li>Native events cross the boundary</li>
          </ul>
        </gluon-product-configurator>
        <button class="host-submit" type="submit">Read native form value</button>
      </form>

      <aside class="vue-host-evidence" aria-live="polite">
        <p class="eyebrow">Observed by Vue</p>
        <h2>Boundary evidence</h2>
        <dl>
          <div><dt>Current configuration</dt><dd data-current-configuration>{{ configuration.finish }} · {{ configuration.temperature }} · {{ configuration.cable }}</dd></div>
          <div><dt>Last add event</dt><dd data-added-line>{{ addedLine }}</dd></div>
          <div><dt>Form value</dt><dd data-form-value>{{ submittedValue }}</dd></div>
        </dl>
      </aside>
    </section>
  </main>
</template>

The mount module registers the production element and adopts the same shop stylesheet contract used by GLUON GOODS:

ts
import { adoptStyles, unadoptStyles } from '@gluonjs/core';
import { createApp as createVueApp, type App } from 'vue';
import { shopStyles } from '../../examples/shop/src/styles.js';
import { registerProductConfigurator } from '../../examples/shop/src/product-configurator.js';
import VueProductHost from './VueProductHost.vue';
import { vueHostStyles } from './vue-host-styles.js';

export interface VueHostMount {
  readonly app: App<Element>;
  unmount(): void;
}

export function mountVueHost(target: string | Element = '#vue-host'): VueHostMount {
  registerProductConfigurator();
  adoptStyles(document, shopStyles, vueHostStyles);
  const app = createVueApp(VueProductHost);
  app.mount(target);
  return Object.freeze({
    app,
    unmount() {
      app.unmount();
      unadoptStyles(document, vueHostStyles, shopStyles);
    },
  });
}

if (document.querySelector('#vue-host')) mountVueHost();

The compiled Vue host and the production shop are exercised by tests/vue-migration-interop.spec.ts, tests/docs-examples.spec.ts, tests/shop-example.spec.ts, and the shop SSR/hydration suite. Those tests are the evidence for every source snippet on this page.

Stage 0 — Establish the baseline ​

Entry criteria ​

  • The current Vue production build and its customer-critical browser flows pass without Gluon changes.
  • Each candidate slice has an identified DOM owner, state owner, URL owner, stylesheet owner, server renderer, teardown hook, and rollback deployment.
  • Product inputs, emitted domain events, form behavior, slots, focus behavior, async work, and persisted state are recorded from observable behavior.

Work ​

Inventory leaves before routes or the application shell. For the running case, the leaf is product configuration: one typed product snapshot enters, native configuration and add-to-bag events leave, and one JSON form value participates in the host form. The surrounding product route and bag remain application concerns rather than component internals.

Exit criteria ​

  • The inventory names every boundary listed in the concept matrix below.
  • The existing Vue behavior has regression evidence that can be run before and after the first Gluon deployment.
  • A deployment can restore the previous Vue leaf without changing persisted customer data or the route URL.

Stage 1 — Replace one leaf with a production Custom Element ​

Entry criteria ​

  • Stage 0 evidence is green.
  • The Gluon element has a documented tag name in a versioned package release, typed public properties and events, native slot semantics, and deterministic disconnect cleanup.
  • The Vue compiler treats the tag as a Custom Element.

Work ​

Register the element before mounting the host when possible. Bind objects with Vue's .prop transport, listen to native events at the element, and place only Vue-owned light DOM in slots. Do not query or mutate the element's Shadow DOM from application code. Retain a typed reference only to invoke documented public methods or assign public properties.

The #88 case proves property assignment before and after definition, reactive property replacement, native event payloads and flags, named/default slots, stable element identity, disconnect/reconnect cleanup, adopted stylesheets, focus and label behavior, form submission/reset/state restore/validation, and the exact configured line item added to the bag.

Exit criteria ​

  • The production customer flow uses the same registered element in GLUON GOODS and the Vue host.
  • Chromium, Firefox, and WebKit evidence covers the boundary behavior.
  • Removing the Vue host also disconnects listeners, reactive scopes, async work, and adopted host styles without retaining the element.

Stage 2 — Transfer component and form state ​

Entry criteria ​

  • Stage 1 is green in production-equivalent builds.
  • The team has classified each value as host application state, element-local interaction state, form state, or cross-route domain state.

Work ​

Move only element-local state into Gluon reactivity. Keep application state in the current Vue owner until its whole consumer slice moves. Pass immutable snapshots into the element and consume domain events; do not hand a Vue ref, Pinia store, Gluon ref, or Gluon Store instance across the boundary.

Let the native form own submission, reset, disabled propagation, constraint validation, and state restoration. The host may read the element's public form value through FormData; it must not mirror the element's private control tree.

Exit criteria ​

  • Every mutable value has exactly one writer and a documented lifetime.
  • Form submission and reset work through native form APIs with and without the Vue host.
  • Application-level state still has one owner, and element-local cleanup passes repeated mount/unmount retention tests.

Stage 3 — Cut over routes, shared state, and async UI ​

Entry criteria ​

  • All leaves needed by one complete route have passed Stage 2.
  • Direct navigation, reload, back/forward, route parameters, query parameters, guards, scroll behavior, lazy loading, async cancellation, and error recovery are covered for that route.

Work ​

Choose route ownership at the deployment boundary. A Vue-owned URL continues through Vue Router. A Gluon-owned URL uses createRouter, one supported history, RouterView, and Gluon route records. Do not install two history listeners that both navigate the same URL.

Create one StoreManager per Gluon application with createStoreManager() and instantiate definitions with definition.use(manager). For server rendering, create and dispose a manager per request. Transfer only validated snapshots or domain events while Vue-owned routes remain; never expose a process-global live store to both applications.

Async ownership follows route ownership. The departing owner aborts its pending work. The arriving owner implements its own pending, error, retry, and teardown behavior; Vue <Suspense> and Gluon Suspense do not share a live task.

Exit criteria ​

  • Deep links, reload, back/forward, redirects, failures, and cancellation pass under the new single Router owner.
  • Cross-route state is created and disposed with the new application/request owner and has explicit snapshot hydration where required.
  • The old Vue route registration, loaders, guards, and store consumers for the migrated URL are removed.

Stage 4 — Cut over styles and universal rendering ​

Entry criteria ​

  • Stage 3 client navigation is green.
  • The route's server renderer, hydration payload, asset manifest, stylesheet order, mismatch policy, and request-state lifetime are recorded.

Work ​

Move global route styles to constructed CSSStyleSheet instances owned by the Gluon application and component styles to sheets adopted by each Shadow Root. Remove Vue scoped-style selectors only after the last Vue-owned node that needs them is gone. Do not add a <style> fallback.

For a Gluon-owned route, render with the public @gluonjs/ssr request APIs, serialize request-local Router and Store snapshots, and hydrate that Gluon markup once on the client. A Vue renderer must not hydrate the same DOM subtree. The Vue coexistence fixture is client-rendered; #88's server/hydration evidence comes from the production GLUON GOODS route, so no Vue-SSR interoperability claim is made.

Exit criteria ​

  • The response contains the expected initial content and stylesheet ownership before client JavaScript runs.
  • Hydration preserves the tested element identity and reports no unexpected mismatch.
  • Request state is isolated and disposed; client navigation and the configured add-to-bag flow remain green after hydration.

Stage 5 — Transfer the shell and remove Vue ​

Entry criteria ​

  • Every production URL, global state consumer, overlay, focus boundary, global stylesheet, server entry, and hydration entry has a Gluon owner.
  • No remaining third-party feature requires a Vue application context.
  • Production and rollback artifacts for the final Vue-backed release are retained according to the application's release policy.

Work ​

Move the final shell as one ownership change. Remove the Vue mount, Vue Router, Pinia/Vuex managers, Vue-only build plugins, .vue compilation, Vue-only CSS, and dependencies only after repository search and production bundle inspection show no remaining consumer. Keep browser platform contracts such as native Custom Events and forms; they are not temporary compatibility code.

Exit criteria ​

  • A clean install, typecheck, test, server build, client build, and production bundle scan pass without Vue or its build plugin.
  • All supported URLs, forms, dialogs, focus returns, persistence, server responses, hydration, and critical customer flows pass in every configured automated engine target.
  • Rollback means deploying the retained prior artifact and compatible data contract, not running two application owners on the same subtree.

Boundary and rollback matrix ​

StageVue ownerGluon ownerTransportTeardown responsibilityRollback point
0 — BaselineEntire current applicationNone in the live Vue treeRecorded inputs, outputs, URLs, and snapshotsExisting Vue applicationLast verified Vue artifact
1 — LeafRoute, product snapshot, light DOM, host form, bagProduct controls, Shadow DOM, configuration rules, form-associated valueDOM properties, native slots, CustomEvent, FormDataVue unmounts host listeners; element disconnects its scope and async workRender the retained Vue leaf
2 — State/formApplication and cross-route domain stateElement-local interaction and native form stateImmutable snapshots in; domain events and form value outThe owner that created each scope/store/listenerRestore the Stage 1 boundary and state adapter
3 — RouteOnly URLs not yet migratedMigrated URL, route async work, request/app Store managerNavigation at disjoint URL ownership; serialized snapshots at deployment/request boundariesDeparting Router removes routes/listeners; each app disposes its managerRoute migrated URLs to the prior Vue artifact
4 — UniversalOnly remaining Vue-rendered routes and their CSSMigrated route server render, hydration, assets, constructed stylesSerialized Router/Store request state and generated asset referencesEach renderer disposes request state; only the owning client hydratesServe the retained Stage 3 client/previous server artifact
5 — ShellNoneFull application, URLs, state, styles, server and client renderPublic Gluon and browser contracts onlyGluon application/request disposalRedeploy the final verified Vue-backed artifact

Concept and ownership matrix ​

ConcernVue-side contractGluon-side contractSemantic difference to verifyRemove from Vue when
Props / propertiesVue props and .prop on a Custom ElementDeclared GluonElement propertiesObjects cross the native boundary as properties; attributes remain stringsThe receiving subtree has a Gluon owner
EventsComponent emits or native listenersTyped native CustomEventVerify detail, bubbling, composition, cancellation, and event nameNo Vue listener consumes the domain event
SlotsVue slots or host light DOMNative Shadow DOM slots or typed scoped-slot functionsNative slot fallback and light-DOM ownership differ from Vue slot renderingThe old component no longer renders that slot
FormsVue bindings plus native form APIsForm-associated Custom Element and ElementInternalsSubmission, reset, validation, labels, focus, disabled state, and restore are native contractsThe Vue control/model mirror is gone
RefsVue template refsTyped element reference or Gluon refA host reference reaches only public element APIs, not private Shadow DOMThe Vue owner no longer calls the element
ReactivityVue ref, computed, watchers and effect scopes@gluonjs/reactivity refs, computed values, watchers, and scopesScheduling and cleanup must be tested; live refs do not cross ownersAll consumers in that scope moved together
RouterVue Router records, guards and viewscreateRouter, supported histories, RouterView, public route APIsRecord shapes, guards, lazy loading, scroll and failures are redesignedThe complete URL belongs to Gluon
StorePinia/Vuex application managerdefineStore plus an application/request StoreManagerStore definitions are reusable; live instances belong to a manager and are not global bridgesEvery consumer and hydration path moved
Async UIVue async components or <Suspense>Gluon async components and SuspenseCancellation, pending/error/retry UI and teardown are separate implementationsThe old route/component cannot start work
StylesVue global or scoped CSSConstructed sheets and adoptedStyleSheetsSelector rewriting is not compatible; ownership and adoption order are explicitNo Vue-owned node needs the sheet
SSR / hydrationVue server renderer and Vue hydration@gluonjs/ssr render, request snapshots, and Gluon hydrationOne renderer and one hydrator own a route subtree; snapshots are request-localThe route is rendered and hydrated by Gluon
TestsVue unit/component/E2E suitesGluon unit, browser integration, SSR/hydration, and E2E evidencePreserve observable behavior; framework-private assertions are rewrittenEquivalent public behavior is covered
Production buildVue plugin, SFC compiler, Vue chunksGluon TypeScript/Vite/server/static entriesClean-install output, assets, deployment fallback, and bundle contents are verifiedNo source, plugin, chunk, or runtime import remains

Verification and rollback runbook ​

Run the narrow coexistence evidence during Stages 1 and 2:

sh
npx vitest run tests/vue-migration-interop.spec.ts tests/docs-examples.spec.ts tests/shop-example.spec.ts
npm run build:shop
npm run build:docs-examples

Run documentation evidence whenever this playbook or its embedded sources change:

sh
npm run check:docs

For each route cutover, add direct-link, reload, back/forward, async failure, teardown, server response, hydration, and production-build evidence before changing traffic. Record the previous artifact identifier and the data contract it expects. If the new route violates its exit criteria, restore traffic to that artifact; do not mount Vue over a Gluon-owned subtree as an emergency fallback.

The repository-wide release gate remains:

sh
npm run check

The output of these commands verifies compiled sources and observable behavior. It does not authorize automatic Vue source rewriting. RFC 0003 and issue #91 provide a report-only reader with an explicit syntax, reporting, privacy, and failure contract. A source writer remains prohibited without another accepted RFC.

Version-matched documentation. Examples are checked against the public package contracts.