Repository: vercel/next.js
Pull request: [next-server] fix params duplicate in query after rewrite

### What

In Vercel deployments, Next.js uses a special query to store the matched route params fr dynamic routes, which means. the matched dynamic params are stored in query. So we have a logic that stripping the route params from query params.

In #76177 we tried to solve a problem where a route param name exists both in search query and route params, e.g. the name `key` in `?key=` and `/[key]`. To keep the name being stripped out, #76177 used a different way to retain the route params.

This caused a regression on the rewrites logic. The server util `handleRewrites` might add more queries to the parsed url query, which can be the matched dynamic route params. Then it lead to that the route params showed up in search params. The logic was only related to minimal mode, so only Vercel deployment was impacted.

This PR fixed the issue by still stripping the route params from parsed url query, but we skip for the query name which was also used in route params.

Closes NEXT-4075

Changed files:
filename: packages/next/src/server/base-server.ts
patch: @@ -1196,28 +1196,34 @@ export default abstract class Server<
             parsedUrl.pathname = `/${defaultLocale}${parsedUrl.pathname}`
           }
 
+          // Store a copy of `parsedUrl.query` before calling handleRewrites.
+          // Since `handleRewrites` might add new queries to `parsedUrl.query`.
+          const originQueryParams = { ...parsedUrl.query }
+
           const pathnameBeforeRewrite = parsedUrl.pathname
           const rewriteParamKeys = Object.keys(
             utils.handleRewrites(req, parsedUrl)
           )
-          const didRewrite = pathnameBeforeRewrite !== parsedUrl.pathname
-
-          if (didRewrite && parsedUrl.pathname) {
-            addRequestMeta(req, 'rewroteURL', parsedUrl.pathname)
-          }
 
           // Create a copy of the query params to avoid mutating the original
           // object. This prevents any overlapping query params that have the
           // same normalized key from causing issues.
           const queryParams = { ...parsedUrl.query }
+          const didRewrite = pathnameBeforeRewrite !== parsedUrl.pathname
 
+          if (didRewrite && parsedUrl.pathname) {
+            addRequestMeta(req, 'rewroteURL', parsedUrl.pathname)
+          }
+
+          const routeParamKeys = new Set<string>()
           for (const [key, value] of Object.entries(parsedUrl.query)) {
             const normalizedKey = normalizeNextQueryParam(key)
             if (!normalizedKey) continue
 
             // Remove the prefixed key from the query params because we want
             // to consume it for the dynamic route matcher.
             delete parsedUrl.query[key]
+            routeParamKeys.add(normalizedKey)
 
             if (typeof value === 'undefined') continue
 
@@ -1371,6 +1377,14 @@ export default abstract class Server<
               ...Object.keys(utils.defaultRouteRegex?.groups || {}),
             ])
           }
+          // Remove the route `params` keys from `parsedUrl.query` if they are
+          // not in the original query params.
+          // If it's used in both route `params` and query `searchParams`, it should be kept.
+          for (const key of routeParamKeys) {
+            if (!(key in originQueryParams)) {
+              delete parsedUrl.query[key]
+            }
+          }
           parsedUrl.pathname = matchedPath
           url.pathname = parsedUrl.pathname
           finished = await this.normalizeAndAttachMetadata(req, res, parsedUrl)

filename: test/e2e/app-dir/rewrite-with-search-params/app/[domain]/[...section]/page.tsx
patch: @@ -0,0 +1,25 @@
+export default async function Page({
+  params,
+  searchParams,
+}: {
+  params: Promise<{ domain: string; section: string[] }>
+  searchParams: Promise<{ [key: string]: string | string[] }>
+}) {
+  const resolvedParams = await params
+  const resolvedSearchParams = await searchParams
+
+  return (
+    <>
+      <div>
+        <h2>Params:</h2>
+        <pre id="params-value">{JSON.stringify(resolvedParams, null, 2)}</pre>
+      </div>
+      <div>
+        <h2>Search Params:</h2>
+        <pre id="search-params-value">
+          {JSON.stringify(resolvedSearchParams, null, 2)}
+        </pre>
+      </div>
+    </>
+  )
+}

filename: test/e2e/app-dir/rewrite-with-search-params/app/layout.tsx
patch: @@ -0,0 +1,11 @@
+export default function RootLayout({
+  children,
+}: Readonly<{
+  children: React.ReactNode
+}>) {
+  return (
+    <html>
+      <body>{children}</body>
+    </html>
+  )
+}

filename: test/e2e/app-dir/rewrite-with-search-params/next.config.js
patch: @@ -0,0 +1,21 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+  async rewrites() {
+    return [
+      {
+        source: '/:path((?!sitemap.xml$).*)',
+        has: [
+          {
+            type: 'host',
+            value: '(?<subdomain>[^.]+)\\.(?<domain>.*)',
+          },
+        ],
+        destination: '/:subdomain/:path*',
+      },
+    ]
+  },
+}
+
+module.exports = nextConfig

filename: test/e2e/app-dir/rewrite-with-search-params/rewrite-with-search-params.test.ts
patch: @@ -0,0 +1,35 @@
+import { nextTestSetup } from 'e2e-utils'
+
+describe('rewrite-with-search-params', () => {
+  const { next, isNextDeploy } = nextTestSetup({
+    files: __dirname,
+  })
+
+  it('should not contain params in search params after rewrite', async () => {
+    const $ = await next.render$(
+      '/galleries/123',
+      {
+        param: 'value',
+      },
+      {
+        headers: isNextDeploy
+          ? undefined
+          : {
+              host: 'vercel-test.vercel.app',
+            },
+      }
+    )
+
+    const searchParams = JSON.parse($('#search-params-value').text())
+    const params = JSON.parse($('#params-value').text())
+
+    expect(searchParams).toEqual({
+      param: 'value',
+    })
+
+    expect(params).toEqual({
+      domain: expect.stringMatching(/[\w-]+/),
+      section: ['galleries', '123'],
+    })
+  })
+})