Loomreference

Vite plugin

Everything loomPreview() contributes — its options, the plugins it returns, every alias, the config it merges, the page it generates, and which hooks run in serve versus build.

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.
targetsGallery 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-targetsserveOnly 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, 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 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 exactly 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 in a published install dies on does 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 resolves optimizeDeps entries from your project root, which has no @loom-dev/* in it. 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 — so they work whether loom is installed in the project, linked from a checkout, or pointed at a foreign workspace by the CLI.

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 no index.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 what targets asked for. Its module script points at the shell through a /@fs/ URL, and the shell pulls the target map from virtual: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.

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:

  1. Source-first redirect. Unless the specifier starts with @rbxts/, walk up from the importer looking for node_modules/<pkg>/package.json. If that package’s "main" ends in .lua/.luau, resolve to its src/index.ts or src/index.tsx instead. This runs before Vite resolves anything, so it works even when the package was never compiled and out/ does not exist. @rbxts/* is excluded because those must go through the alias table, not to their own source.
  2. Normal resolution, wrapped in a try — a package whose "main" points at a missing file can make this.resolve throw, and that is treated as unresolved so Vite reports it rather than crashing the plugin.
  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. 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

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.