import { loomPreview } from "@loom-dev/preview/vite";
loomPreview(options?: LoomPreviewOptions): Plugin[]Returns an array of plugins, which you spread into plugins as a single array entry — Vite flattens
nested plugin arrays.
Options
interface LoomPreviewOptions { entry?: string; targets?: string | string[] | true; title?: string; html?: boolean; shims?: Record<string, string>; assets?: boolean;}| Option | Default | Effect |
|---|---|---|
entry | auto-detected | Client entry, root-relative (/src/boot.tsx) or relative to the project root. A leading ./ is stripped; a value without a leading slash gets one. |
targets | — | Gallery mode. Normalized exactly like the CLI’s --targets: true → **/*.loom.tsx, a value containing * used verbatim, anything else treated as a directory and expanded to <value>/**/*.loom.tsx. |
title | "loom preview", or "loom gallery" with targets | <title> of the generated page. |
html | true | false removes the HTML plugin: no generated page, no entry detection, no Rollup input. |
shims | {} | Package redirects for roblox-ts packages loom cannot run — { "<specifier>": "<module>" }. Exact-match only, and applied before every alias below, so a shim can override the built-ins. See Package compatibility. |
assets | true | false stops a build working out and downloading its rbxassetid:// images — both the scan of the emitted output and, with targets, the prerender pass that mounts each one (0.9.5). Use it for a build that must not reach the network, or one whose images come from somewhere else. The dev-server route is unaffected. See rbxassetid:// in a static build. |
The plugins
| Name | enforce | apply | Job |
|---|---|---|---|
loom-preview:import-equals | pre | both | Rewrites roblox-ts import X = require("m") to import * as X from "m". |
loom-preview | pre | both | Resolution (.luau-main fallback) and the config hook. |
loom-preview:serve-globals | pre | serve | Injects the Roblox globals script into <head>. |
loom-preview:gallery-targets | — | serve | Only with targets: serves and watches the virtual:loom-targets import map. |
loom-preview:index-html | pre | both | Unless html: false: the generated page, in serve and build. |
enforce: "pre" on the resolver is load-bearing. Vite’s core vite:resolve runs ahead of normal
user plugins, so a normal-phase resolveId would never see the bare specifiers whose package main
points at Luau.
Config contributions
The config hook returns this object. Vite deep-merges it with your own config, and lets you
override it.
{ esbuild: { jsx: "automatic" }, optimizeDeps: { include: [ "react", "react/jsx-runtime", "react/jsx-dev-runtime", // …plus, when loom is installed under node_modules: "@loom-dev/react", "react-reconciler", "react-reconciler/constants", ], exclude: [ "@loom-dev/preview", "@loom-dev/vide", "@loom-dev/runtime", "@loom-dev/renderer", "@loom-dev/scene", "@loom-dev/layout", // …plus "@loom-dev/react" when loom resolves to a source checkout. ], }, resolve: { alias: [ /* see below */ ] }, server: { fs: { allow: [<loom package roots>, searchForWorkspaceRoot(projectRoot)] } },}esbuild.jsx: "automatic"— roblox-ts source never imports React, so the classic transform would throwReact is not defined.optimizeDeps.exclude— the loom packages own the WASM engine. Pre-bundling would break theirnew URL(...wasm)references, and several hold module state that must exist once.optimizeDeps.include— an installed@loom-dev/reactis the exception. It imports the CommonJSreact-reconciler, and Vite serves an excluded dep’s imports raw, which dies ondoes not provide an export named 'DefaultEventPriority'. Every included id is also aliased to an absolute path, because Vite resolvesoptimizeDepsentries from your project root. A source checkout is left unbundled, so developing loom keeps its HMR.optimizeDepsandserver.fsare dev-only. The aliases andesbuild.jsxdrive both modes.
Alias table
Order matters. More specific patterns come first.
| Pattern | Resolves to |
|---|---|
Every shims entry | The module you named. Exact match, ahead of everything below — so a shim can replace any built-in. |
@rbxts/ui-labs, @rbxts/ripple, @rbxts/react-ripple | Loom’s built-in adapters. Exact matches: @rbxts/ui-labs/controls and @rbxts/ui-labs-extra are deliberately not captured. |
@rbxts/react-roblox | The preview client — createRoot, createPortal, setPreviewTheme. Exact, not subpath-globbing: an unadapted subpath must reach the entrypoint diagnostic rather than silently landing on createRoot. |
@rbxts/services | Service singletons backed by getService. |
@rbxts/react/jsx-runtime | React’s JSX runtime (absolute path). |
@rbxts/react/jsx-dev-runtime | React’s dev JSX runtime (absolute path). |
@rbxts/react | The compatibility facade: loom’s one React instance forwarded by identity, plus the Roblox-only surface — ReactComponent, Event / Change / Tag, None — and bindings. |
react/jsx-runtime | Same absolute path as above. |
react/jsx-dev-runtime | Same absolute path as above. |
react | One absolute React path, resolved from the preview package. |
react-reconciler, react-reconciler/constants, @loom-dev/react, and the loom packages it imports | Absolute paths — added only for an installed loom, so the pre-bundled adapter chunk can find them from node_modules/.vite/deps. |
@rbxts/vide | @loom-dev/vide. |
All targets are absolute paths, resolved as ../src relative to the executing plugin module.
They work whether loom is installed, linked from a checkout, or pointed at a foreign workspace.
react, react/jsx-runtime and react/jsx-dev-runtime are rewritten for every module in the config, and esbuild.jsx: "automatic" applies globally. Adding loomPreview() to an existing React app’s Vite config replaces that app’s React — keep the preview in its own project.
Absolute-path aliases are used rather than resolve.dedupe, because dedupe would re-anchor React at your project root, which may hoist a different major. The aliased @rbxts/react and the reconciler’s React must be the same instance.
The generated page
Unless html: false, the plugin owns the entry document.
Serve. A middleware answers / and /index.html with the generated document, run through
transformIndexHtml so Vite’s own injections still apply. It is base-aware, so an embedded gallery
mounted under /loom-preview/ answers there.
- Without
targets, it registers after Vite’s own middlewares and only when the project root has noindex.html. - With
targets, it registers ahead of them and always wins. Its module script points at the shell through a/@fs/URL, and the shell pulls the target map fromvirtual:loom-targets.
Build. With no dev-server URL space, the same document is served as a virtual
<root>/index.html wired up as the Rollup input. That is what makes vite build work in a project
with no HTML file. An explicit build.rollupOptions.input is left alone. In gallery mode the page’s
script imports the globals, then a generated target map of relative import()s Rollup code-splits
per target, then the shell.
With no entry option, the first existing path wins:
src/main.client.tsx src/main.client.tssrc/client/main.client.tsx src/client/main.client.tssrc/main.tsx src/main.ts src/index.tsx src/client.tsxNone of them, no index.html and no targets is an error naming the candidates, raised when the page is first requested or the build starts.
Globals injection
Under serve, the plugin registers a virtual module virtual:loom-globals and injects it into
<head> as a real script tag:
<script type="module" src="/@id/__x00__virtual:loom-globals"></script>A <script src> rather than an inline bare import, so it resolves whether the index.html is a
real file or generated. The virtual module imports the globals installer by absolute path — a bare
@loom-dev/preview/globals would resolve from the (possibly foreign) project root and fail.
Under build that is unavailable, because transformIndexHtml runs after bundling and an
injected src never joins the graph. Instead the HTML plugin collects the page’s entry modules and
prepends import ".../globals.ts" to each. ESM evaluates imports depth-first in source order, so it
runs before anything else.
Resolution
resolveId handles bare specifiers only — anything starting with ., /, \0, or containing :
falls through untouched. Scoped packages and subpaths never contain a colon.
For a bare specifier from a real importer:
- Source-first redirect. Unless the specifier starts with
@rbxts/, walk up from the importer fornode_modules/<pkg>/package.json. If its"main"ends in.luaor.luau, resolve tosrc/index.ts(x)instead. This runs before Vite resolves anything, so it works even whenout/does not exist.@rbxts/*is excluded because those go through the alias table. - Normal resolution, wrapped in a
try— a package whose"main"points at a missing file can makethis.resolvethrow, which is treated as unresolved. - Post-resolution fallback. If the result is a
.luau/.luafile, walk up to the nearestpackage.jsonand retry that package’ssrc/index.ts(x).
Verdicts are memoized per specifier. false means “not Luau, let normal resolution handle it”,
which is why the memo can be keyed on the specifier alone.
The import-equals transform
Applies to any .ts/.tsx file outside node_modules — previewed workspace sources resolve
through symlinks to real paths outside it, so the exclusion is safe.
The regex is anchored to line starts, so const x = require(...) and commented-out forms never
match, and the quote character is backreferenced against mixed quotes. Files with no require
substring short-circuit, and the rewrite is idempotent.
It runs in both serve and build, because esbuild lowers import-equals identically in either mode.
Other exports
| Specifier | Contents |
|---|---|
@loom-dev/preview | createRoot, LoomReactRoot, loomPreview, LoomPreviewOptions. |
@loom-dev/preview/vite | loomPreview, plus findEntry / ENTRY_CANDIDATES (the entry lookup, for pre-flighting it yourself). |
@loom-dev/preview/client | createRoot, createPortal, setPreviewTheme, PreviewTheme. |
@loom-dev/preview/globals | Side-effecting: installs the globals, declares their ambient types. |
@loom-dev/preview/services | The service singletons @rbxts/services is aliased to. |
@loom-dev/preview/gallery | Node-side gallery pieces: findLoomTargets, normalizeTargetsPatterns, parseGalleryParams, the codegen helpers, and the package path constants. What loom-dev builds on. |