Repository: vercel/next.js
Pull request: [Cache Components] Do not automatically client render useSearchParams…

… when prerendering

useSearchParams historically always bailed out to client side rendering when prerendering. The idea was that reading search params shouldn't deopt a page from static so instead it simply omitted prerendering any boundary with this hook inside of it. With Cache Components every page can be partially static so there is no huge perf cliff in adding this hook to any particular component. Additionally since client components cannot make a page be dynamic independently (something in the RSC layer must be) using this on an otherwise fully static page will still just result in the current client-only rendering behavior. But if you use useSearchParams on a page that has dynamic RSC it will now SSR when resuming.

Changed files:
filename: packages/next/errors.json
patch: @@ -792,5 +792,6 @@
   "791": "Unexpected match for a pathname \"%s\" with a param \"%s\" of type \"%s\"",
   "792": "Unexpected empty path segments match for a pathname \"%s\" with param \"%s\" of type \"%s\"",
   "793": "No value found for segment key: \"%s\"",
-  "794": "Invalid <Link> with <a> child. Please remove <a>.\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor"
+  "794": "Invalid <Link> with <a> child. Please remove <a>.\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor",
+  "795": "\\`%s\\` was called from a Server Component. Next.js should be preventing %s from being included in server components statically, but did not in this case."
 }

filename: packages/next/src/client/components/bailout-to-client-rendering.ts
patch: @@ -1,29 +0,0 @@
-import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
-import { workAsyncStorage } from '../../server/app-render/work-async-storage.external'
-import { workUnitAsyncStorage } from '../../server/app-render/work-unit-async-storage.external'
-
-export function bailoutToClientRendering(reason: string): void | never {
-  const workStore = workAsyncStorage.getStore()
-
-  if (workStore?.forceStatic) return
-
-  const workUnitStore = workUnitAsyncStorage.getStore()
-
-  if (workUnitStore) {
-    switch (workUnitStore.type) {
-      case 'prerender':
-      case 'prerender-runtime':
-      case 'prerender-client':
-      case 'prerender-ppr':
-      case 'prerender-legacy':
-        throw new BailoutToCSRError(reason)
-      case 'request':
-      case 'cache':
-      case 'private-cache':
-      case 'unstable-cache':
-        break
-      default:
-        workUnitStore satisfies never
-    }
-  }
-}

filename: packages/next/src/client/components/navigation.ts
patch: @@ -23,6 +23,13 @@ const useDynamicRouteParams =
       ).useDynamicRouteParams
     : undefined
 
+const useDynamicSearchParams =
+  typeof window === 'undefined'
+    ? (
+        require('../../server/app-render/dynamic-rendering') as typeof import('../../server/app-render/dynamic-rendering')
+      ).useDynamicSearchParams
+    : undefined
+
 /**
  * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook
  * that lets you *read* the current URL's search parameters.
@@ -45,6 +52,8 @@ const useDynamicRouteParams =
  */
 // Client components API
 export function useSearchParams(): ReadonlyURLSearchParams {
+  useDynamicSearchParams?.('useSearchParams()')
+
   const searchParams = useContext(SearchParamsContext)
 
   // In the case where this is `null`, the compat types added in
@@ -60,14 +69,6 @@ export function useSearchParams(): ReadonlyURLSearchParams {
     return new ReadonlyURLSearchParams(searchParams)
   }, [searchParams]) as ReadonlyURLSearchParams
 
-  if (typeof window === 'undefined') {
-    // AsyncLocalStorage should not be included in the client bundle.
-    const { bailoutToClientRendering } =
-      require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering')
-    // TODO-APP: handle dynamic = 'force-static' here and on the client
-    bailoutToClientRendering('useSearchParams()')
-  }
-
   return readonlySearchParams
 }
 

filename: packages/next/src/server/app-render/dynamic-rendering.ts
patch: @@ -36,6 +36,7 @@ import { DynamicServerError } from '../../client/components/hooks-server-context
 import { StaticGenBailoutError } from '../../client/components/static-generation-bailout'
 import {
   getRuntimeStagePromise,
+  throwForMissingRequestStore,
   workUnitAsyncStorage,
 } from './work-unit-async-storage.external'
 import { workAsyncStorage } from '../app-render/work-async-storage.external'
@@ -640,6 +641,55 @@ export function useDynamicRouteParams(expression: string) {
   }
 }
 
+export function useDynamicSearchParams(expression: string) {
+  const workStore = workAsyncStorage.getStore()
+  const workUnitStore = workUnitAsyncStorage.getStore()
+
+  if (!workStore) {
+    // We assume pages router context and just return
+    return
+  }
+
+  if (!workUnitStore) {
+    throwForMissingRequestStore(expression)
+  }
+
+  switch (workUnitStore.type) {
+    case 'prerender-client': {
+      React.use(
+        makeHangingPromise(
+          workUnitStore.renderSignal,
+          workStore.route,
+          expression
+        )
+      )
+      break
+    }
+    case 'prerender-legacy':
+    case 'prerender-ppr': {
+      if (workStore.forceStatic) {
+        return
+      }
+      throw new BailoutToCSRError(expression)
+    }
+    case 'prerender':
+    case 'prerender-runtime':
+      throw new InvariantError(
+        `\`${expression}\` was called from a Server Component. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.`
+      )
+    case 'cache':
+    case 'unstable-cache':
+    case 'private-cache':
+      throw new InvariantError(
+        `\`${expression}\` was called inside a cache scope. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.`
+      )
+    case 'request':
+      return
+    default:
+      workUnitStore satisfies never
+  }
+}
+
 const hasSuspenseRegex = /\n\s+at Suspense \(<anonymous>\)/
 
 // Common implicit body tags that React will treat as body when placed directly in html

filename: test/e2e/app-dir/missing-suspense-with-csr-bailout/missing-suspense-with-csr-bailout.test.ts
patch: @@ -21,8 +21,13 @@ describe('missing-suspense-with-csr-bailout', () => {
     await next.clean()
   })
 
+  const isCacheComponentsEnabled =
+    process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true'
+
   describe('useSearchParams', () => {
-    const message = `useSearchParams() should be wrapped in a suspense boundary at page "/".`
+    const message = isCacheComponentsEnabled
+      ? `Route "/": A component accessed data, headers, params, searchParams, or a short-lived cache without a Suspense boundary nor a "use cache" above it. See more info: https://nextjs.org/docs/messages/next-prerender-missing-suspense`
+      : `useSearchParams() should be wrapped in a suspense boundary at page "/".`
 
     it('should fail build if useSearchParams is not wrapped in a suspense boundary', async () => {
       const { exitCode } = await next.build()