Every symptom below was reproduced on a real project, ordered roughly by how often it comes up.
A long list of symptoms are all fixed bugs whose only remedy is upgrading. Missing Luau globals. Text sizing and wrapping that disagrees between two machines, or between vite dev and a deployed build. Clipped descenders. A ScrollingFrame that never scrolls. rbxassetid:// images blank in a static build. AutomaticSize="XY" collapsing to zero. Check npm ls loom-dev first, and see Scope and status for what each release closed.
Quick index
| Symptom | Cause | Fix |
|---|---|---|
Blank page, [loom] nothing mounted into #loom-root after 2s | Entry exports instead of mounting | Call createRoot().render(...) at top level |
ReferenceError: UDim2 is not defined in a built page | A page whose entry the plugin could not see | import "@loom-dev/preview/globals" first in the entry |
Invalid hook call / two React copies | Stale optimizer cache, or React 19 installed | rm -rf node_modules/.vite; pin React 18 |
Failed to resolve entry for package "@loom-dev/layout" | Plugin imported by file path, not bare specifier | Import @loom-dev/preview/vite |
Cannot find module '.../dist/vite.js' when loading the config | link:ed checkout that was never built | pnpm build:packages in the checkout |
no index.html and no client entry found | Neither a conventional entry nor an index.html under the root | Pass entry, or targets for a gallery |
no targets matched … — nothing to build | Glob missed, or unsupported glob syntax | Only **, **/ and * are supported |
Package "…" only provides a Lua/Luau runtime | A declaration-only roblox-ts package | Add a shims entry |
RollupError: Expected ';', '}' or <eof> in a .luau file, build only | The same thing, reached by a target the dev server never loaded | As above |
| Renders, but looks wrong | Unimplemented property | Check Supported instances and properties |
| Text or buttons spill out of their container | Content wider than the parent allows | Expected — AutomaticSize is bounded and the engine overflows rather than widening |
| Runs off the edge on a phone, or a narrow window reflows | Expected — the scene lays out against the real viewport, as the engine does | ?base=960 keeps the wide composition and scales it to fit; see Previews on a phone |
no face is loaded for the Roblox font family "…" | A family loom names but cannot redistribute — Gotham, BuilderSans, … | Register your own copy |
| A list orders itself alphabetically | SortOrder defaults to Name, as it does in Studio | Set SortOrder={Enum.SortOrder.LayoutOrder} |
| You cannot tell what a preview is doing at all | — | Open the debug panel: ?debug=1, the sidebar’s debug button, or Ctrl+Alt+D |
Blank page, nothing mounted into #loom-root after 2s
[loom] nothing mounted into #loom-root after 2s — does your entry callcreateRoot().render(<App />) at the top level?The globals installer sets a two-second timer and checks whether #loom-root has any children, so
this means loom loaded fine and your entry mounted nothing. Almost always the entry exports a
component instead of rendering one — loom never calls your component for you:
import { createRoot } from "@rbxts/react-roblox";import { App } from "./App";
createRoot().render(<App />); // this line is the whole contractThe other cause is loom picking a different entry than you expected. It takes the first match from
a fixed candidate list, so a project with both src/main.client.tsx and src/main.tsx uses the
former. Check the <script src> in the served page, and pass loomPreview({ entry }) to override.
ReferenceError: UDim2 is not defined
The Roblox datatype globals were never installed. Under the dev server, a plugin injects them as a
script tag. Under vite build, the plugin prepends the globals import to the page’s entry modules,
since a tag injected by transformIndexHtml never joins the bundle.
In a page you built, the plugin could not see your entry. Likely a build.rollupOptions.input
of your own, or an entry pulled in by something other than a <script type="module" src>. Add the
import yourself, first in the entry:
import "@loom-dev/preview/globals";Under the dev server, something is loading before the injected script — most likely an inline
<script type="module"> in your index.html. Move that logic into the entry module.
Warning: Invalid hook call / “more than one copy of React”
Two React instances are in the graph. The plugin aliases bare react and both JSX runtimes to one
absolute path, so the adapter, the reconciler and your components converge. When it happens anyway,
one of three things is true:
- A stale optimizer cache, where Vite re-optimized mid-session and served a mix of old and new
pre-bundles.
rm -rf node_modules/.viteand restart. - React 19 is installed. The adapter’s
react-reconciler@0.29reads React 18 internals that React 19 renamed. Pin React 18 in the preview project. - You merged
loomPreview()into an app that has its own React. The aliases are global to the Vite config. Give the preview its own Vite project — see Vite integration.
Failed to resolve entry for package "@loom-dev/layout"
You imported the plugin by file path instead of by bare specifier. The plugin resolves the modules it aliases relative to its own location, and bundling it into your config moves that anchor.
import { loomPreview } from "@loom-dev/preview/vite"; // always thisThe config fails to load with Cannot find module '.../dist/vite.js'
A link:ed source checkout that has never been built. Run pnpm build:packages in the checkout. To
work against unbuilt sources, vite --configLoader runner loads the config through Vite’s module
runner instead of pre-bundling it.
loom: no index.html and no client entry found
The directory has neither an index.html nor any of the eight recognized entry paths — usually you
aimed at a workspace root rather than an app. Point deeper, name the entry with
loomPreview({ entry }) (the CLI has no flag for it), or switch to gallery mode with --targets.
no targets matched ... — nothing to build
loom build found no files matching the glob. A bare directory expands to <dir>/**/*.loom.tsx, and
the matcher supports only **, **/ and * — no braces, no ?, no negation. The walk also skips
node_modules and dot-directories.
A scene renders but looks wrong
Check the property against Supported instances and
properties before assuming a loom bug. A surprising number of
“wrong render” reports are a prop that is typed, settable, and read by nothing —
UIGradient.Offset, ScrollBarThickness. The one image combination loom cannot reproduce is tiling
a sprite window, and it warns rather than pretending.
A preview is a high-fidelity approximation, not a Studio replacement.
Text is a different size, or wraps differently, than on your colleague’s machine
loom: no face is loaded for the Roblox font family "Gotham" — its text is paintedand measured in the system fallback instead, …A browser ships none of Roblox’s typefaces. AutomaticSize and TextWrapped are driven by
measuring glyph widths, so a missing face moves the layout, not just the paint. The preview loads
28 of the engine’s families itself, with no import and no configuration. The warning survives for
the ones loom cannot redistribute: Gotham, BuilderSans and six more. Register your own copy with
registerFont. Both halves are in Fonts and text metrics.
The debug panel’s fonts section answers this directly: it lists every typeface the scene resolved to and whether the browser really loaded it.
Content spills out of its container
An AutomaticSize object grows only up to the room its parent leaves, which is what Roblox does.
Content with an irreducible minimum overflows the box instead of widening it: a long unbreakable
word, a row of buttons that will not fit. That overflow is the same one Studio shows.
If a preview overflows and Studio does not, the layout is genuinely too small for its content at that viewport. Give the parent more room, or let the text wrap.
If you are developing loom itself and edit anything under crates/loom-layout, the browser keeps
using the previously built binary until you run pnpm build:native. A layout fix that “did nothing”
is usually a missing rebuild.
A target throws and the page still works
By design. The gallery shell is plain DOM with a React error boundary around each target. Import
failures, bad preview exports and render throws each land in an inline red panel with a stack,
while the sidebar stays usable. Switching targets clears it.