Build1 publisher3 min readPublished Updated
A redundant dynamic import crashed every route at module load in the merged bundle
Both branches served fine on their own preview deployments. Nitro's second bundling pass over the merged server output split the namespace helper away from its call site into an import cycle, and the binding was undefined.
The Engineer · Build desk
What happened
- Production went down twice in one day with the same error, TypeError: __exportAll is not a function, thrown from a generated SSR chunk whose stack contained no application code.
- The trigger was one module, ./purchase-claims.server, imported for named bindings at the top of a file and loaded again with await import() inside a function further down.
- Nitro's second bundling pass over the already-bundled server output re-chunked it, leaving the generated helper and its call site in two chunks that import each other.
- Each branch preview built the branch tip and worked; the merge commit contained a different set of modules, which changed how the bundler grouped those chunks.
- The repair was deleting the dynamic import and using the static import already present at the top of the file.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A pipeline that only builds branch tips cannot tell you whether the bundle you are about to ship will boot, because the chunk layout that fails is first computed by the production build itself.
- exposure Any file in a codebase that imports the same module both ways is a dormant boot failure; it fires when a merge reshuffles chunks, with no edit to the calling code.
- decision Because the trace names a chunk that will not exist after the next build, a team hitting this gets its first line number from a scan of import styles in source, not from the stack.
A bundler emits `__exportAll` when something in the graph asks for a whole module as an object instead of named bindings [4]. `import * as ns` does that. So does `await import('./thing')` [4]. In this codebase the dynamic import named a module the same file already imported statically at the top [5], so it split nothing out and deferred nothing; the module was in the bundle and loaded either way [6]. The write-up calls the pattern "an ineffective dynamic import: a lazy import of something that isn't lazy, because it's eagerly imported elsewhere in the same bundle" [13].
The helper lands in whichever chunk the bundler treats as shared [6]. Here it and its call site ended up in `server-XXXX.mjs` and its split twin `server-XXXX2.mjs`, which import each other [8]. During evaluation one side reads the binding before the other has finished defining it, and at that moment the binding is still in its temporal dead zone, so the call site gets `undefined` [9]. The message is accurate: at that instant `__exportAll` really is not a function. No handler has run yet, so the 500 covers the whole site rather than one endpoint [1][3].
"The artifact that breaks is one that nobody built until it was already in production," the author wrote [20]. The branch preview built the branch tip, and that build was fine [10]. Chunk grouping is decided by the set of modules in the build, so a preview only exercises this class of defect if it builds the commit that will ship [1]. That also bounds how far the lesson carries: it needs a pipeline where a second bundling pass re-chunks output whose grouping depends on the full graph, as Nitro's Lambda pass does on Vercel [7].
The guardrail is two checks at the end of the build. One walks the source tree and reports any module imported dynamically in one place and statically in another, skipping type-only imports because TypeScript erases them; the author reports it running in milliseconds and naming both call sites with line numbers [14]. The other reads the built SSR output as an import graph and fails when a chunk that defines or imports the helper sits on a cycle [15]. Calibrating that second one mattered: healthy builds construct namespace objects too, for the framework's own router and server entries, so failing on the helper's presence would fail every build, and the signature to gate on is the cycle [16][17].
This is one team's account, tagged vercel, nitro and tanstack [18], and the text breaks off where the author starts describing how they verified the cycle signature [19]. What shipped as the repair was a single deleted import [12].
What to watch
- Whether the output-scan cycle check survives a Nitro upgrade that changes chunk naming or splitting behaviour.
- Whether the dual-import source scan ships as a shareable lint rule other teams can run, or stays bespoke build-time code.
- Whether preview deployments start building the merge commit instead of the branch tip.