fix(build): write BUILD_ID via writeBundle so App Router builds emit it#1810
Conversation
The vinext:build-id plugin used closeBundle, which does not fire during the multi-environment createBuilder().buildApp() pipeline used for App Router production builds. As a result dist/server/BUILD_ID was silently never written for pure App Router apps — only hybrid apps (which run a second vite.build() pass) and Pages Router apps got the file. This was masked until server bundles started being minified by default (#1777): the e2e deploy harness (scripts/e2e-deploy.sh) falls back to regex-parsing the buildId out of dist/server/index.js when BUILD_ID is absent, and minification mangles the `get buildId()` / `buildId = "..."` patterns the regex relies on. The result was ~700 App Router deploy-suite tests failing at setup with "Failed to extract build ID from dist/server/index.js". Switch the plugin to writeBundle (mirroring the working vinext:image-config plugin), which fires for every emitted bundle; the existing one-time write guard ensures the file is written exactly once. Add a regression assertion to the App Router production build test.
commit: |
|
|
/bigbonk review |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
A single `vinext build` can instantiate vinext() more than once: the App Router multi-environment build (createBuilder().buildApp()) and the separate Pages Router SSR build for hybrid app+pages apps are distinct plugin instances. With no user generateBuildId, each instance resolved its own random UUID, so the App Router runtime, the Pages Router runtime, the prerender manifest, and dist/server/BUILD_ID could each get a different build ID — risking ISR/seed-cache key mismatches for hybrid apps. The CLI now resolves the build ID once (honoring the user's generateBuildId) and publishes it via __VINEXT_SHARED_BUILD_ID. The plugin adopts it unless the user supplied their own generateBuildId (which is authoritative and already shared because every instance calls it). resolveBuildId()'s standalone semantics are unchanged, so the build-only coordination never leaks into dev or tests. Verified on the hybrid app-router-cloudflare example: the App Router RSC/SSR runtime, Pages entry, prerender manifest, and BUILD_ID file now all carry the same build ID. Adds a coordination test alongside the BUILD_ID emission guard.
|
/bigbonk review for issues |
/bigbonk reviewReviewed both fixes. Fix 1 ( I found one real correctness issue in Fix 2. 🔴 The
|
|
Posted the review on PR #1810. Summary of findings: Review summaryFix 1 ( Fix 2 (shared build ID): 🔴 One real correctness issue. The guard
Suggested fix: drop the guard and always adopt the shared ID when set, since the CLI's Minor: test gap (no coverage of the |
Addresses review on #1810. The previous `!rawConfig?.generateBuildId` guard reintroduced the divergence it was meant to fix: when generateBuildId returns null (documented Next.js behavior) or is non-deterministic, resolveBuildId() mints a fresh random UUID per plugin instance, so a hybrid app+pages build still got divergent IDs across the buildApp() and Pages vite.build() instances. The CLI's resolvedNextConfig.buildId is already the fully-resolved authoritative value (it ran resolveBuildId honoring generateBuildId, including the null→UUID fallback), so the plugin now always adopts the shared ID when set. Add a regression test using generateBuildId: () => null + a shared ID, and document that the CLI intentionally does not clear the env var (the build process exits).
|
Thanks for the review — the Fix: dropped the Test gap: added Env var lifetime: documented at
|
|
/bigbonk re-review for issues |
/bigbonk re-reviewRe-reviewed at Fix 2 — guard dropped, now correct for all cases ✅The
Fix 1 —
|
|
Re-review posted to PR #1810. Summary: The prior correctness issue is resolved and no new blocking issues found. ✅
LGTM. |
#1814) Follow-up to #1810. createRscCompatibilityId() falls back to a fresh randomUUID() per plugin instance when no deploymentId is pinned, so a hybrid app+pages build baked two different RSC-compat tokens — the App Router buildApp() instance and the separate Pages Router vite.build() instance each minted their own. The token gates client-side RSC payload acceptance (the X-Vinext-RSC-Compatibility-Id header), so cross-instance divergence is a latent deploy-skew hazard. Coordinate it the same way as the build ID: relocate createRscCompatibilityId to config/next-config.ts (single source of truth, beside resolveBuildId / resolveDeploymentId), have the CLI resolve it once and publish it via __VINEXT_SHARED_RSC_COMPATIBILITY_ID, and have the plugin always adopt it when set. Reuses deploymentId when configured (already stable). The env var is only ever set by the build CLI, so dev and standalone resolution are unchanged. Adds a coordination test asserting the shared token lands in both the server and client output.
What regressed
The nightly Next.js Deploy Suite regressed by ~700 tests between run 27057467752 (2143 passing) and run 27081060115 (1444 passing). Nearly every App Router fixture failed at setup with:
Root cause
Two interacting facts:
The
vinext:build-idplugin wrotedist/server/BUILD_IDfrom acloseBundlehook.closeBundledoes not fire during the multi-environmentcreateBuilder().buildApp()pipeline used for App Router production builds, so the file was silently never written for pure App Router apps. Only hybrid apps (which run a secondvite.build()pass with a fresh plugin instance) and Pages Router apps produced the file.The e2e deploy harness (
scripts/e2e-deploy.sh→read_build_id) falls back to regex-parsing the buildId out ofdist/server/index.jswhenBUILD_IDis absent. That fallback worked while server bundles were unminified — until perf(build): minify server build environments by default #1777 (perf(build): minify server build environments by default) landed between the two runs. Minification mangles theget buildId() { return "..." }/buildId = "..."patterns the regex relies on, so the fallback started returning no match and every App Router deploy failed at setup.Confirmed by reproducing locally: a pure App Router
buildApp()did not emitdist/server/BUILD_ID, and the regex returnsNO MATCHagainst the minifiedindex.js.Fix 1 — emit BUILD_ID reliably
Switch the
vinext:build-idplugin fromcloseBundletowriteBundle(mirroring the already-workingvinext:image-configplugin).writeBundlefires for every emitted bundle across all environments; the existing one-time write guard ensures the file is written exactly once. Verifieddist/server/BUILD_IDis now emitted for pure App Router, hybrid, and Pages Router builds.Fix 2 — one build ID across all plugin instances
While reviewing Fix 1 we found a pre-existing divergence: a single
vinext buildcan instantiatevinext()more than once (App RouterbuildApp()+ the separate Pages Routervite.build()for hybrid apps). With no usergenerateBuildId, each instance resolved its own random UUID, so the App Router runtime, Pages runtime, prerender manifest, andBUILD_IDfile could each get a different ID — risking ISR/seed-cache key mismatches for hybrid apps.The CLI now resolves the build ID once (honoring the user's
generateBuildId) and publishes it via__VINEXT_SHARED_BUILD_ID; the plugin adopts it unless the user supplied their owngenerateBuildId.resolveBuildId()'s standalone semantics are unchanged, so this build-only coordination never leaks into dev or tests.Verified on the hybrid
app-router-cloudflareexample — the App Router RSC/SSR runtime, Pagesentry.js,vinext-prerender.json, anddist/server/BUILD_IDnow all carry the same build ID.Tests
tests/app-router-production-build.test.tsgains two assertions on thebuildApp()path:dist/server/BUILD_IDis emitted and non-empty (the regression guard).__VINEXT_SHARED_BUILD_IDset, the emitted file and the value baked into the runtime bundle both equal the shared ID.