Reproduction + instrumentation harness for the cross-request tryUseNuxtApp leak under concurrent SSR/prerender (nuxt/nuxt#35011)
tryUseNuxtApp context leak reproMinimal reproduction harness for the cross-request nuxtApp leak in tryUseNuxtApp under concurrent SSR / prerender. Companion to https://github.com/nuxt/nuxt/pull/35011.
ASYNC_CONTEXT=on leaks: 1000 / 13600 calls (~7.4% of all tryUseNuxtApp calls returned the wrong nuxtApp)
ASYNC_CONTEXT=off leaks: 1680 / 13600 calls (~12.4%)
Both deterministically reproduce the cross-request leak. With experimental.asyncContext: true, Nuxt’s withAsyncContext build-plugin reduces the leak rate but does not eliminate it — the underlying ordering bug in tryUseNuxtApp still fires whenever Vue’s currentInstance was leaked from a foreign route’s <App> setup.
tryUseNuxtApp consults Vue’s module-global currentInstance before the Nuxt ALS context. Vue’s compiler transform for top-level await in <script setup> (withAsyncContext) restores currentInstance on resume but never re-unsets it, so it leaks to other concurrently-rendering requests. Nuxt then returns the foreign request’s nuxtApp.
// packages/nuxt/src/app/nuxt.ts (current)
export function tryUseNuxtApp (id?: string): NuxtApp | null {
let nuxtAppInstance
if (hasInjectionContext()) {
nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt // ← reads leaked module-global first
}
nuxtAppInstance ||= getNuxtAppCtx(id).tryUse() // ← ALS fallback never reached when above hits
return nuxtAppInstance || null
}
The minimum ingredients for a deterministic concurrent-SSR leak are surprisingly specific:
app.vue has top-level await in <script setup> → triggers Vue’s withAsyncContext, leaving currentInstance set after __restore().app.vue widens the window during which other concurrent renders’ resumed microtasks can read the leaked global.pages/r/[id].vue has its own await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 800)) — variable per-route delay so renders stagger.<NuxtIsland name="Slow"> × 2 per page — the island’s own server render is another long-running async setup that holds currentInstance for hundreds of ms (this is what makes the race fire reliably; without islands the timing windows close too quickly).plugins/route-marker.ts) that does await Promise.resolve() × 50 — many short yields, each a chance to land in another route’s leak window.nitro.prerender.concurrency: 32.This roughly mirrors what @nuxt/ui docs hits in production: heavy content/component rendering, OG image islands, layouts with async data, and a plugin chain that touches useNuxtApp() after awaits. The repro is intentionally synthetic to keep it small, but the failure mode is identical.
| File | Role |
|---|---|
app.vue |
Top-level await useFetch + post-restore busy-loop (the leak vector) |
pages/r/[id].vue |
200 routes, each with async setup + 2 <NuxtIsland> |
components/islands/Slow.server.vue |
Slow async island (50–250ms) — multiplies in-flight renders |
plugins/route-marker.ts |
Async plugin that yields many times then re-resolves useNuxtApp() |
server/api/nav.ts |
Backing endpoint for app.vue’s useFetch |
patches/nuxt@4.4.4.patch |
Instruments tryUseNuxtApp with leak counters and persists stats to /tmp/nuxt-ctx-stats.json |
nuxt.config.ts |
SSR build + prerender; ASYNC_CONTEXT env toggles experimental.asyncContext |
pnpm install
# experimental.asyncContext: true (Nuxt's withAsyncContext shim active)
ASYNC_CONTEXT=on pnpm repro
# experimental.asyncContext: false (Vue's stock withAsyncContext)
ASYNC_CONTEXT=off pnpm repro
cat /tmp/nuxt-ctx-stats.json
This is nuxt build (full SSR build + prerender), not nuxt generate — matching @nuxt/ui docs’ actual deployment shape. The leak is at prerender time, identical to what would happen in runtime SSR under request concurrency.
{
"total": 13600, // every server-side tryUseNuxtApp call
"ciHit": 5798, // calls where Vue's currentInstance had a $nuxt
"alsHit": 10203, // calls where Nuxt's ALS had a $nuxt
"ciOnly": 3397, // ci-set, ALS empty (Vue render path with no plugin scope active)
"alsOnly": 7802, // ALS-set, ci empty (plugin chain or mid-await body)
"leaks": 1000 // BOTH set, BUT they referred to DIFFERENT nuxtApp instances ← the bug
}
The 1000 leaks are calls where ALS correctly knew which request was active, but tryUseNuxtApp returned a foreign route’s $nuxt from currentInstance instead. Each leak prints a line like:
[NUXT-CTX-LEAK] returned <App>'s nuxtApp (url=/r/47) but ALS had url=/r/12
Confirming the handoff finding from the @nuxt/ui docs build: the leaked instance is almost always <App>, leaked through Vue’s withAsyncContext after the top-level await in app.vue.
Patch node_modules/.pnpm/nuxt@4.4.4*/node_modules/nuxt/dist/app/nuxt.js:
export function tryUseNuxtApp(id) {
- let nuxtAppInstance;
+ let nuxtAppInstance = getNuxtAppCtx(id).tryUse();
- if (hasInjectionContext()) {
+ if (!nuxtAppInstance && hasInjectionContext()) {
nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt;
}
- nuxtAppInstance ||= getNuxtAppCtx(id).tryUse();
return nuxtAppInstance || null;
}
After this reorder, the ALS-bound nuxtApp is authoritative and the cross-request mismatch is structurally impossible. leaks should drop to 0 (with asyncContext: true).
experimental.asyncContext: falseEven with the reorder, experimental.asyncContext: false is still vulnerable to a different leak vector (unctx’s sync module-global being clobbered across awaits by other concurrent runWithContext calls). The tryUseNuxtApp reorder fixes the Vue-currentInstance path; full request isolation requires ALS to be active server-side. Strong follow-up: default experimental.asyncContext: true for server builds.
@nuxt/ui docs build losing <style id="nuxt-ui-colors"> on 25–50% of routes per pnpm generate