[]previous and next page

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.

For agentsUpdated

@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.

vite.config.tsts
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.jsx to the automatic runtime with @shadow-js/web as the import source, so a file the plugin skips is still parsed with ShadowJS JSX types and never React's.
  • resolve.dedupe to @shadow-js/core and @shadow-js/web, so an app and its dependencies share one copy of each.
  • optimizeDeps.exclude to the same two packages.
  • envPrefix to VITE_ and DUSK_PUBLIC_, so import.meta.env.DUSK_PUBLIC_* is readable in client code next to VITE_*.

Which modules are compiled#

The transform hook decides for each module id, in this order:

  1. An id that starts with a NUL character is a virtual module that another plugin made (Vite writes \0virtual:name for the resolved form of virtual:name). It is never compiled, whatever its suffix looks like.
  2. The query string is cut off (/app/a.tsx?v=1 is /app/a.tsx), and the rest is matched against exclude. A match is skipped. The default exclude is any path with a node_modules segment, with / or \ as the separator. my_node_modules is not a node_modules segment.
  3. The rest is matched against include. The default is every file ending in .jsx or .tsx, with an optional c or m before the extension letters (.mtsx, .cjsx).
  4. A plain .ts, .js, .mts, .cts, .mjs or .cjs module that does not match include is still compiled when its text contains useStore, so a single-value useStore(0) there takes the cell path. A plain module without useStore is 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.

vite.config.tsts
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.

vite.config.tsts
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/vite sets the JSX runtime under oxc.jsx on Vite 8 and under esbuild.jsx and esbuild.jsxImportSource on Vite 7, so a file the plugin skips and the dependency scan read the ShadowJS runtime on both.
  • @shadow-js/dusk/vite sets the entry and server bundles under build.rolldownOptions (with cwd, which only Rolldown reads) on Vite 8 and build.rollupOptions on Vite 7, the dependency scan's JSX runtime under optimizeDeps.rolldownOptions.transform.jsx or optimizeDeps.esbuildOptions, and reads the server entry with transformWithOxc or transformWithEsbuild.
  • 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, .svelte or .mdx files. 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, or exclude must be narrowed on purpose.
  • It does not run the type checker. Use tsc for types.
  • 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 the plugins option takes.
  • Bundle size covers what the production output costs.