Skip to content

Commit

Permalink
Opt-in to dynamic rendering when reading searchParams (#46205)
Browse files Browse the repository at this point in the history
Ensures that using `searchParams` opts into dynamic rendering.

Fixes #43077 
fix NEXT-601 ([link](https://linear.app/vercel/issue/NEXT-601))

<!--
Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:
-->

## 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)
  • Loading branch information
timneutkens committed Mar 16, 2023
1 parent dbdf47c commit cc0da4b
Show file tree
Hide file tree
Showing 15 changed files with 251 additions and 6 deletions.
4 changes: 4 additions & 0 deletions packages/next/src/build/webpack/loaders/next-app-loader.ts
Expand Up @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions 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}`)
}
},
}
)
}
@@ -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<any>
propsForComponent: any
}) {
const searchParams = createSearchParamsBailoutProxy()
return <Component searchParams={searchParams} {...propsForComponent} />
}
36 changes: 31 additions & 5 deletions packages/next/src/server/app-render/index.tsx
Expand Up @@ -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.
Expand Down Expand Up @@ -1278,18 +1287,28 @@ 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.
// Params are always the current params that apply to the layout
// 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)
)
Expand All @@ -1300,7 +1319,14 @@ export async function renderToHTMLOrFlight(
return (
<>
{/* <Component /> needs to be the first element because we use `findDOMONode` in layout router to locate it. */}
<Component {...props} />
{isPage && isClientComponent && isStaticGeneration ? (
<StaticGenerationSearchParamsBailoutProvider
propsForComponent={props}
Component={Component}
/>
) : (
<Component {...props} />
)}
{preloadedFontFiles?.length === 0 ? (
<link
data-next-font={
Expand Down
2 changes: 1 addition & 1 deletion plopfile.js
Expand Up @@ -9,7 +9,7 @@ module.exports = function (plop) {
type: 'confirm',
name: 'appDir',
message: 'Is this test for the app directory?',
default: false,
default: true,
},
{
type: 'input',
Expand Down
@@ -0,0 +1,10 @@
'use client'
import { nanoid } from 'nanoid'
export default function Page({ searchParams }) {
return (
<>
<h1>Parameter: {searchParams.search}</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
@@ -0,0 +1,11 @@
'use client'
import { nanoid } from 'nanoid'

export default function Page() {
return (
<>
<h1>No searchParams used</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
@@ -0,0 +1,8 @@
'use client'
export default function ClientComponent({ searchParams }) {
return (
<>
<h1>Parameter: {searchParams.search}</h1>
</>
)
}
@@ -0,0 +1,11 @@
import { nanoid } from 'nanoid'
import ClientComponent from './component'

export default function Page({ searchParams }) {
return (
<>
<ClientComponent searchParams={searchParams} />
<p id="nanoid">{nanoid()}</p>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/searchparams-static-bailout/app/layout.tsx
@@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,10 @@
import { nanoid } from 'nanoid'

export default function Page({ searchParams }) {
return (
<>
<h1>Parameter: {searchParams.search}</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
@@ -0,0 +1,10 @@
import { nanoid } from 'nanoid'

export default function Page() {
return (
<>
<h1>No searchParams used</h1>
<p id="nanoid">{nanoid()}</p>
</>
)
}
8 changes: 8 additions & 0 deletions 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
@@ -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)
}
})
})
}
)
24 changes: 24 additions & 0 deletions 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"]
}

0 comments on commit cc0da4b

Please sign in to comment.