Skip to content

Vite pre-bundling under bun's isolated linker — one root cause, three failures

State: open/active

  • Bun’s isolated linker hoists nothing, so a file:-wired host resolves none of a linked package’s dependencies from its own node_modules. Vite resolves optimizeDeps entries from the host, so a layer cannot fix its consumers by asking — the host has to be able to resolve the name itself.
  • Three failures come out of this and they look nothing alike: a blank page that never settles, a blank flicker that does, and a duplicated singleton.
  • An include entry the host cannot resolve is not an error and produces no warning. It is ignored, the failure it was written to prevent stays, and nothing reports it.

A Nuxt config declaring vite.optimizeDeps states which failure each entry prevents and links here rather than re-deriving the mechanism. New entries follow this matrix:

SituationEntryAlso required
A CJS dependency of a linked packageincludethe name resolvable from the host — see below
A dependency reached only at runtime (a dynamic import(), a generated client)includeas above
A linked workspace packageexclude when it is edited alongside its consumer, include when it is notask the git log, not your impression — see below
A singleton that breaks when loaded twiceexclude in the layer, plus resolve.dedupethe same list in every layer that reaches it

Resolvability is the whole game, and it has two spellings. A bare 'katex' resolves from the host’s own node_modules. The nested 'parent > katex' form resolves parent from the host first, then the child from that package’s tree. Under bun’s isolated linker, neither spelling helps when the named package is absent from the host — and a transitively-reached layer usually is. Verify before relying on either:

Terminal window
ls node_modules/<bare-name> # for 'name'
ls node_modules/<parent> # for 'parent > child'

If neither resolves, the fix is a devDependency on the host declaring the bare name. Adding one to a host that does not import the package directly looks like dead weight and is not: it is what makes the include entry take effect.

For a linked workspace package, both entries cure the reload, so the choice is about what breaks afterwards. include pre-bundles it, and edits to its source are then served from the stale bundle until someone passes --force or clears node_modules/.cache/vite. That is cheap for a package nobody touches and expensive for one edited in the same sessions as its consumer — a reload is visible and self-correcting, a module that looks live and is not sends the reader hunting in the wrong file. Decide it from the history rather than from an impression of which packages are “upstream”:

Terminal window
git log --oneline --since="90 days ago" -- <package path> # how often
git log --format=%h --since="90 days ago" -- <package path> | # how often
while read h; do git show --stat --format= $h -- <consumer path> |
grep -q . && echo $h; done # ...WITH the consumer

A package that co-changes with its consumer is exclude. @sdlc/dashboard-client is the worked example: 23 commits in 90 days, six of them changing the client and SDF’s app together.

Two tools meet here and neither knows about the other.

Bun’s isolated linker gives every package its own copy of its dependencies and hoists nothing to the root, so a host wired by file: cannot resolve a linked package’s dependencies by bare name.

Vite’s dep pre-bundling decides what to pre-bundle by scanning the entry graph, and resolves optimizeDeps entries relative to the project root — the host, not the layer that asked. A layer’s request propagates through the layer merge and then resolves to nothing.

The three failures are worth telling apart, because the symptom is what a reader is holding when they come looking:

  1. Dev never boots. A CommonJS dependency is served untransformed, arrives as an ES module with no default export, and the client graph dies on the first one. Every page blank, one console error, nothing else — while nuxt build stays green, because Rollup bundles it regardless. The unified / extend / debug case.

  2. Dev boots, then reloads itself. A dependency Vite meets for the first time while serving a page is optimised on the spot, and the page is thrown away and reloaded. A cold start flickers through blank frames before it settles. Unlike (1) it settles on its own, which is how to tell them apart. Anything reached only at runtime lands here: a dynamic import(), a generated transport nothing imports until a page calls the backend.

  3. A singleton loads twice. Pre-bundling a package a host also reaches by another path mints a second copy; CodeMirror hard-errors when two @codemirror/state load. exclude covers dev, resolve.dedupe covers the production build, and both are needed.

The silent-failure property is what makes this expensive. A host declaring an entry it cannot resolve looks correct, boots green, and quietly keeps the failure the entry was written to remove.

Adding an entry: pick the row, check resolvability with the ls above, add the devDependency if neither spelling resolves, and say in the config which failure the entry prevents with an [S-0016-vite-prebundling-under-bun](/planning/standards/vite-prebundling-under-bun/) link for the mechanism.

Diagnosing a blank page: check whether it settles. A permanent blank with one console error is failure (1). A blank that resolves itself after a few reloads is failure (2) — read the dev server’s Vite discovered new dependencies at runtime block, which names the culprits. A hard error naming a duplicated module is failure (3).

Verifying a fix: the dev log’s discovery block is the gate. If a package is still named there, the entry meant to cover it is not working, whatever it looks like. When counting reloads, count document loads, not navigations — a Nuxt app does client-side routing, so a navigation count includes route changes that were never reloads and overstates the problem several times over.

  • An include entry naming something the host cannot resolve. Reads as a fix, is a no-op. SDF has had this twice: @sksizer/app-frame > katex (wrong parent) and @sksizer/obsidian-md > katex (right parent, still not resolvable from the host — ls node_modules/@sksizer/obsidian-md finds nothing). Both left katex in the runtime-discovery block. The working fix was a devDependency on the host plus the bare name.
  • A layer declaring include and expecting consumers to inherit it. The request propagates; the resolution does not. @sksizer/markdown-ui asks for unified / extend / debug and every file:-wired host still declares them itself.
  • Copying another config’s exclude list without a pointer. The codemirror list lives in two configs, the second annotated as the same list for the same reason — duplication documenting itself instead of referencing a home.

Known sites: packages/ts/markdown-ui, solutions/determined/packages/code-editor, solutions/determined/apps/vault-graph, solutions/ontological/desktop/src-nuxt.

optimizeDeps is dev-only. The production build goes through Rollup, which bundles everything regardless — which is why all three failures are invisible to a green nuxt build and can only be caught by booting the dev server.


← Back to Standards