DEV Community

CAPTD
CAPTD

Posted on Edited on

TypeError: __exportAll is not a function — the deploy that only breaks after you merge

Production went down twice in one day with the same error, and both times the branch had built and run perfectly on its own preview deployment. Here's what it was, why previews don't catch it, and the check that now stops it reaching production.
The symptom
Every route returns a 500. Not a slow route, not one endpoint — the whole site. The runtime log says:

TypeError: __exportAll is not a function
at file:///var/task/_ssr/server-ATlsWYy_2.mjs:1916:38
at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
at async Object.fetch (file:///var/task/_ssr/ssr.mjs:177:56)

The stack has no application code in it. It fails at module load, before any handler runs, which is why nothing is spared.
The stack is also unhelpful in a specific way: server-ATlsWYy_2.mjs is a generated chunk name that changes on every build, and line 1916 is in code you never wrote. There is nothing to grep for in your own source.
What __exportAll actually is
It's a bundler helper. When a bundler needs to hand you a module namespace object — the thing you get from import * as ns or from await import('./thing') — it generates a small function to copy the exports across. Different bundlers name it differently; in this output it's __exportAll.
So the helper's presence means: somewhere, something asked for a whole module as an object rather than for named bindings.
The cause
A dynamic import() of a module that is also imported statically somewhere else in the same graph.

// at the top of the file
import { buildClaimEmail, verifyClaimToken } from './purchase-claims.server'

// ...and then, further down, inside a function
const helpers = await import('./purchase-claims.server')

That dynamic import buys nothing. The module is already in the bundle, already loaded, already reachable through the static import. But it forces the bundler to construct a namespace object for it anyway, and the helper that builds that object gets placed in whichever chunk the bundler thinks is shared.
On Vercel, Nitro runs a second bundling pass over the already-bundled server output to produce the Lambda. That pass re-chunks. If the helper and the code calling it end up split across two chunks that import each other — in our case server-XXXX.mjs and its split twin server-XXXX2.mjs — you get a circular ESM import where one side reads the helper before the other side has finished defining it. At module-evaluation time the binding is still in its temporal dead zone, so the call site sees undefined.
Hence: __exportAll is not a function.
Why the preview build passes
This is the part that cost us the second outage.
The branch's own preview deployment builds the branch tip. That build is fine. The crash appears only after merging, because the merge commit contains a different set of modules, which changes how the bundler groups chunks, which is what puts the helper on the wrong side of a cycle.
So a green preview tells you nothing about whether the merge will boot. The artifact that breaks is one that nobody built until it was already in production.
The fix
Delete the dynamic import. Use the static one that was already there.

import { buildClaimEmail, verifyClaimToken } from './purchase-claims.server'

That's the whole fix. The pattern has a name worth knowing — an ineffective dynamic import: a lazy import of something that isn't lazy, because it's eagerly imported elsewhere in the same bundle. It gives you none of the code-splitting benefit and all of the namespace-object cost.
The check that stops it recurring
Two layers, both running at the end of the build.
Source scan. Walk the source tree and report any module that is imported dynamically in one place and statically in another. Exclude type-only imports, since TypeScript erases those. This catches the root cause in milliseconds and points at both call sites with line numbers, which is far more actionable than a generated chunk name.
Output scan. Read the built SSR output as an import graph and fail if a chunk that defines or imports the helper sits on an import cycle.
That second layer needs calibrating, and this is the part I'd have got wrong by guessing: the mere presence of __exportAll in an app chunk is not the failure. A perfectly healthy build also constructs namespace objects — the framework does it for its own router and server entries. If you fail the build on "helper found", you'll fail every build.
The actual crash signature is the cycle. We verified that by building both real commits with the production preset: the healthy one passes, the crashed one fails and names the offending module.
Making CI test the merge result
Since the branch tip isn't what breaks, the check has to run against the merge commit.
GitHub Actions gives you this for free on the pull_request event: actions/checkout checks out refs/pull/N/merge, which is the merge result rather than the branch tip. Build that, with the same preset production uses, and the check is examining the artifact that will actually deploy.
Two repository settings make it stick, and neither is the default:
• Mark the job as a required status check.
• Enable Require branches to be up to date before merging, so the merge commit the job built is the one that lands.
Without the second, you can pass the check on a stale merge base and still ship something nobody built.
The general lesson
The specific bug is narrow — one framework, one host, one bundler pass. But two things generalise:
A green preview is not a green deploy when your build output depends on the full module graph. Anything that re-chunks at deploy time has this property.
Verify a guard by making it fire. We reintroduced the bad import deliberately on a scratch commit to confirm the build failed with a useful message, then removed it. A guard that has never failed is a guard you're only assuming works.

Top comments (3)

Collapse
 
raknaos profile image
Raknaos •

"A green preview is not a green deploy when your build output depends on the full module graph" is the sentence I'm going to steal. The second bundling pass at the host is the part nobody models: the artifact that shipped was built by a pipeline neither you nor your preview ever ran.

Your "verify a guard by making it fire" line went straight into our own checklist — we had a session-state check that had never once failed, which we later read as health rather than as an untested assertion. On the CI side: do you run the merge-commit build check on every pull, or only when the module graph plausibly moved? On a busy repo the required-check tax is real and I'd like to know how you decided it was worth paying.

Collapse
 
captd profile image
CAPTD •

Thanks — and the session-state check that never failed is exactly the same shape. "Has never fired" and "works" look identical right up until they don't.
On the CI question: every pull, unconditionally. But I should be honest about why that was an easy call — I'm a solo developer on a repo with a handful of PRs a day, and the merge-commit build takes about 20 seconds. The required-check tax you're describing isn't a cost I'm actually paying.
If I were on a busy repo I don't think I'd gate on "the module graph plausibly moved", though. The whole reason both of my outages got through is that the change which broke the bundle looked local and harmless — a dynamic import in one file, a single new dependency. Neither would have tripped a heuristic about the graph moving, because from the diff's point of view it hadn't. The re-chunking happened at the host, in a pass nobody ran.
What I'd probably do instead is keep the check cheap enough that it can run always. Mine is two layers: a source scan that just looks for modules imported both statically and dynamically, which is milliseconds and catches the root cause rather than the symptom, and the full build only after that passes. If the source scan is fast and specific, the expensive layer earns its place.
The other thing that would change my answer: the check has to run on the merge result. A check on the branch tip is cheap and tells you almost nothing here.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.