Vite plugin
Add @shadow-js/vite to a Vite app, know which files it compiles and which it skips, how dev, build, SSR and Vitest differ, what its options do, and how compile errors and bad options are reported.
@shadow-js/vite is the Vite plugin that runs the ShadowJS compiler. It turns JSX and useStore calls into the code the runtime expects before Vite's own JSX handling sees the file, so Vite never meets JSX. This page covers the setup, the rules that decide which modules are compiled, what changes between vite dev, vite build, an SSR build and a Vitest run, each option, and how failures are reported. The package README lists the install command and the exports.
Add the plugin#
Vite 7 and Vite 8 are supported (peer range ^7.0.0 || ^8.0.0; see Vite 7 and Vite 8 for what differs and how it was verified). Put shadow() in the plugin list of vite.config.ts. The named export shadow and the default export are the same function.
import shadow from "@shadow-js/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [shadow()],
});
shadow() returns a plugin named shadowjs with enforce: "pre", so it runs before Vite's TypeScript and JSX transforms. Its config hook also sets four things for you:
oxc.jsxto the automatic runtime with@shadow-js/webas the import source, so a file the plugin skips is still parsed with ShadowJS JSX types and never React's.resolve.dedupeto@shadow-js/coreand@shadow-js/web, so an app and its dependencies share one copy of each.optimizeDeps.excludeto the same two packages.envPrefixtoVITE_andDUSK_PUBLIC_, soimport.meta.env.DUSK_PUBLIC_*is readable in client code next toVITE_*.
Which modules are compiled#
The transform hook decides for each module id, in this order:
- An id that starts with a NUL character is a virtual module that another plugin made (Vite writes
\0virtual:namefor the resolved form ofvirtual:name). It is never compiled, whatever its suffix looks like. - The query string is cut off (
/app/a.tsx?v=1is/app/a.tsx), and the rest is matched againstexclude. A match is skipped. The defaultexcludeis any path with anode_modulessegment, with/or\as the separator.my_node_modulesis not anode_modulessegment. - The rest is matched against
include. The default is every file ending in.jsxor.tsx, with an optionalcormbefore the extension letters (.mtsx,.cjsx). - A plain
.ts,.js,.mts,.cts,.mjsor.cjsmodule that does not matchincludeis still compiled when its text containsuseStore, so a single-valueuseStore(0)there takes the cell path. A plain module withoutuseStoreis left alone.
A module the compiler has nothing to change comes back as null, and Vite keeps its own result. Compiled output is not compiled twice: a second pass over it also returns null.
A g or y flag on include or exclude is ignored. RegExp.prototype.test on such a pattern starts where the previous call stopped, which would make one file match on one call and not on the next, so the plugin matches with a copy of the pattern that has neither flag.
To compile more or fewer files, pass your own patterns. exclude is checked first, so an excluded file is never compiled even when include matches it.
import shadow from "@shadow-js/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [shadow({ include: /\.[jt]sx$/, exclude: /[\\/](node_modules|legacy)[\\/]/ })],
});
Development and builds#
The plugin reads the command and mode Vite reports in its config hook.
| Run | Target | Development output | Hot updates |
|---|---|---|---|
vite dev, client |
DOM | on | on |
vite dev, SSR environment |
server (@shadow-js/web/server) |
on | never |
vite build, client |
DOM | off | never |
vite build, SSR |
server | off | never |
| Vitest (any environment) | follows the transform's ssr flag |
on, because Vitest runs the serve command |
off unless hmr: true |
An SSR transform (Vite passes ssr: true) always emits the server target and never contains hot-update code, in development and in production. Development output means component names in the compiled code and the extra runtime checks; a production build contains none of it. Hot updates register each top-level component with @shadow-js/web/hmr, so an edit re-renders that component's instances while parents and siblings keep their state.
Options#
Every option is optional; an option set to undefined is the default.
| Option | Default | What it does |
|---|---|---|
include |
/\.[cm]?[jt]sx$/ |
Files to compile, matched against the id without its query. Plain modules that call useStore are compiled as well. |
exclude |
node_modules segment |
Files never compiled. Checked before include. |
moduleName |
@shadow-js/web |
Module the compiled code imports its runtime helpers from (<moduleName>/hmr for hot updates). SSR transforms read @shadow-js/web/server. |
autoReactive |
true |
Turns a const computed from reactive reads, inside a component, into a live value. false keeps it as written. |
hmr |
true in vite dev, false under a test runner |
Hot-updates top-level components in place. Browser transforms only. |
dev |
true for vite dev, false for vite build |
Development output. Set true to get it in a build, false to remove it from vite dev. |
domTests |
true for happy-dom and jsdom |
Runs the browser build of ShadowJS under Vitest. |
plugins |
none | Compiler plugins that run in the build on every compiled module. See Compiler plugins. |
autoReactive: false applies to browser and server transforms alike. The next block compiles the same component both ways and shows the difference:
Errors#
There are two kinds, and they are reported at different times.
A bad option fails when shadow() is called, before Vite reads any file. include and exclude must be RegExp values, moduleName must be a non-empty string, and plugins must be a list of compiler plugins, each with a unique name and a transform function (the message names the member, as in shadow(): plugins[1].name must be ...). Each refusal is a TypeError whose message names the option and what it received, for example shadow(): "include" must be a RegExp (got string).. The plugin does not give these errors a code property.
A compile error (a syntax error in a component, for instance, or an error a compiler plugin reports, which names the plugin) is passed to Vite through this.error, with the file, the line and the column. A warning that a compiler plugin reports goes to Vite's warning log with the same position. The message starts with file:line:column and the compiler's diagnostic code (PARSE_ERROR for a syntax error), and Vite shows it in the terminal and in the dev overlay. Any other thrown value is rethrown as it is, because it points at a compiler bug, not at your code.
Source maps#
Every transform returns a version 3 source map that names the original file and carries its source, in serve, build and SSR transforms alike. Turn on build.sourcemap in the Vite config to ship maps from a build; the emitted map lists the original .tsx file and its text, not the compiled output.
import shadow from "@shadow-js/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [shadow()],
build: { sourcemap: true },
});
Compiling the same input twice gives the same code and the same map.
Vite 7 and Vite 8#
Vite 8 bundles with Rolldown and transforms with Oxc; Vite 7 bundles with Rollup and transforms with esbuild. The plugins support both, and choose by what the installed Vite exports (rolldownVersion exists only in the Rolldown based releases), not by a version number:
@shadow-js/vitesets the JSX runtime underoxc.jsxon Vite 8 and underesbuild.jsxandesbuild.jsxImportSourceon Vite 7, so a file the plugin skips and the dependency scan read the ShadowJS runtime on both.@shadow-js/dusk/vitesets the entry and server bundles underbuild.rolldownOptions(withcwd, which only Rolldown reads) on Vite 8 andbuild.rollupOptionson Vite 7, the dependency scan's JSX runtime underoptimizeDeps.rolldownOptions.transform.jsxoroptimizeDeps.esbuildOptions, and reads the server entry withtransformWithOxcortransformWithEsbuild.- The environment API Dusk builds on (
createBuilder, per-environment build options,hotUpdate) is the same in both.
Behavior that depends on the bundler's output text is not part of the contract: Rollup writes const where Rolldown writes var for the same module.
What the plugin does not do#
- It is a Vite plugin, not an unplugin: Rollup, webpack and esbuild are not supported hosts in 1.0.0.
- It does not read
.vue,.svelteor.mdxfiles. Only the include rules above decide what is compiled. - It does not compile a module in
node_modules. A package that ships JSX must be compiled before it is published, orexcludemust be narrowed on purpose. - It does not run the type checker. Use
tscfor types.
Related#
- Dusk composes this plugin in its own Vite plugin for the compile step.
- Compiler covers what
compile()emits and how to write the compiler plugins that thepluginsoption takes. - Bundle size covers what the production output costs.