ITADN

cacheComponents: dynamic-`generateMetadata` check fires on partial fallback shells that Next itself marked `allowEmptyStaticShell` — no userland fix exists

#96933ClosedSogdianDotNet 创建于 16 天前
invalid link
S
SogdianDotNetcommented
## Link to the code that reproduces this **No public reproduction repository — the affected app is private.** Being upfront about what is and isn't verified here, since it affects how much weight to give each section: - **Verified, from real `next build` runs:** the failure itself, the exact error, the instrumented guard inputs (`allowEmpty=true`), the fact that patching one condition in the compiled runtime makes the build pass with *zero* application changes, and that `16.3.1-canary.7` still reproduces. - **Not verified:** the standalone scaffold under "Minimal reproduction" below. It was derived by reduction from the failing app, not executed on its own. Happy to build and publish a runnable minimal repro if that's the blocker for triage — the reduction is straightforward, it just wasn't needed to diagnose this. ## To Reproduce A route with a dynamic segment whose `generateStaticParams` returns a single placeholder set, plus a `generateMetadata` that reads any route param: ``` app/[tenant]/[locale]/page.tsx ``` ```tsx export const generateStaticParams = async () => { // Real apps: build-time route discovery returned nothing (our API is deliberately // unreachable during `next build`), so we emit a sentinel. return [{ tenant: '__placeholder__', locale: 'en' }]; }; export const generateMetadata = async ({ params }) => { const { tenant, locale } = await params; // ← any param read is enough return { title: tenant, alternates: { canonical: `/${locale}` } }; }; // The body must produce a FULL prelude — the check requires `prelude === PreludeState.Full`. // In our app that happens because an ancestor layout resolves the placeholder tenant and calls // `notFound()`, so the shell renders a complete 404 page while `generateMetadata` is still hanging // on the unknown `locale`. Any body that renders without suspending on request-time data will do. export default function Page() { return <main>static body</main>; } ``` `next.config.ts`: `{ cacheComponents: true }`. Run `next build`. ## Current vs. Expected behavior **Current:** the build fails while prerendering the *partial fallback shells* — the export paths where some params are concrete and at least one is still a fallback param: ``` > Export encountered errors on 3 paths: /(routes)/[tenant]/[locale]/(with-header)/(tenant-overview)/page: /__placeholder__/[locale] /(routes)/[tenant]/[locale]/(without-header)/restaurant/[restaurantSlug]/menu/page: /__placeholder__/[locale]/restaurant/[restaurantSlug]/menu /(routes)/[tenant]/[locale]/(without-header)/restaurant/[restaurantSlug]/menu/page: /__placeholder__/en/restaurant/[restaurantSlug]/menu ``` with ``` Route "/[tenant]/[locale]": Next.js encountered uncached or runtime data in `generateMetadata()`. This route's metadata is blocked, but the rest of its content can be prerendered. Ways to fix this: - [static] Use a static metadata export instead of `generateMetadata()` - [cache] Cache the metadata with `"use cache"` in `generateMetadata()` (does not apply to `connection()`) - [dynamic] Render a marker component that calls `await connection()` inside `<Suspense>` on the page Learn more: https://nextjs.org/docs/messages/blocking-prerender-metadata-runtime ``` The fully-concrete shells (`/__placeholder__/en`, `/__placeholder__/en/restaurant/__placeholder__/menu`) and the fully-unknown shell (`/[tenant]/[locale]`) build fine. Only the *partial* fallback shells fail. **Expected:** these shells build. Next.js has already decided they are allowed to be empty/blocking, and none of the three suggested fixes is applicable to a fallback shell (details below). ## Why we believe this is a bug, not a mis-configuration ### 1. Next.js explicitly marks these shells as allowed-to-be-empty, then bypasses that decision `node_modules/next/dist/build/static-paths/app.js` (Phase 2 of the trie walk) sets `throwOnEmptyStaticShell = false` for exactly these routes: ```js // A route is ok not to throw on an empty static shell (and thus // `throwOnEmptyStaticShell` should be `false`) if either of the // following conditions is met: // 1. `hasChildren` is true: ... // 2. `route.fallbackRouteParams.length > minFallbacks`: ... if (hasChildren || route.fallbackRouteParams && route.fallbackRouteParams.length > minFallbacks) { route.throwOnEmptyStaticShell = false // Should not throw on empty static shell. } else { route.throwOnEmptyStaticShell = true // Should throw on empty static shell. } ``` which becomes, in `node_modules/next/dist/build/index.js`: ```js _allowEmptyStaticShell: !route.throwOnEmptyStaticShell, ``` `/__placeholder__/[locale]` has a concrete child (`/__placeholder__/en`), so `hasChildren === true` → `throwOnEmptyStaticShell === false` → `_allowEmptyStaticShell === true`. But `throwIfDisallowedDynamic` in `node_modules/next/dist/server/app-render/dynamic-rendering.js` checks the metadata condition *before* consuming that flag — and says so in its own comment: ```js function throwIfDisallowedDynamic(workStore, prelude, dynamicValidation, serverDynamic, allowEmptyStaticShell) { throwIfSyncIOUsed(workStore, serverDynamic); // The dynamic metadata error is a mistake-detection signal. It fires when the // rest of the shell is otherwise fully static apart from metadata, suggesting // the dynamic data access in `generateMetadata` was probably unintentional. // That condition is independent of whether the user or build phase accepted // an empty shell, so we surface it before any opt-in bypass. if (prelude === 0 && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) { console.error((0, _blockingroutemessages.createDynamicOrRuntimeMetadataError)(workStore.route).message); throw new _staticgenerationbailout.StaticGenBailoutError(); } // Either flag expresses "this shell is allowed to be empty/blocking": // - `allowEmptyStaticShell` covers `instant = false` (user opt-in) // and the build-phase fallback-shell case. // - `hasSuspenseAboveBody` is the structural opt-in inside the user's root // layout. // Treat them as synonyms for the purpose of bypassing shell-failure errors. if (allowEmptyStaticShell || dynamicValidation.hasSuspenseAboveBody) { return; } ... } ``` The second comment states `allowEmptyStaticShell` "covers … **the build-phase fallback-shell case**" — which is precisely the case that the first check rejects before that `return` is reachable. We instrumented `throwIfDisallowedDynamic` to log its inputs. For the failing path: ``` [[GUARD]] /[tenant]/[locale] prelude=0 allowEmpty=true hasAllowedDynamic=false hasDynamicMetadata=true hasSuspenseAboveBody=false errs=0 ``` `allowEmpty=true` — the bypass is armed, the guard just never gets to it. ### 2. The mistake-detection premise does not hold for a fallback shell The check assumes the dynamic access in `generateMetadata` "was probably unintentional." In a fallback shell it is *unavoidable*: the metadata depends on a route param, and in a fallback shell that param is by definition unknown. `createStaticPrerenderParams` in `node_modules/next/dist/server/request/params.js` hangs the **entire** params promise if *any* param is a fallback param: ```js const fallbackParams = prerenderStore.fallbackRouteParams; if (hasFallbackRouteParams(underlyingParams, fallbackParams)) { // This params object has one or more fallback params, so we need // to consider the awaiting of this params object dynamic. return makeHangingParams(underlyingParams, workStore, prerenderStore); } ``` So `await params` inside `generateMetadata` can never resolve for `/__placeholder__/[locale]`, no matter which properties are destructured, and no matter whether the function is `'use cache'`. ### 3. None of the three suggested fixes can work here | Suggested fix | Why it can't apply | |---|---| | `[static]` static `metadata` export | Title/description/canonical are per-tenant and per-locale; they *are* the route params. | | `[cache]` `'use cache'` in `generateMetadata` | `'use cache'` doesn't make a hanging params promise resolve — see (2). The docs' own `'use cache'` + `generateStaticParams` example only covers *enumerated* params, not fallback shells. | | `[dynamic]` `connection()` marker inside `<Suspense>` on the page | The page subtree is never rendered for these shells: an ancestor layout resolves the placeholder tenant and throws `notFound()`, so nothing inside the page — marker included — mounts. Verified empirically. | Both documented opt-outs are also ineffective, for the same structural reason: - `export const instant = false` — resolves through `isPageAllowedToBlock()` into `allowEmptyStaticShell`, i.e. the exact flag this check runs ahead of. - `experimental.instantInsights.validationLevel: 'manual-warning'` — same path. ### 4. One-line change makes the build pass with zero app changes Adding the flag to the condition — i.e. honouring the bypass that the surrounding comment already claims covers this case: ```diff - if (prelude === 0 && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) { + if (prelude === 0 && !allowEmptyStaticShell && dynamicValidation.hasAllowedDynamic === false && dynamicValidation.hasDynamicMetadata) { ``` We applied this to the compiled `app-page*.runtime.*.js` bundles in `node_modules` and re-ran `next build` **with no application changes at all**: the build completes successfully and all shells are emitted as `◐ (Partial Prerender)`, including the three that previously failed. (We are not proposing this exact diff as the fix — moving the check after the bypass, or scoping it to `fallbackRouteParams.size === 0`, may be more appropriate. The point is that the check is the only blocker.) ## Minimal reproduction *Derived from the failure in our app by reduction — we reproduced and diagnosed against our own codebase and have not executed this standalone scaffold. Everything above is from real builds.* 1. `npx create-next-app@16.3.0`, set `cacheComponents: true`. 2. Add `app/[a]/[b]/page.tsx` with: - `generateStaticParams` returning a single set (`[{ a: 'x', b: 'y' }]`), - `generateMetadata` that does `const { a } = await params` and returns a title derived from it, - a page body that renders without suspending on anything request-time (so the prelude is Full). 3. `next build`. Expected failing export path: `/x/[b]` — the partial fallback shell. ## Versions | | | |---|---| | Next.js | **16.3.0** — fails. **16.3.1-canary.7** (2026-08-07) — still fails, identical error. **16.2.12** — builds fine. | | React | 19.2.3 | | next-intl | 4.13.1 | | Node | 24.14.0 | | Bundler | Turbopack (default) | | OS | macOS (darwin 25.5.0). Nothing environment-specific is involved: our build-time API client rejects every request during `next build` by design, so `generateStaticParams` deterministically falls back to the placeholder set on every machine and in CI. | | Config | `cacheComponents: true`, `experimental.serverActions.allowedOrigins` | ## Regression Yes — 16.2.12 → 16.3.0. Likely related to the 16.3.0 partial-fallback-shell work ("Restore partial fallback shell upgrade coverage", "partial fallbacks: adapter support for intermediate shells") combined with the new Instant Navigations validation. ## Additional context The only app-side workaround we found is to render a `<Suspense>`-wrapped `await connection()` marker in a layout **above** the ancestor that short-circuits, so that it actually mounts for the placeholder shell. That makes the build pass, but it sets `hasAllowedDynamic = true` for every route in the app, permanently disabling this mistake-detection everywhere — which is not an acceptable trade for a framework false positive.
关闭于 16 天前 1 条评论