Vite pre-bundling under bun's isolated linker — one root cause, three failures
State: open/active
Summary
Section titled “Summary”- Bun’s isolated linker hoists nothing, so a
file:-wired host resolves none of a linked package’s dependencies from its ownnode_modules. Vite resolvesoptimizeDepsentries 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
includeentry 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:
| Situation | Entry | Also required |
|---|---|---|
| A CJS dependency of a linked package | include | the name resolvable from the host — see below |
A dependency reached only at runtime (a dynamic import(), a generated client) | include | as above |
| A linked workspace package | exclude when it is edited alongside its consumer, include when it is not | ask the git log, not your impression — see below |
| A singleton that breaks when loaded twice | exclude in the layer, plus resolve.dedupe | the 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:
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”:
git log --oneline --since="90 days ago" -- <package path> # how oftengit 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 consumerA 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:
-
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 buildstays green, because Rollup bundles it regardless. Theunified/extend/debugcase. -
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. -
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/stateload.excludecovers dev,resolve.dedupecovers 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.
How to apply
Section titled “How to apply”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.
Anti-examples
Section titled “Anti-examples”- An
includeentry 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-mdfinds nothing). Both leftkatexin the runtime-discovery block. The working fix was a devDependency on the host plus the bare name. - A layer declaring
includeand expecting consumers to inherit it. The request propagates; the resolution does not.@sksizer/markdown-uiasks forunified/extend/debugand everyfile:-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.