From cc0da4bd29a8d573664ba916659098de02311eb2 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 16 Mar 2023 21:46:35 +0100 Subject: [PATCH] Opt-in to dynamic rendering when reading searchParams (#46205) Ensures that using `searchParams` opts into dynamic rendering. Fixes #43077 fix NEXT-601 ([link](https://linear.app/vercel/issue/NEXT-601)) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../build/webpack/loaders/next-app-loader.ts | 4 + .../components/searchparams-bailout-proxy.ts | 15 ++++ ...neration-searchparams-bailout-provider.tsx | 14 +++ packages/next/src/server/app-render/index.tsx | 36 ++++++-- plopfile.js | 2 +- .../app/client-component-page/page.tsx | 10 +++ .../page.tsx | 11 +++ .../app/client-component/component.tsx | 8 ++ .../app/client-component/page.tsx | 11 +++ .../app/layout.tsx | 7 ++ .../app/server-component-page/page.tsx | 10 +++ .../page.tsx | 10 +++ .../next.config.js | 8 ++ .../searchparams-static-bailout.test.ts | 87 +++++++++++++++++++ .../searchparams-static-bailout/tsconfig.json | 24 +++++ 15 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 packages/next/src/client/components/searchparams-bailout-proxy.ts create mode 100644 packages/next/src/client/components/static-generation-searchparams-bailout-provider.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/client-component-page/page.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/client-component-without-searchparams/page.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/client-component/page.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/layout.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/app/server-component-without-searchparams/page.tsx create mode 100644 test/e2e/app-dir/searchparams-static-bailout/next.config.js create mode 100644 test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts create mode 100644 test/e2e/app-dir/searchparams-static-bailout/tsconfig.json diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index 7ebc40dd858..696c4865365 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -423,6 +423,10 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { export { requestAsyncStorage } from 'next/dist/client/components/request-async-storage' + export { staticGenerationBailout } from 'next/dist/client/components/static-generation-bailout' + export { default as StaticGenerationSearchParamsBailoutProvider } from 'next/dist/client/components/static-generation-searchparams-bailout-provider' + export { createSearchParamsBailoutProxy } from 'next/dist/client/components/searchparams-bailout-proxy' + export * as serverHooks from 'next/dist/client/components/hooks-server-context' export { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/server.edge' diff --git a/packages/next/src/client/components/searchparams-bailout-proxy.ts b/packages/next/src/client/components/searchparams-bailout-proxy.ts new file mode 100644 index 00000000000..fbae8de1f5d --- /dev/null +++ b/packages/next/src/client/components/searchparams-bailout-proxy.ts @@ -0,0 +1,15 @@ +import { staticGenerationBailout } from './static-generation-bailout' + +export function createSearchParamsBailoutProxy() { + return new Proxy( + {}, + { + get(_target, prop) { + // React adds some properties on the object when serializing for client components + if (typeof prop === 'string') { + staticGenerationBailout(`searchParams.${prop}`) + } + }, + } + ) +} diff --git a/packages/next/src/client/components/static-generation-searchparams-bailout-provider.tsx b/packages/next/src/client/components/static-generation-searchparams-bailout-provider.tsx new file mode 100644 index 00000000000..624693a1984 --- /dev/null +++ b/packages/next/src/client/components/static-generation-searchparams-bailout-provider.tsx @@ -0,0 +1,14 @@ +'use client' +import React from 'react' +import { createSearchParamsBailoutProxy } from './searchparams-bailout-proxy' + +export default function StaticGenerationSearchParamsBailoutProvider({ + Component, + propsForComponent, +}: { + Component: React.ComponentType + propsForComponent: any +}) { + const searchParams = createSearchParamsBailoutProxy() + return +} diff --git a/packages/next/src/server/app-render/index.tsx b/packages/next/src/server/app-render/index.tsx index db688690cda..9811e8536f3 100644 --- a/packages/next/src/server/app-render/index.tsx +++ b/packages/next/src/server/app-render/index.tsx @@ -734,12 +734,21 @@ export async function renderToHTMLOrFlight( ? crypto.randomUUID() : require('next/dist/compiled/nanoid').nanoid() - const searchParamsProps = { searchParams: query } - const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../../client/components/layout-router').default const RenderFromTemplateContext = ComponentMod.RenderFromTemplateContext as typeof import('../../client/components/render-from-template-context').default + const createSearchParamsBailoutProxy = + ComponentMod.createSearchParamsBailoutProxy as typeof import('../../client/components/searchparams-bailout-proxy').createSearchParamsBailoutProxy + const StaticGenerationSearchParamsBailoutProvider = + ComponentMod.StaticGenerationSearchParamsBailoutProvider as typeof import('../../client/components/static-generation-searchparams-bailout-provider').default + + const isStaticGeneration = staticGenerationStore.isStaticGeneration + // During static generation we need to call the static generation bailout when reading searchParams + const providedSearchParams = isStaticGeneration + ? createSearchParamsBailoutProxy() + : query + const searchParamsProps = { searchParams: providedSearchParams } /** * Server Context is specifically only available in Server Components. @@ -1278,6 +1287,8 @@ export async function renderToHTMLOrFlight( } } + const isClientComponent = isClientReference(layoutOrPageMod) + const props = { ...parallelRouteComponents, // TODO-APP: params and query have to be blocked parallel route names. Might have to add a reserved name list. @@ -1285,11 +1296,19 @@ export async function renderToHTMLOrFlight( // If you have a `/dashboard/[team]/layout.js` it will provide `team` as a param but not anything further down. params: currentParams, // Query is only provided to page - ...(isPage ? searchParamsProps : {}), + ...(() => { + if (isClientComponent && isStaticGeneration) { + return {} + } + + if (isPage) { + return searchParamsProps + } + })(), } // Eagerly execute layout/page component to trigger fetches early. - if (!isClientReference(layoutOrPageMod)) { + if (!isClientComponent) { Component = await Promise.resolve().then(() => preloadComponent(Component, props) ) @@ -1300,7 +1319,14 @@ export async function renderToHTMLOrFlight( return ( <> {/* needs to be the first element because we use `findDOMONode` in layout router to locate it. */} - + {isPage && isClientComponent && isStaticGeneration ? ( + + ) : ( + + )} {preloadedFontFiles?.length === 0 ? ( +

Parameter: {searchParams.search}

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/client-component-without-searchparams/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/client-component-without-searchparams/page.tsx new file mode 100644 index 00000000000..bcba154710d --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/client-component-without-searchparams/page.tsx @@ -0,0 +1,11 @@ +'use client' +import { nanoid } from 'nanoid' + +export default function Page() { + return ( + <> +

No searchParams used

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx new file mode 100644 index 00000000000..7afd6d9a327 --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/component.tsx @@ -0,0 +1,8 @@ +'use client' +export default function ClientComponent({ searchParams }) { + return ( + <> +

Parameter: {searchParams.search}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/client-component/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/page.tsx new file mode 100644 index 00000000000..565990f9c5e --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/client-component/page.tsx @@ -0,0 +1,11 @@ +import { nanoid } from 'nanoid' +import ClientComponent from './component' + +export default function Page({ searchParams }) { + return ( + <> + +

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/layout.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/layout.tsx new file mode 100644 index 00000000000..e7077399c03 --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx new file mode 100644 index 00000000000..54aacb3456c --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-page/page.tsx @@ -0,0 +1,10 @@ +import { nanoid } from 'nanoid' + +export default function Page({ searchParams }) { + return ( + <> +

Parameter: {searchParams.search}

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/app/server-component-without-searchparams/page.tsx b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-without-searchparams/page.tsx new file mode 100644 index 00000000000..fb84f9bf796 --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/app/server-component-without-searchparams/page.tsx @@ -0,0 +1,10 @@ +import { nanoid } from 'nanoid' + +export default function Page() { + return ( + <> +

No searchParams used

+

{nanoid()}

+ + ) +} diff --git a/test/e2e/app-dir/searchparams-static-bailout/next.config.js b/test/e2e/app-dir/searchparams-static-bailout/next.config.js new file mode 100644 index 00000000000..bf49894afd4 --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { appDir: true }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts b/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts new file mode 100644 index 00000000000..7b8944672f2 --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts @@ -0,0 +1,87 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'searchparams-static-bailout', + { + files: __dirname, + dependencies: { + nanoid: '4.0.1', + }, + }, + ({ next, isNextStart }) => { + describe('server component', () => { + it('should bailout when using searchParams', async () => { + const url = '/server-component-page?search=hello' + const $ = await next.render$(url) + expect($('h1').text()).toBe('Parameter: hello') + + // Check if the page is not statically generated. + if (isNextStart) { + const id = $('#nanoid').text() + const $2 = await next.render$(url) + const id2 = $2('#nanoid').text() + expect(id).not.toBe(id2) + } + }) + + it('should not bailout when not using searchParams', async () => { + const url = '/server-component-without-searchparams?search=hello' + + const $ = await next.render$(url) + expect($('h1').text()).toBe('No searchParams used') + + // Check if the page is not statically generated. + if (isNextStart) { + const id = $('#nanoid').text() + const $2 = await next.render$(url) + const id2 = $2('#nanoid').text() + expect(id).toBe(id2) + } + }) + }) + + describe('client component', () => { + it('should bailout when using searchParams', async () => { + const url = '/client-component-page?search=hello' + const $ = await next.render$(url) + expect($('h1').text()).toBe('Parameter: hello') + + // Check if the page is not statically generated. + if (isNextStart) { + const id = $('#nanoid').text() + const $2 = await next.render$(url) + const id2 = $2('#nanoid').text() + expect(id).not.toBe(id2) + } + }) + + it('should bailout when using searchParams is passed to client component', async () => { + const url = '/client-component?search=hello' + const $ = await next.render$(url) + expect($('h1').text()).toBe('Parameter: hello') + + // Check if the page is not statically generated. + if (isNextStart) { + const id = $('#nanoid').text() + const $2 = await next.render$(url) + const id2 = $2('#nanoid').text() + expect(id).not.toBe(id2) + } + }) + + it('should not bailout when not using searchParams', async () => { + const url = '/client-component-without-searchparams?search=hello' + const $ = await next.render$(url) + expect($('h1').text()).toBe('No searchParams used') + + // Check if the page is not statically generated. + if (isNextStart) { + const id = $('#nanoid').text() + const $2 = await next.render$(url) + const id2 = $2('#nanoid').text() + expect(id).toBe(id2) + } + }) + }) + } +) diff --git a/test/e2e/app-dir/searchparams-static-bailout/tsconfig.json b/test/e2e/app-dir/searchparams-static-bailout/tsconfig.json new file mode 100644 index 00000000000..d2bc2ac5e3c --- /dev/null +++ b/test/e2e/app-dir/searchparams-static-bailout/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}