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, which Vite deep-merges with — and lets you override — your own
config.
{ 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 exactly 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 in a published install dies ondoes not provide an export named 'DefaultEventPriority'. Pre-bundling the adapter folds the reconciler into its chunk. Every included id is also aliased to an absolute path, because Vite resolvesoptimizeDepsentries from your project root, which has no@loom-dev/*in it. 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 — so
they work whether loom is installed in the project, linked from a checkout, or pointed at a foreign
workspace by the CLI.
react, react/jsx-runtime and react/jsx-dev-runtime are rewritten for every module in the
config, and esbuild.jsx: "automatic" applies globally too. Adding loomPreview() to an existing
React web app’s Vite config replaces that app’s React. Keep the preview in its own Vite project.
Absolute-path aliases are used rather than resolve.dedupe on purpose: dedupe would re-anchor React
at your project root, which may hoist a different major, and the aliased @rbxts/react and the
reconciler’s React must be the same instance or hooks dispatch breaks.
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 (the HMR client, the globals script) 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— a project that ships one keeps it. - With
targets, it registers ahead of them and always wins: the gallery page (sidebar + stage +#loom-root) is whattargetsasked for. Its module script points at the shell through a/@fs/URL, and the shell pulls the target map fromvirtual:loom-targets.
Build. There is no dev-server URL space, so the same document is served as a virtual
<root>/index.html and wired up as the Rollup input — which is what makes vite build work in a
project with no HTML file at all. An explicit build.rollupOptions.input is left alone. In gallery
mode the page’s script is a generated entry module that 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 and no index.html (and no targets) is an error naming the candidates, raised when
the page is first requested or when 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 by the middleware. The virtual module body 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 mechanism is unavailable: transformIndexHtml runs after bundling, so an
injected src never joins the module graph. Instead the HTML plugin collects the page’s entry
modules — from the generated document, or by reading the <script type="module"> tags out of your
own index.html — and prepends import ".../globals.ts" to each. ESM evaluates imports depth-first
in source order, so a prepended import runs before anything else the entry pulls in.
Resolution
resolveId handles bare specifiers only — anything starting with ., /, \0, or containing :
(so node:, virtual:, data:, http:) falls through untouched. Scoped packages and subpaths never
contain a colon, so the check is safe.
For a bare specifier from a real importer:
- Source-first redirect. Unless the specifier starts with
@rbxts/, walk up from the importer looking fornode_modules/<pkg>/package.json. If that package’s"main"ends in.lua/.luau, resolve to itssrc/index.tsorsrc/index.tsxinstead. This runs before Vite resolves anything, so it works even when the package was never compiled andout/does not exist.@rbxts/*is excluded because those must go through the alias table, not to their own source. - Normal resolution, wrapped in a
try— a package whose"main"points at a missing file can makethis.resolvethrow, and that is treated as unresolved so Vite reports it rather than crashing the plugin. - 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. A false verdict 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 typically resolve
through symlinks to real paths outside node_modules, 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 so mixed quotes inside a specifier cannot
false-positive. Files with no occurrence of the substring require short-circuit immediately. The
rewrite is idempotent — its output no longer matches.
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. |