Repository: vercel/next.js
Pull request: [dynamicIO] Do not skip dynamic validation when metadata is dynamic

Metadata can be dynamic without that being an error. A bug was introduced in a recent refactor where the presence of dynamic metadata was incorrectly interpreted as a sign that the metadata did not have a Suspense boundary around it. This would be a problem because metadata is now streaming however it was actually a misuse of the tracking flag and would incorrectly error if you had dynamic metadata at all. This fix addresses the incorrect invariant by removing it.

there is arguably a second bug here in that InvariantError causes the validation of the page to be skipped but it doesn't error the build. That is concerning but not the focus of this change. regardless by removing the incorrect invariant error code the validation begins to work again as expected.

Changed files:
filename: packages/next/src/server/app-render/dynamic-rendering.ts
patch: @@ -42,7 +42,6 @@ import {
   OUTLET_BOUNDARY_NAME,
 } from '../../lib/metadata/metadata-constants'
 import { scheduleOnNextTick } from '../../lib/scheduler'
-import { InvariantError } from '../../shared/lib/invariant-error'
 
 const hasPostpone = typeof React.unstable_postpone === 'function'
 
@@ -653,15 +652,6 @@ export function throwIfDisallowedEmptyStaticShell(
   serverDynamic: DynamicTrackingState,
   clientDynamic: DynamicTrackingState
 ): void {
-  // TODO: Now that metadata is streaming we don't really need to check if it is blocking
-  // the root. We leave this here for now to ensure we've actually covered all our bases here
-  // but we can actually remove this in a future update
-  if (dynamicValidation.hasDynamicMetadata) {
-    throw new InvariantError(
-      'Expected `generateMetadata` not to block the application shell but it did.'
-    )
-  }
-
   if (dynamicValidation.hasSuspenseAboveBody) {
     // This route has opted into allowing fully dynamic rendering
     // by including a Suspense boundary above the body. In this case

filename: test/e2e/app-dir/dynamic-io-errors/dynamic-io-errors.test.ts
patch: @@ -87,6 +87,48 @@ function runTests(options: { withMinification: boolean }) {
         expect($('#sentinel').text()).toBe('sentinel')
       })
     })
+    describe('Dynamic Metadata - Error Route', () => {
+      const { next, isNextDev, skipped } = nextTestSetup({
+        files: __dirname + '/fixtures/dynamic-metadata-error-route',
+        skipStart: true,
+        skipDeployment: true,
+      })
+
+      if (skipped) {
+        return
+      }
+
+      if (isNextDev) {
+        it('does not run in dev', () => {})
+        return
+      }
+
+      beforeEach(async () => {
+        if (!withMinification) {
+          await next.patchFile('next.config.js', (content) =>
+            content.replace(
+              'serverMinification: true,',
+              'serverMinification: false,'
+            )
+          )
+        }
+      })
+
+      // This test is just here because there was a bug when dynamic metadata was used alongside another dynamic IO violation which caused the validation to be skipped.
+      it('should error the build for the correct reason when there is a dynamic IO violation alongside dynamic metadata', async () => {
+        try {
+          await next.start()
+        } catch {
+          // we expect the build to fail
+        }
+        const expectError = createExpectError(next.cliOutput)
+
+        expectError(
+          'Error: Route "/": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary'
+        )
+        expectError('Error occurred prerendering page "/"')
+      })
+    })
     describe('Dynamic Metadata - Static Route With Suspense', () => {
       const { next, isNextDev, skipped } = nextTestSetup({
         files: __dirname + '/fixtures/dynamic-metadata-static-with-suspense',

filename: test/e2e/app-dir/dynamic-io-errors/fixtures/dynamic-metadata-error-route/app/layout.tsx
patch: @@ -0,0 +1,9 @@
+export default function Root({ children }: { children: React.ReactNode }) {
+  return (
+    <html>
+      <body>
+        <main>{children}</main>
+      </body>
+    </html>
+  )
+}

filename: test/e2e/app-dir/dynamic-io-errors/fixtures/dynamic-metadata-error-route/app/page.tsx
patch: @@ -0,0 +1,23 @@
+export async function generateMetadata() {
+  await new Promise((r) => setTimeout(r, 0))
+  return { title: 'Dynamic Metadata' }
+}
+
+export default async function Page() {
+  return (
+    <>
+      <p>
+        This page has dynamic metadata and is also dynamic in the page without a
+        Suspense boundary. This is violation of dynamic IO rules. This test
+        exists however because there was previously a bug that would incorrectly
+        report this as an invariant error.
+      </p>
+      <Dynamic />
+    </>
+  )
+}
+
+async function Dynamic() {
+  await new Promise((r) => setTimeout(r))
+  return <p id="dynamic">Dynamic</p>
+}

filename: test/e2e/app-dir/dynamic-io-errors/fixtures/dynamic-metadata-error-route/next.config.js
patch: @@ -0,0 +1,12 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+  experimental: {
+    ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true',
+    dynamicIO: true,
+    serverMinification: true,
+  },
+}
+
+module.exports = nextConfig