Repository: vercel/next.js
Pull request: Ensure constructor for `useSearchParams` can be imported for `instanceof` checks

Fixes https://github.com/vercel/next.js/issues/86501
Closes https://github.com/vercel/next.js/pull/86649

The same constructor is relevant for `instanceof` checks. Previously `useSearchParams() instanceof ReadonlyURLSearchParams` lead to hydration mismatches.

Changed files:
filename: packages/next/src/client/components/navigation-devtools.ts
patch: @@ -2,14 +2,14 @@ import type { FlightRouterState } from '../../shared/lib/app-router-types'
 import type { Params } from '../../server/request/params'
 import {
   createDevToolsInstrumentedPromise,
+  ReadonlyURLSearchParams,
   type InstrumentedPromise,
   type NavigationPromises,
 } from '../../shared/lib/hooks-client-context.shared-runtime'
 import {
   computeSelectedLayoutSegment,
   getSelectedLayoutSegmentPath,
 } from '../../shared/lib/segment'
-import { ReadonlyURLSearchParams } from './readonly-url-search-params'
 
 /**
  * Promises are cached by tree to ensure stability across suspense retries.
@@ -28,7 +28,7 @@ const layoutSegmentPromisesCache = new WeakMap<
  * Creates instrumented promises for layout segment hooks at a given tree level.
  * This is dev-only code for React Suspense DevTools instrumentation.
  */
-export function createLayoutSegmentPromises(
+function createLayoutSegmentPromises(
   tree: FlightRouterState
 ): LayoutSegmentPromisesCache | null {
   if (process.env.NODE_ENV === 'production') {

filename: packages/next/src/client/components/navigation.ts
patch: @@ -11,12 +11,12 @@ import {
   PathnameContext,
   PathParamsContext,
   NavigationPromisesContext,
+  ReadonlyURLSearchParams,
 } from '../../shared/lib/hooks-client-context.shared-runtime'
 import {
   computeSelectedLayoutSegment,
   getSelectedLayoutSegmentPath,
 } from '../../shared/lib/segment'
-import { ReadonlyURLSearchParams } from './readonly-url-search-params'
 
 const useDynamicRouteParams =
   typeof window === 'undefined'
@@ -61,15 +61,15 @@ export function useSearchParams(): ReadonlyURLSearchParams {
   // In the case where this is `null`, the compat types added in
   // `next-env.d.ts` will add a new overload that changes the return type to
   // include `null`.
-  const readonlySearchParams = useMemo(() => {
+  const readonlySearchParams = useMemo((): ReadonlyURLSearchParams => {
     if (!searchParams) {
       // When the router is not ready in pages, we won't have the search params
       // available.
-      return null
+      return null!
     }
 
     return new ReadonlyURLSearchParams(searchParams)
-  }, [searchParams]) as ReadonlyURLSearchParams
+  }, [searchParams])
 
   // Instrument with Suspense DevTools (dev-only)
   if (process.env.NODE_ENV !== 'production' && 'use' in React) {
@@ -285,13 +285,17 @@ export function useSelectedLayoutSegment(
 export { unstable_isUnrecognizedActionError } from './unrecognized-action-error'
 
 // Shared components APIs
+export {
+  // We need the same class that was used to instantiate the context value
+  // Otherwise instanceof checks will fail in usercode
+  ReadonlyURLSearchParams,
+}
 export {
   notFound,
   forbidden,
   unauthorized,
   redirect,
   permanentRedirect,
   RedirectType,
-  ReadonlyURLSearchParams,
   unstable_rethrow,
 } from './navigation.react-server'

filename: packages/next/src/shared/lib/hooks-client-context.shared-runtime.ts
patch: @@ -2,6 +2,7 @@
 
 import { createContext } from 'react'
 import type { Params } from '../../server/request/params'
+import { ReadonlyURLSearchParams } from '../../client/components/readonly-url-search-params'
 
 export const SearchParamsContext = createContext<URLSearchParams | null>(null)
 export const PathnameContext = createContext<string | null>(null)
@@ -17,8 +18,8 @@ export type InstrumentedPromise<T> = Promise<T> & {
 
 export type NavigationPromises = {
   pathname: InstrumentedPromise<string>
-  searchParams: InstrumentedPromise<any> // ReadonlyURLSearchParams
-  params: InstrumentedPromise<any> // Params
+  searchParams: InstrumentedPromise<ReadonlyURLSearchParams>
+  params: InstrumentedPromise<Params>
   // Layout segment hooks (updated at each layout boundary)
   selectedLayoutSegmentPromises?: Map<
     string,
@@ -44,6 +45,8 @@ export function createDevToolsInstrumentedPromise<T>(
   return promise
 }
 
+export { ReadonlyURLSearchParams }
+
 if (process.env.NODE_ENV !== 'production') {
   SearchParamsContext.displayName = 'SearchParamsContext'
   PathnameContext.displayName = 'PathnameContext'

filename: test/e2e/app-dir/hooks/app/hooks/use-search-params/instanceof/page.js
patch: @@ -0,0 +1,42 @@
+'use client'
+
+import { useSearchParams, ReadonlyURLSearchParams } from 'next/navigation'
+import { useSyncExternalStore } from 'react'
+
+export default function Page() {
+  const searchParams = useSearchParams()
+  const searchParamsClient = useSyncExternalStore(
+    () => {
+      return () => {}
+    },
+    () => {
+      return searchParams
+    },
+    () => {
+      return null
+    }
+  )
+
+  return (
+    <ul>
+      <li>
+        server:{' '}
+        <span data-testid="server" suppressHydrationWarning>
+          {searchParams instanceof ReadonlyURLSearchParams
+            ? 'PASS instanceof check'
+            : `FAILED Received: "${String(searchParams)}" instead instanceof ${searchParams.constructor.name}`}
+        </span>
+      </li>
+      <li>
+        client:{' '}
+        <span data-testid="client">
+          {searchParamsClient === null
+            ? '<pending>'
+            : searchParamsClient instanceof ReadonlyURLSearchParams
+              ? 'PASS instanceof check'
+              : `FAILED Received: "${String(searchParamsClient)}" instead instanceof ${searchParamsClient.constructor.name}`}
+        </span>
+      </li>
+    </ul>
+  )
+}

filename: test/e2e/app-dir/hooks/hooks.test.ts
patch: @@ -71,6 +71,26 @@ describe('app dir - hooks', () => {
         expect($('#params-not-real').text()).toBe('N/A')
       })
     }
+
+    it('should be able to use instanceof ReadonlyURLSearchParams', async () => {
+      const browser = await next.browser(
+        '/hooks/use-search-params/instanceof?foo=bar'
+      )
+
+      const [server, client] = await Promise.all([
+        browser
+          .elementByCss('[data-testid="server"]')
+          .then((handle) => handle.text()),
+        browser
+          .elementByCss('[data-testid="client"]')
+          .then((handle) => handle.text()),
+      ])
+
+      expect({ client, server }).toEqual({
+        server: 'PASS instanceof check',
+        client: 'PASS instanceof check',
+      })
+    })
   })
 
   describe('useDraftMode', () => {