Vite Plugin
@praxisjs/vite-plugin — configures Vite for PraxisJS with decorator support, JSX transform, TypeScript, and optional HMR.
Vite Plugin
The Vite plugin handles everything needed to run PraxisJS: decorator support, the JSX transform to @praxisjs/jsx, and optional component-level HMR. It's required for all PraxisJS projects.
npm install -D @praxisjs/vite-pluginpnpm add -D @praxisjs/vite-pluginyarn add -D @praxisjs/vite-pluginbun add -d @praxisjs/vite-pluginSetup
// vite.config.ts
import { defineConfig } from 'vite'
import { praxisjs } from '@praxisjs/vite-plugin'
export default defineConfig({
plugins: [praxisjs()],
})Options
praxisjs({
hmr: true, // component-level hot reload in the dev server (default: true)
autoImport: true, // auto-import common decorators (default: false)
keyedLists: true, // rewrite keyed .map() lists into <For> (default: true)
templates: true, // compile static JSX trees into cloned templates (default: true)
libraries: true, // compile the PraxisJS libraries that opt in like your own code (default: true)
})| Option | Type | Default | Description |
|---|---|---|---|
hmr | boolean | true | Component-level hot module replacement in the dev server: editing a component re-renders its mounted instances in place, without a page reload (see Hot module replacement). |
autoImport | boolean | false | Auto-imports @Component, @State, @Prop, and other commonly used decorators so you don't have to import them in every file. |
keyedLists | boolean | { warn?: boolean } | true | Rewrites {() => list.map((item) => <Row key={…} />)} into For so rows are kept between updates. Keyed .map() calls it can't rewrite are reported as build warnings; { warn: false } silences them, false turns the rewrite off. |
templates | boolean | { include?: RegExp } | true | Compiles JSX trees of HTML/SVG elements into templates that are built once and cloned per render (see Compiled templates). false leaves every element to jsx(); include restricts which modules are compiled. |
libraries | boolean | Record<string, boolean | { rootDir?: string; outDir?: string }> | true | Compiles dependencies that are PraxisJS libraries like your own code (see Libraries). By default, the packages whose package.json opts in. An object opts packages in or out by name ("@scope/*" for a whole scope); false compiles no dependency. |
What the plugin does
- Decorator support — sets Vite's oxc transform target to
ES2022, and lowers TC39 decorators with the TypeScript compiler since oxc doesn't support that natively yet. The helpers the lowering calls (__esDecorate,__runInitializers, …) come from one virtual module,virtual:praxisjs/decorators, instead of being written into every decorated file, and the same copies written bytscinto the files of a library (an ES module) are replaced with imports of it. A helper is only replaced when it is exactly the one TypeScript writes. The decoration of a public field or method with a literal name, of the class itself, and the class's decorator metadata go through shorter helpers of the same module, which build the same context objects and apply the results the same way: about 200 bytes less per decorated member once minified. Accessors, getters, setters and private or computed members keep TypeScript's call - JSX transform — configures Vite to use
@praxisjs/jsxas the JSX import source (equivalent tojsxImportSourcein tsconfig) - Keyed lists — rewrites keyed
.map()children intoFor(see Lists). It parses with the TypeScript compiler the plugin already ships with and emits a source map - Compiled templates — turns trees of host elements into cloned templates (see below), with a source map
- HMR — in the dev server, re-renders the mounted instances of an edited component in place (see below)
- Libraries — applies the same work to the dependencies that are PraxisJS libraries and opt in (see below)
Compiled templates
A JSX tree of HTML or SVG elements is split at build time into its static part and its dynamic parts:
<tr class={() => (this.isSelected(row().id) ? "danger" : "")}>
<td class="col-md-1">{row().id}</td>
<td class="col-md-4"><a onClick={() => this.select(row().id)}>{() => row().label}</a></td>
<td class="col-md-1"><a onClick={() => this.remove(row().id)}>×</a></td>
</tr>- Static — tags, literal attributes (
class="col-md-1"), literal text and the anchors of reactive children. They are built once and every render starts from onecloneNode(true)of that tree. When the HTML parser provably reads the tree back unchanged, it is emitted as markup and parsed once (shorter in the bundle than one DOM call per node); otherwise it is built with the same DOM calls a plain render makes. - Dynamic — expressions, arrow functions, event handlers, refs and components. The clone is walked with
firstChild/nextSiblingto the nodes that need them, then each one is bound exactly asjsx()would bind it.
A tree is emitted as markup only when it holds known HTML elements (no custom element, SVG, form control state or raw-text element), none the parser would close or move (block content inside a p, nested links, buttons, forms or headings, list items or table parts out of place), no text the parser would merge, drop or move out of a table, and only attributes whose property writes the same attribute the markup holds (id, class, href, type, aria-*, data-*, boolean attributes such as disabled, …). Any other tree keeps the DOM-call builder, so the result is always the DOM jsx() would build.
A reactive child that is the only content of its element (<a>{() => row().label}</a>) needs no anchor comment: it fills the element, and a text update writes the data of its text node. Any other only child (<td>{row().id}</td>) is written with textContent when it is text.
The template is built in the inert document that <template> contents use, and each clone is adopted by the page's document when it is inserted, which is cheaper than cloning inside the live document. A template that contains a custom element (a tag with a -) is built and cloned in the live document instead, so the element is upgraded as soon as it is cloned and its properties are set through its class.
Event handlers. An event prop of a template element attaches no listener when it is bound: the handler is stored on the element. One capture listener per event type, on the document and on every render container and portal target, gives the element its own listener the first time an event of that type is on its way to it, before the event arrives. The handler then runs at its usual place in the dispatch (target or bubble phase), with the usual this, currentTarget and stopPropagation(), for bubbling and non-bubbling events alike, in or out of the document as long as the element is under a render container. The only difference from a listener added at render time is its position among other listeners added to the same element with addEventListener before that first event: those now run first.
A lone element is compiled when it has something static (a literal attribute or text) or a reactive child. The output DOM is the same as without templates, except that a reactive child that is the only content of its element has no anchor comment, so the option can be turned off at any time. A tree stays on jsx() when it has a spread attribute ({...props}), an explicit children attribute, an HTML entity the compiler doesn't know, or an await/yield inside one of its expressions — its nested trees are still compiled on their own.
Props that don't survive cloning
A literal prop that lives only in a property (an expando, a custom element field) would be lost by cloneNode. The runtime checks each literal prop on a probe element when it builds the template and replays the ones that don't survive on every clone, so nothing has to be declared.
Hot module replacement
In the dev server (vite), every top-level class of your app's modules is registered with the runtime under its file and class name. When you save a module whose exports are all component classes, Vite runs the new module and each mounted instance of its components is rendered again, in place, with the new class: no page reload, and the rest of the page (other components, the router, stores) keeps its state.
- State — the edited component's instances are new instances: their
@Statefields start from their initial values,onUnmountruns on the old instance andonMounton the new one. State kept outside the component (stores, the URL) is untouched. - References elsewhere — a route table or a parent that imported the old class keeps working: mounting a registered class always mounts its newest version.
- Full reload, on purpose — a module that also exports something else (a helper, a constant, a store, a re-export), or whose exported class is not a component, is not replaced in place: modules that import it may hold the old values, so the update goes to them the usual Vite way, which re-runs them or reloads the page. Edits to
main.tsxand other modules without classes reload too. Keep components in their own modules to get hot updates. - Dev DOM — while HMR is active, every registered component is bounded by two comments (
<!--[Name]-->…<!--[/Name]-->), also when it renders a single node, so it can be replaced between them. Production builds are unaffected: the plugin adds nothing to them.
Dependencies (anything under node_modules, or linked from outside the project root) are left alone.
Libraries
A component library built with tsc (such as @morphos/*) reaches your app as plain JavaScript, so by default none of the work above happens to it: its decorated fields are defined and then replaced by their decorator, which puts every instance in V8's slow dictionary mode, and its elements are built by jsx() calls. A library can opt in to be compiled like your own code:
// the library's package.json
{
"praxisjs": { "optimize": true, "rootDir": "./src", "outDir": "./dist" }
}optimize: true— the decorated fields of its compiled files are defined directly by their decorator, as in your own components, so instances keep fast properties. This works on whattscpublishes, nothing else is needed.rootDirandoutDir— when the package also ships its sources, every import of a compiled file (dist/button/button.js) loads the source it comes from (src/button/button.tsx) instead, which is compiled like your own files: compiled templates, keyed lists and claimed fields. The folders are therootDirandoutDirof the library's tsconfig, relative to itspackage.json.
An app can opt packages in or out itself, by name, which also gives the folders of a library that doesn't declare them:
praxisjs({
libraries: {
'@morphos/*': { rootDir: 'src', outDir: 'dist' }, // every package of the scope, from its sources
'@acme/widgets': true, // compiled files only
'@acme/legacy': false, // as published, even if it opts in
},
})In a test of five @morphos/inputs components (1,000 instances each, production build, Chromium), every stateful component went from dictionary to fast properties, and mounting took 16 to 37% less time with optimize alone, 23 to 44% with the sources. Packages that don't opt in are compiled exactly as before.
What a library must be. The plugin compiles a library with the settings it compiles your app with, so the library must be built the way a PraxisJS app is: an ES module package, TC39 decorators (not experimentalDecorators), target ES2022 or later with useDefineForClassFields, and jsxImportSource: "@praxisjs/jsx". Its sources must compile one file at a time (isolatedModules) and import nothing its build resolves differently (no paths aliases). A compiled class the plugin can't claim is left as published, with a build warning.
Dev server. Vite pre-bundles dependencies before any plugin runs, so the plugin also runs inside Vite's dependency optimizer: libraries stay pre-bundled (one request each, one copy of the framework), and come out of it compiled. The optimizer's cache depends on the libraries, templates and keyedLists options, so changing them pre-bundles again. A library you add to optimizeDeps.exclude is compiled by the regular plugin pipeline instead, with Vite's usual caveats for excluded dependencies.
SSR. Libraries that opt in are added to ssr.noExternal, so the server runs the same compiled code as the browser instead of importing the published files with Node. A package you list in ssr.external stays external.
Trade-offs. Claimed fields and compiled templates make the output larger, as they do for your own code (here, +2.4 kB gzip with optimize, +7.6 kB with the sources, part of it For used by a rewritten list), and compiling from sources adds build time. Hot module replacement still leaves dependencies alone. Source maps point at the library's compiled files, or at its sources when it is compiled from them.
Using decorators in Vitest
Vitest runs test files through Node directly — it doesn't go through Vite's dev/build pipeline unless you wire a plugin into vitest.config.ts. Since decorated classes need the same TC39-to-runtime lowering praxisjs() applies in the browser, import the standalone decoratorLoweringPlugin() export and register it as a Vitest plugin:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { decoratorLoweringPlugin } from '@praxisjs/vite-plugin'
export default defineConfig({
plugins: [decoratorLoweringPlugin()],
test: {
environment: 'node', // or 'jsdom' for tests touching the DOM
},
})This is the same plugin praxisjs() includes internally — it only lowers files that contain real decorator syntax, so it's safe to add without also pulling in the JSX transform or HMR pieces, which test files don't need.
Static site generation
For prerendering routes to static HTML at build time (with real client-side hydration), add ssgPlugin() from @praxisjs/ssg alongside praxisjs():
// vite.config.ts
import { praxisjs } from '@praxisjs/vite-plugin'
import { ssgPlugin } from '@praxisjs/ssg'
export default defineConfig({
plugins: [praxisjs(), ssgPlugin({ root: './src/app.tsx' })],
})See the SSG guide for route enumeration, dynamic routes, and how hydration works.
TypeScript configuration
The plugin handles the Vite/oxc side, but TypeScript still needs its own configuration:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@praxisjs/jsx",
"useDefineForClassFields": true,
"strict": true,
"noEmit": true
}
}Tooling
Developer tools for PraxisJS — Vite plugin, in-app DevTools overlay, Storybook adapter, MCP server, and the project scaffolder and maintenance CLIs.
SSG
@praxisjs/ssg — static site generation with real client-side hydration: prerender every route to HTML at build time, then reconcile it into the live DOM on the client instead of discarding it.