Loomreference

Vite plugin

Everything `loomPreview()` contributes — options, plugins, aliases, merged config and the generated page.

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;
}
OptionDefaultEffect
entryauto-detectedClient 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.
htmltruefalse 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.
assetstruefalse 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

NameenforceapplyJob
loom-preview:import-equalsprebothRewrites roblox-ts import X = require("m") to import * as X from "m".
loom-previewprebothResolution (.luau-main fallback) and the config hook.
loom-preview:serve-globalspreserveInjects the Roblox globals script into <head>.
loom-preview:gallery-targets—serveOnly with targets: serves and watches the virtual:loom-targets import map.
loom-preview:index-htmlprebothUnless 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 throw React is not defined.
  • optimizeDeps.exclude — the loom packages own the WASM engine. Pre-bundling would break their new URL(...wasm) references, and several hold module state that must exist once.
  • optimizeDeps.include — an installed @loom-dev/react is the exception. It imports the CommonJS react-reconciler, and Vite serves an excluded dep’s imports raw, which dies on does not provide an export named 'DefaultEventPriority'. Every included id is also aliased to an absolute path, because Vite resolves optimizeDeps entries from your project root. A source checkout is left unbundled, so developing loom keeps its HMR.
  • optimizeDeps and server.fs are dev-only. The aliases and esbuild.jsx drive both modes.

Alias table

Order matters. More specific patterns come first.

PatternResolves to
Every shims entryThe module you named. Exact match, ahead of everything below — so a shim can replace any built-in.
@rbxts/ui-labs, @rbxts/ripple, @rbxts/react-rippleLoom’s built-in adapters. Exact matches: @rbxts/ui-labs/controls and @rbxts/ui-labs-extra are deliberately not captured.
@rbxts/react-robloxThe preview client — createRoot, createPortal, setPreviewTheme. Exact, not subpath-globbing: an unadapted subpath must reach the entrypoint diagnostic rather than silently landing on createRoot.
@rbxts/servicesService singletons backed by getService.
@rbxts/react/jsx-runtimeReact’s JSX runtime (absolute path).
@rbxts/react/jsx-dev-runtimeReact’s dev JSX runtime (absolute path).
@rbxts/reactThe compatibility facade: loom’s one React instance forwarded by identity, plus the Roblox-only surface — ReactComponent, Event / Change / Tag, None — and bindings.
react/jsx-runtimeSame absolute path as above.
react/jsx-dev-runtimeSame absolute path as above.
reactOne absolute React path, resolved from the preview package.
react-reconciler, react-reconciler/constants, @loom-dev/react, and the loom packages it importsAbsolute 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.

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 no index.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 from virtual: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.

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:

  1. Source-first redirect. Unless the specifier starts with @rbxts/, walk up from the importer for node_modules/<pkg>/package.json. If its "main" ends in .lua or .luau, resolve to src/index.ts(x) instead. This runs before Vite resolves anything, so it works even when out/ does not exist. @rbxts/* is excluded because those go through the alias table.
  2. Normal resolution, wrapped in a try — a package whose "main" points at a missing file can make this.resolve throw, which is treated as unresolved.
  3. Post-resolution fallback. If the result is a .luau/.lua file, walk up to the nearest package.json and retry that package’s src/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

SpecifierContents
@loom-dev/previewcreateRoot, LoomReactRoot, loomPreview, LoomPreviewOptions.
@loom-dev/preview/viteloomPreview, plus findEntry / ENTRY_CANDIDATES (the entry lookup, for pre-flighting it yourself).
@loom-dev/preview/clientcreateRoot, createPortal, setPreviewTheme, PreviewTheme.
@loom-dev/preview/globalsSide-effecting: installs the globals, declares their ambient types.
@loom-dev/preview/servicesThe service singletons @rbxts/services is aliased to.
@loom-dev/preview/galleryNode-side gallery pieces: findLoomTargets, normalizeTargetsPatterns, parseGalleryParams, the codegen helpers, and the package path constants. What loom-dev builds on.