Skip to content

mindtwo/app-component

Repository files navigation

@mindtwo/app-component

Helper for mounting Vue 3 components into the DOM as custom HTML elements. This utility simplifies embedding Vue components dynamically onto specific DOM elements and facilitates the seamless integration of Vue components into non-SPA environments (e.g. legacy CMS, server-rendered pages, multi-page apps).

Table of Contents

Introduction

The @mindtwo/app-component package is a utility that allows you to easily mount Vue components onto specific HTML elements through native custom elements. This is particularly useful when integrating Vue into existing applications where Vue isn't controlling the entire page. The package handles the lifecycle of Vue components, including mounting, unmounting, hooks and prop forwarding from HTML attributes.

Problems Solved

  • Embedding Vue components into non-SPA applications.
  • Dynamic mounting and unmounting of Vue components on demand.
  • Passing props through HTML attributes.
  • Lifecycle hooks for integration with surrounding code.
  • Optional Shadow DOM isolation per component instance.

Installation

Install the package from the GitHub npm registry:

npm install @mindtwo/app-component --registry=https://npm.pkg.github.com/mindtwo

Configure your .npmrc to use the GitHub npm registry for the @mindtwo scope:

# .npmrc
@mindtwo:registry=https://npm.pkg.github.com/

Peer dependencies

  • vue ^3.5.17

Usage

Register the component once on application startup. AppComponent.create() defines a custom HTML element that mounts the Vue component for every matching tag found on the page.

import { AppComponent } from '@mindtwo/app-component';
import MyComponent from './components/MyComponent.vue';

AppComponent.create({
    name: 'my-app-component',
    component: MyComponent,
});

In your HTML:

<my-app-component greeting="Hello"></my-app-component>

The component is auto-mounted whenever it is connected to the DOM.

Short syntax

If you don't need any options you can use the shorthand:

AppComponent.create('my-app-component', MyComponent);

Passing props

All HTML attributes (except a few reserved names — see Attributes) are forwarded as props to the Vue component. Attribute names are converted to kebab-case:

<my-app-component some-prop="Hello, World!" user-id="42"></my-app-component>
<script setup>
defineProps<{
    someProp: string;
    userId: string;
}>();
</script>

Shadow DOM

Enable Shadow DOM isolation per component:

AppComponent.create({
    name: 'my-app-component',
    component: MyComponent,
    shadowRoot: true,
});

Stylesheets

Provide a stylesheet URL, inline CSS, or any combination of the two. Each entry is rendered into the component root as either a <link> or a <style> element:

AppComponent.create({
    name: 'my-app-component',
    component: MyComponent,
    // A bare string is treated as a URL.
    style: '/assets/my-component.css',
});

AppComponent.create({
    name: 'my-app-component',
    component: MyComponent,
    style: [
        { url: '/assets/base.css' },
        { css: ':host { --primary: #34d399; }' },
    ],
});

Options

AppComponent.create() accepts the following options:

Option Type Default Description
name string required Component name. Used for PascalCase global registration.
component Component required The Vue component to mount.
elementName string kebab-case of name Custom element tag name.
hookableName string | boolean undefined Prefix for emitted hook names. If true, uses kebab-case of name.
shadowRoot boolean false If true, the component is mounted inside a Shadow DOM.
debug boolean false Enables verbose logging.
globalHooks boolean false If true, the component uses a shared hooks instance across all components.
style StyleSpec | StyleSpec[] undefined Stylesheet(s) to inject. See Stylesheets.
hooks { [name]: fn | { callback, once }} undefined Hook callbacks to register at creation time.
navigation NavigationAdapter undefined Navigation adapter (see Navigation). Enables useNavigation() and bridge.navigate().

Hooks

Hooks let you react to the component lifecycle. Register them via the hooks option, or via bridge.on() from the global bridge object.

Available hook names:

  • init — fired before the AppComponent is constructed.
  • ready — fired after the bridge is created.
  • initialized — fired after the HTML element initializes its DOM.
  • connected — fired when the element is added to the DOM.
  • disconnected — fired when the element is removed from the DOM.
  • creating / created — around Vue app creation.
  • mounting / mounted — around Vue app mount.
  • unmounting / unmounted — around Vue app unmount.
  • loaded — reserved for asynchronous manifest loading (see Manifest loader).
  • navigate — reserved for consumer-fired SPA navigation events.

Example:

AppComponent.create({
    name: 'my-app-component',
    component: MyComponent,
    hooks: {
        connected: (el) => {
            console.log('element connected', el);
        },
        mounted: { once: true, callback: (bridge, app) => console.log('mounted', app) },
    },
});

You can also use custom hook names. Custom hooks are stored as "external hooks" and cleaned up automatically on unmount().

Adding type-safe custom hook names

Extend the ComponentHookMap interface via module augmentation to get autocomplete and type-checking for your own hooks:

declare module '@mindtwo/app-component' {
    interface ComponentHookMap {
        'analytics:track': (event: string) => void;
        'auth:logout': void;
    }
}

bridge.on('analytics:track', (event) => {
    // event is typed as string
});

Attributes

The generated custom element forwards HTML attributes as props. The following attribute names are reserved and not forwarded:

  • auto-mount — reserved, but currently auto-mount is the default behavior.
  • Any data-v-* Vue scope-id attributes.
  • data-type-* — used as type hints for the matching prop (see below).

All other attributes are converted to kebab-case keys on the Vue props object.

Typed attributes (opt-in)

By default every attribute value is forwarded as a string. Add a data-type-{prop} hint to coerce the value into a richer type:

<my-app-component
    count="5"          data-type-count="number"
    active=""          data-type-active="boolean"
    config='{"x":1}'   data-type-config="json"
></my-app-component>

Supported types: string (default), number, boolean, json. Parse failures fall back to the raw string and emit a warning.

API

AppComponent.create(name, component) / AppComponent.create(options)

Registers a custom element and creates the bridge. Returns Promise<AppComponentBridge> so you can keep an imperative handle without going through window. Auto-mounts on connect.

const bridge = await AppComponent.create('my-app-component', MyComponent);
bridge.on('mounted', () => console.log('ready'));

Global bridge

For each component, a bridge instance is registered on window under the PascalCase name. You can use it to control the component after creation:

// e.g. for name "my-app-component", PascalCase: "MyAppComponent"
const bridge = window.MyAppComponent;

await bridge.mount({ extraProp: 'value' });
await bridge.unmount();
bridge.recreate();
bridge.on('mounted', () => console.log('mounted'));
bridge.off('mounted', handler);
bridge.emit('custom-event', payload);

Bridge methods

  • create(): Promise<void> — create the underlying Vue app.
  • mount(props?): Promise<void> — mount and optionally merge additional props.
  • unmount(): Promise<void> — unmount and clean up external hooks.
  • recreate(): void — rebuild DOM and recreate the Vue app.
  • created(): boolean — whether the Vue app has been created.
  • getName(): string — get the PascalCase name.
  • on(name, callback, once?) / off(name, callback) — manage hook callbacks.
  • emit(name, ...args) / trigger(name, ...args) — emit hooks.
  • navigate(url, action?) — host-facing entry point for the registered navigation adapter (see Navigation). No-op with a warning when no adapter is set.

Composables

Inside Vue components mounted via AppComponent.create(), the @mindtwo/app-component-composables package gives you typed access to the bridge, hooks, and root element:

import { useHooks, useAppComponent, useAppComponentRoot } from '@mindtwo/app-component-composables';

const hooks = useHooks();                     // ComponentHooks
const { name, bridge, element } = useAppComponent();
const getRoot = useAppComponentRoot();        // () => ShadowRoot | HTMLElement | undefined

hooks.on('mounted', () => {
    getRoot()?.appendChild(myEl);
});

All three composables throw a clear error when called outside an app-component context, so misuse fails loudly rather than silently returning undefined.

Navigation

App-components are often embedded into pages that already own their URL. Pass a navigation adapter to AppComponent.create({ navigation }) to give the Vue component a uniform API for reading the current URL and requesting changes, regardless of who actually controls the address bar.

Two built-in adapters ship with the core package:

Adapter Who owns the URL When the Vue side calls push(url) How the host pushes a URL into the component
createHistoryNavigationAdapter() The component (window.history) Calls history.pushState, updates currentUrl n/a — back/forward buttons handled via popstate
createEventNavigationAdapter() The host page Fires the navigate hook; URL bar untouched Host calls bridge.navigate(url)

History mode (component owns the URL)

import { AppComponent, createHistoryNavigationAdapter } from '@mindtwo/app-component';
import MyApp from './MyApp.vue';

AppComponent.create({
    name: 'my-app',
    component: MyApp,
    navigation: createHistoryNavigationAdapter(),
});
<script setup>
import { watch } from 'vue';
import { useNavigation } from '@mindtwo/app-component-composables';

const nav = useNavigation();
watch(nav.currentUrl, (url) => console.log('pathname:', url.pathname));
</script>

<template>
    <a href="#" @click.prevent="nav.push('/details')">Go to details</a>
</template>

Event mode (host owns the URL)

The host page tells the component about URL changes by calling bridge.navigate(url). When the embedded component wants to navigate, it calls nav.push(url) which fires the navigate hook — the host listens and decides what to do (update its own router, change the URL bar, etc.).

// in the embedded bundle
import { AppComponent, createEventNavigationAdapter } from '@mindtwo/app-component';

const bridge = await AppComponent.create({
    name: 'my-app',
    component: MyApp,
    navigation: createEventNavigationAdapter(),
    hooks: {
        // component asked the host to navigate
        navigate: ({ url, action }) => console.log('component wants to', action, '→', url),
    },
});
// in the host page
const bridge = window.MyApp;

// React to host-side navigation by syncing the component:
window.addEventListener('hostrouterchange', (e) => {
    bridge.navigate(e.detail.url);
});

Vue API

useNavigation() returns the same shape for both adapters:

interface NavigationApi {
    currentUrl: Readonly<Ref<ParsedUrl>>;  // { href, pathname, search, hash, searchParams }
    push:    (url: string) => void;
    replace: (url: string) => void;
}

Route matching is intentionally out of scope — pair the adapter with vue-router if you need it. The adapter only owns "what is the current URL?" and "ask to change it."

Manifest loader

@mindtwo/unplugin-app-component can emit a manifest-loader script that fetches a Vite-style manifest and injects the entry scripts/styles into the page at runtime. Enable it via the manifestLoader option on the bundler plugin.

For environments that need to defer or gate script loading (consent banners, Nuxt apps, CSP-strict pages), provide a scriptLoader module specifier. The runtime dynamically imports that module and delegates script injection to its default export.

// vite.config.ts
import AppComponentPlugin from '@mindtwo/unplugin-app-component/vite';

export default {
    plugins: [
        AppComponentPlugin({
            manifestLoader: true,
            scriptLoader: '@mindtwo/app-component-nuxt-scripts',
        }),
    ],
};

The shipped @mindtwo/app-component-nuxt-scripts adapter wraps useScript() from nuxt-scripts. Provide your own module to integrate with a different loader — it must default-export a function matching:

export type ScriptLoader = (script: { src: string; id?: string }) => Promise<void>;

Packages

This monorepo provides four packages:

License

This project is licensed under the MIT License.

About

Package that holds code for embedding Vue-Apps as HTML5 custom elements

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors