Bundler plugin
The bundler plugin lets an app use a .uf.tsx component as if it were written in the app's own framework: a React app imports Button.uf.tsx and gets a React component, and a Vue app gets a Vue one. The plugin compiles the file during the build, so there's no output to keep in sync by hand. Think of a copied dist/react folder that's two releases behind its source, an edit that needs a full rebuild before you see it, or a stack trace that points at code nobody wrote.
@unframework/unplugin compiles the import to your framework's source, and then your framework's own plugin finishes the job, exactly as it would for a file you wrote: React's JSX transform, plugin-vue, vite-plugin-svelte, the Solid compiler, the Qwik optimiser or Astro. This page walks you through using it. We'll look at:
- Vite: adding the plugin, how it finds your framework, and its options.
- Hot updates and source maps: what an edit updates, and where an error points.
- Packages that ship sources: compiling a design system's
.uf.tsxfiles in your app. - Astro: the integration that replaces the plugin there.
- Type-checking in an app: checking
.uf.tsxfiles beside your framework's own. - Other bundlers: the entries we provide but haven't tested.
Add it to Vite
Add unframework() before your framework's plugin. This is our React test app's config:
import { fileURLToPath } from "node:url";
import unframework from "@unframework/unplugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
root: fileURLToPath(new URL("./src/plugin", import.meta.url)),
// No `target`: the plugin detects React from plugin-react.
plugins: [unframework(), react()],
});
Then import a component by its file, as in import Button from "./Button.uf.tsx", and use it like any other. Here's the Button from our sample design system:
import "./Button.css";
import { defineEmits, defineSlots } from "unframework";
import type { Element } from "unframework";
import type { ButtonVariant } from "./types";
export default function Button({ variant = "primary" }: { variant?: ButtonVariant }) {
const emit = defineEmits<{ press: [] }>();
const slots = defineSlots<{ default?(): Element }>();
return (
<button type="button" class="button" data-variant={variant} onClick={() => emit("press")}>
{slots.default?.()}
</button>
);
}
In the React app it takes onPress and children, in the Vue app a press event and a default slot, as each framework names them. Its stylesheet is scoped and attached each framework's way, and its child components and composables load through the plugin too. A shared .ts module it imports loads as any TypeScript file does, and the plugin watches it.
TypeScript's other spellings of the same import compile too: ./Button.uf with moduleResolution: "bundler", and ./Button.uf.js or ./Button.uf.jsx with "nodenext". A .uf.ts module of composables loads the same way.
Target detection
On Vite you can leave target out, and the plugin reads which framework your config's plugins belong to:
| Target | Plugin name | Plugin |
|---|---|---|
| Vue | vite:vue | @vitejs/plugin-vue |
| Svelte | vite-plugin-svelte | @sveltejs/vite-plugin-svelte |
| React | vite:react-* | @vitejs/plugin-react |
| Solid | solid | vite-plugin-solid |
| Qwik | vite-plugin-qwik | qwikVite from Qwik |
| Astro | astro:* | Astro's own plugins |
| Angular | @analogjs/vite-plugin-angular | @analogjs/vite-plugin-angular |
No match stops the dev server or the build with UF7005, and plugins of two frameworks with UF7006, which names them. Pass target in either case. Keep in mind that an Astro app that also adds a framework's plugin holds both, so use the integration there.
On Vue, plugin-vue's template.compilerOptions.whitespace: "preserve" reports the warning UF7010, because every output assumes "condense", Vue's default. With "preserve", a component can render different whitespace from the other targets.
Options
| Option | What it does |
|---|---|
target | A built-in target's name ("react", "vue", "svelte", "solid", "angular", "qwik", "astro") or a third-party Target. Optional on Vite, required on other bundlers |
extension | What module ids add to the .uf.tsx path. Built-in targets have one, and a third-party target must set it |
passThrough | The import prefixes that reach every output as written, for a styling engine. Defaults to ["virtual:"], and a list replaces it |
format | Formats the output with oxfmt. Defaults to true |
plugins | Compiler plugins, passed to the compiler |
onCompile | Called each time a module loads, with its files and diagnostics. Returning a message fails that module with it |
Warnings and notes go to the bundler's log and fail nothing. An error diagnostic fails its module, with its UF code, its message and a code frame.
How the module ids work
The plugin gives each import a module id that ends in the extension your framework's plugin claims, so that plugin compiles it. Nothing exists on disk at that id: the plugin serves the compiled code.
| Target | Module id of Hello.uf.tsx | Compiled by |
|---|---|---|
| React, Solid, Qwik | /abs/Hello.uf.tsx | the JSX transform, which claims .tsx |
| Vue | /abs/Hello.uf.tsx.vue | plugin-vue |
| Svelte | /abs/Hello.uf.tsx.svelte | vite-plugin-svelte |
| Astro | /abs/Hello.uf.tsx.astro | Astro's plugin |
| Angular | /abs/Hello.uf.tsx.ts | Unframework's ngtsc step, ahead of Analog |
A .vue, .svelte or .astro module has only a default export, so on those targets a file's main component must be its default export. Every file compiles through the project graph at your project's root, so an edit recompiles only the components it reaches.
Angular is the exception for now. The Angular CLI's @angular/build has no plugin API, and Analog compiles only the .ts files on disk, so an Analog app needs Unframework's own ngtsc step ahead of it, which ships only with our test tooling so far. An Angular app installs the packaged components instead.
Hot updates
On a Vite dev server, editing a .uf.tsx file, its stylesheet or a .ts file its props types come from updates the page through your framework's own hot updates. The plugin recompiles only what the edit reaches, and sends nothing when an edit reaches no component. Editing a stylesheet updates the sheet and the markup together, because the scope id comes from the sheet's text.
Each framework then does what it does for its own files:
- React, Vue, Svelte and Solid send an update.
- Astro does a full reload with the new content.
- Qwik: Qwik's own plugin reloads the page, or sends its
qwik:hmrupdate, from thessrenvironment only, so it does that for a server-rendered app. For a client-rendered one, whosessrenvironment holds none of the change's modules, the plugin invalidates its modules and sends the full reload itself from the client environment, whatever Qwik's hot-update setting. That way a stylesheet edit never meets the sheet Qwik'suseStyles$added first.
Watching stops at node_modules, as Vite's does, so an edit to an installed package's files shows on the next load rather than at once.
Source maps
Each compiled component the plugin serves carries a source map back to its .uf.tsx file, so the browser's dev tools show your source. In development, an error thrown in a handler, a computed or onMounted points at its .uf.tsx line on React, Vue, Svelte, Solid, Angular and Qwik. Astro renders on the server only, with no handlers or onMounted, so there an error thrown in a computed does.
After your framework's plugin runs, the maps differ a little. React and Solid still trace to the .uf.tsx file in dev and in a build, and Vue does in dev, while a Vue build traces to the compiled .vue module.
Packages that ship sources
A design system can ship its .uf.tsx sources, and an app can compile them with its own plugin instead of installing a prebuilt build. The package exposes them through exports, as our sample design system does:
"exports": {
"./source/*": "./src/*"
}
An app then imports a component by its subpath. These are the first lines of our React test app's page:
import Button from "@unframework/e2e-design-system/source/Button.uf.tsx";
import Card from "@unframework/e2e-design-system/source/Card.uf.tsx";
import Counter from "@unframework/e2e-design-system/source/Counter.uf.tsx";
import TextField from "@unframework/e2e-design-system/source/TextField.uf.tsx";
import { useToggle } from "@unframework/e2e-design-system/source/useToggle.uf.ts";
What decides is the file a specifier resolves to, so an exports entry that hides the extension works too. You don't need optimizeDeps.exclude or ssr.noExternal: the plugin keeps these files away from Vite's dependency optimiser, and Vite never externalises a .tsx or .ts file. The package compiles in a project graph of its own, rooted at the package, so its diagnostics name its files relative to the package.
Keep in mind that your app's own files do the importing. A .uf.tsx file imports other components by relative path, so a .uf.tsx file that imports a package's component reports UF1202.
The dev server's allow list
A .uf.tsx file outside Vite's server.fs.allow gets the same 403 as any other file there, whichever id a request names it by. The plugin asks Vite's own check before it reads a file, so server.fs.strict and server.fs.deny apply as they do to everything else. A file outside the list that a served module imports still loads, so a child component or a linked package's sources load as soon as something you serve imports them. A .uf.ts module of composables isn't covered yet.
Astro
An Astro project adds the integration rather than the Vite plugin. It's the @unframework/unplugin/astro entry, and it takes the plugin's options but target, which it sets to "astro". This is our Astro test app's config:
import unframework from "@unframework/unplugin/astro";
import { defineConfig } from "astro/config";
export default defineConfig({
srcDir: "./src/plugin",
devToolbar: { enabled: false },
integrations: [unframework()],
});
A page then imports a .uf.tsx component like an .astro one, and astro dev and astro build both render it, with its children and its stylesheet. The output is static, as every Astro output is.
The integration also takes .uf.tsx files out of Astro's dependency scan, which would read them as plain JSX. Once every integration has set up, it reads Astro's compressHTML: anything but "jsx", Astro's default, logs the warning UF7010, because a component could then render different whitespace from the other targets. The build carries on.
Type-checking in an app
A .uf.tsx file type-checks against Unframework's JSX types, which write class and onKeydown, while your app's own files check against your framework's, which may write className and onKeyDown. So an app checks its .uf.tsx and .uf.ts files through a second config, tsconfig.uf.json, which unframework init writes:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"jsx": "preserve",
"jsxImportSource": "unframework"
},
"include": [
"**/*.uf.tsx",
"**/*.uf.ts"
],
"exclude": [
"dist"
]
}
Run it with tsc --noEmit -p tsconfig.uf.json, beside your app's usual check. A misused prop in a .uf.tsx file fails it at that file's line. Then leave those files out of your own tsconfig.json, so your framework's JSX types don't check them:
{
"exclude": ["**/*.uf.tsx", "**/*.uf.ts"]
}
A tsconfig.uf.json that extends your root config needs an exclude of its own, or it inherits this one and finds no files.
A known limit
exclude only stops TypeScript from starting at a file. When one of your app's own files imports a .uf.tsx component, TypeScript follows the import and checks that file under your framework's JSX types, so your app's own tsc fails. In a React app, it reports TS2322 on every class, and TS2882 on the stylesheet's import when nothing declares *.css:
src/Greeting.uf.tsx(1,8): error TS2882: Cannot find module or type declarations for side-effect import of './Greeting.css'.
src/Greeting.uf.tsx(5,13): error TS2322: Type '{ class: string; children: string[]; }' is not assignable to type 'DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>'.
The build itself is fine: the plugin compiles the file, and tsconfig.uf.json checks it. Writing /** @jsxImportSource unframework */ in the file doesn't help either: the file then checks clean, and React's types reject the component where you use it, with TS2786. How your app's own files should type such an import is still an open question.
Other bundlers
The plugin has one entry for every bundler unplugin supports, each with the same options:
@unframework/unplugin/bun
@unframework/unplugin/esbuild
@unframework/unplugin/farm
@unframework/unplugin/rolldown
@unframework/unplugin/rollup
@unframework/unplugin/rsbuild
@unframework/unplugin/rspack
@unframework/unplugin/unloader
@unframework/unplugin/vite
@unframework/unplugin/webpack
We test only Vite end to end. The other entries run the same hooks through unplugin, and our tests create a plugin from each one and run its hooks on stand-in contexts, without a real build, so we recommend Vite until they've been tried in real builds. Target detection, hot updates, the allow list and the dependency scan are Vite's alone. Every other entry needs target, and the project's root is webpack's or Rspack's compiler.context, esbuild's absWorkingDir, or else the working directory.
On a bundler whose plugins can't resolve an import themselves, like esbuild and webpack, a specifier must be a path: relative, like ./Hello.uf.tsx or ./Hello.uf, root-relative or absolute. A package's specifier or an alias is left to the bundler there. On React, Solid and Qwik the file it resolves to is the module id, so it still compiles, but on the other targets it fails, so there packages that ship sources need Vite or Rollup's family.
Probes we ran when the plugin moved to unplugin's hooks found these failures, and they may still be there:
- webpack: the
./webpackentry, on a stub compiler with webpack's resolver, fails React, Solid and Qwik imports withRecursion in resolving. - esbuild: in real builds, warnings and info diagnostics are lost, and so are the watch edges, so an edit under
ctx.watch()rebuilds nothing. - webpack and Rspack: in a simulation with their real hooks, the plugin's transform receives an encoded module id on targets whose ids have a suffix (Vue, Svelte, Astro and Angular), so there the
onCompilecheck lets the module through and the watch edges name a path that doesn't exist.
The decisions behind this page are ADR-0080, ADR-0074 and ADR-0075. The plugin's README holds its whole contract.
CLI
The unframework command builds every target's source tree, checks a project and prints diagnostics for people, agents and CI, explains a diagnostic code and scaffolds a new project.
Packaging
unframework build --package writes one package you can publish, with each framework's components in that framework's own package format, a subpath per framework, every framework as an optional peer, and the .uf.tsx sources for apps that compile them.