Adding a UI Framework
HexaJS managed UI is built around a small UiFrameworkAdapter contract so that adding a new framework — for example, Svelte — never requires changes inside the popup, devtools, newtab, or content @View builders. This page documents that contract end-to-end so a future contributor has a single checklist to follow.
Status: React and Vue are first-class. Svelte is the worked-out next case used as the running example below. Once Svelte is added, this page should be updated with the second concrete adapter.
What is required, in short
For a new framework named <name> you must provide:
- A Vite plugin loader that resolves the framework's plugin from the user's project (the CLI never ships these plugins itself).
- An adapter that implements
UiFrameworkAdapter, exposing the loader, the deduped runtime modules, and the shadow renderer subpath. - A shadow renderer (
<Name>ShadowRenderer.mount({...})) at@hexajs-dev/ui/<name>with the exact same call signature as the React/Vue renderers. - Scaffold templates for popup, devtools, and newtab, plus a
hexa newprompt branch andhexa add uidispatch branch. - A guard test that ensures
getAdapter('<name>')resolves and unsupported names throw a clear error pointing back to this doc.
The full file-by-file checklist is below.
The UiFrameworkAdapter contract
// packages/ui/src/core/framework-adapter.ts
export type UiFrameworkName = 'react' | 'vue' /* | 'svelte' */;
export interface UiFrameworkAdapter {
name: UiFrameworkName;
/** Plugin package the user must install (used for actionable error messages). */
vitePluginPackage: string;
/** Throws a clear error when the plugin is missing. */
loadVitePlugin(cwd: string): Plugin | Plugin[];
/** Where the CLI's @View generator imports the renderer from. */
shadowRendererImport: { module: string; exportName: string };
/** SFC/JSX file extensions associated with the framework. */
componentExtensions: readonly string[];
/** resolve.dedupe modules (e.g. ['react','react-dom'] or ['vue']). */
dedupe: readonly string[];
}
export function getAdapter(name: UiFrameworkName): UiFrameworkAdapter;
Each adapter is intentionally tiny: it's a declarative constant that links a plugin loader, dedupe list, and shadow renderer subpath together. The framework runtime itself is not imported eagerly — it lives behind the loader, so adding Svelte does not bloat React- or Vue-only bundles.
Concrete checklist (running example: Svelte)
1. Vite plugin loader
packages/ui/src/core/svelte-plugin.ts — mirror react-plugin.ts and vue-plugin.ts:
- Use
createRequire(path.join(cwd, 'package.json'))to resolve@sveltejs/vite-plugin-sveltefrom the user project, never from the workspace. - Throw a clear error:
'@sveltejs/vite-plugin-svelte' is not installed in your project. Run: pnpm add -D @sveltejs/vite-plugin-svelte svelte.
2. Adapter
packages/ui/src/core/adapters/svelte.adapter.ts:
import type { Plugin } from 'vite';
import type { UiFrameworkAdapter } from '../framework-adapter';
import { loadSveltePlugin } from '../svelte-plugin';
export const svelteAdapter: UiFrameworkAdapter = {
name: 'svelte',
vitePluginPackage: '@sveltejs/vite-plugin-svelte',
loadVitePlugin(cwd: string): Plugin | Plugin[] { return loadSveltePlugin(cwd)(); },
shadowRendererImport: { module: '@hexajs-dev/ui/svelte', exportName: 'SvelteShadowRenderer' },
componentExtensions: ['.svelte'] as const,
dedupe: ['svelte'] as const,
};
Then register it in framework-adapter.ts:
import { svelteAdapter } from './adapters/svelte.adapter';
const adapters: Record<UiFrameworkName, UiFrameworkAdapter> = {
react: reactAdapter,
vue: vueAdapter,
svelte: svelteAdapter,
};
Add 'svelte' to the UiFrameworkName union and to cli/bin/config/config.ts UiFrameworkName and the JSON schema enum (packages/cli/src/bin/config/hexa-cli.schema.json and packages/cli/schema/hexa-cli.schema.json).
3. Shadow renderer
packages/ui/src/services/svelte-shadow-renderer.ts:
- Match the React/Vue renderer surface exactly:
static mount({ id, component, controllerInstance, cssText, anchorSelector }): () => void
- Internally instantiate the Svelte component with
target: mountElement, props: { controller: controllerInstance }. - Return a teardown closure that calls
component.$destroy()and removes the host element.
Add the corresponding entries:
packages/ui/src/services/svelte.ts(subpath barrel).packages/ui/vite.config.tslib.entry: addsvelte: 'src/services/svelte.ts'and add'svelte'//^svelte\//toBROWSER_EXTERNALS.packages/ui/package.jsonexports: add./svelteblock alongside./reactand./vue.packages/ui/package.jsonpeer deps:svelteand@sveltejs/vite-plugin-svelte(bothoptional: trueinpeerDependenciesMeta).
4. CLI scaffold templates
Mirror the existing template fan-out under packages/cli/src/bin/programs/new/templates/:
popup-{main,app,index-html,vite-config,tsconfig}-svelte.template.tsdevtools-{main,app,index-html,vite-config,tsconfig}-svelte.template.tsnewtab-{main,app,index-html,vite-config,tsconfig}-svelte.template.ts
CSS templates (popup-style, devtools-style, newtab-style) are framework-agnostic and shared. The bridge devtools.html and devtools.ts entry are also shared.
5. Scaffold dispatch
In packages/cli/src/bin/programs/new/services/scaffold.service.ts, extend the isVue branch into a switch on ctx.framework. Update:
packages/cli/src/bin/programs/new/templates/package-json.template.ts— emitsveltedependency and@sveltejs/vite-plugin-svelte+svelte-checkdevDeps underframework === 'svelte'.packages/cli/src/bin/programs/new/templates/hexa-config.template.ts— already emitsui.framework, no change needed.packages/cli/src/bin/programs/new/new.ts— add{ name: 'svelte', message: 'Svelte ...' }to the framework prompt choices.
6. hexa add ui dispatch
In packages/cli/src/bin/programs/schematics/ui-surface-files.ts, extend buildPopupFiles and buildDevtoolsFiles to handle the new framework === 'svelte' branch and route to the Svelte templates.
7. Guard test
Add a focused test in packages/ui/tests/:
import { describe, expect, it } from 'vitest';
import { getAdapter } from '../src/core/framework-adapter';
describe('getAdapter', () => {
it('returns the registered adapter for known frameworks', () => {
expect(getAdapter('react').name).toBe('react');
expect(getAdapter('vue').name).toBe('vue');
expect(getAdapter('svelte').name).toBe('svelte');
});
it('throws a contributor-friendly error for unknown frameworks', () => {
expect(() => getAdapter('solid' as any)).toThrow(/Unsupported UI framework/);
expect(() => getAdapter('solid' as any)).toThrow(/adding-frameworks/);
});
});
8. Documentation
- New
documentation/docs/managed-ui/svelte-integration.mdmirroringvue-integration.md(request data inonMount, send change in handler, resolve token, scope reminder). - Update
documentation/sidebars.tsto include'managed-ui/svelte-integration'. - Update
documentation/docs/managed-ui/index.mdto mention Svelte alongside React/Vue. - Update
hexa-features.mdand the projectREADME.mdmatrices.
9. End-to-end example
Add examples/hexa-svelte-starter mirroring examples/hexa-vue-starter. CI's existing turbo build target already picks it up via examples/* in pnpm-workspace.yaml.
CI test checklist
A pull request adding a new framework must keep the following green, in order:
pnpm --filter @hexajs-dev/ui test— Vue/React shadow renderer tests + the new framework's renderer test.pnpm --filter @hexajs-dev/cli test— scaffold service + new command + add command tests, with new cases for the framework branch.pnpm --filter examples/hexa-<framework>-starter build— end-to-end build of the new starter.- Manual smoke test: load
dist/chrome/productionas an unpacked extension and confirm popup + content overlay render.
Things you should not change
The popup/devtools/newtab managed builders, the content @View generator, the bundler-framework loader, and the hexa new/hexa add ui shells are intentionally framework-agnostic. They consume the adapter contract; they should not learn about a new framework directly. If you find yourself editing them to support a new framework, the contract is wrong — open an issue before continuing.