Files
next.js/test/e2e/legacy-link-behavior/validations.console.test.ts
Janka UrygaandGitHub 46681d90f0 [PPF] Sync IO is only allowed in the dynamic stage (#95384)
This PR makes Sync IO behavior more restrictive when
`partialPrefetching` is on (either globally or for a page).

- Previously, we allowed `await cookies(); Date.now()` in in segments
that won't ever be runtime prefetched. This was achieved by separating
segments into "early" and "late" stages and only erroring in the "early"
ones.
- After this PR, it will always be an error to do sync IO anywhere other
than after `io()`/`connection()`/uncached IO (i.e. outside the Dynamic
stage).

We're doing this mainly because with `partialPrefetching`, `cookies()`
resolves in App Shells, and sync IO causes a severe deopt in any
prerender. `allow-runtime` also opts a route into `partialPrefetching`,
which means that even the segments above the runtime prefetch boundary
will need an App Shell and can't tolerate Sync IO.

This PR removes all early/late stage separation, because we no longer
need to vary the Sync IO behavior on the segment. Instead, we now pick
whether a stageController should use
- `SyncIOMode.AllowedInRuntimeOrDynamic` (legacy)
- `SyncIOMode.AllowedInDynamic` (`partialPrefetching`)
- `SyncIOMode.Untracked` if it needs to opt out

I've removed the existing tests that asserted that sync IO is allowed in
segments above the `allow-runtime` boundary. Instead, we check that sync
io either errors (if partialPrefetching is on) or is allowed (if
partialPrefetching is off)

Closes NAR-855
2026-07-10 15:36:41 +00:00

580 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isNextDev, nextTestSetup } from 'e2e-utils'
import { waitForNoRedbox } from 'next-test-utils'
import { getDeterministicOutput } from '../app-dir/cache-components-errors/utils'
const partialPrefetching = !!process.env.__NEXT_PARTIAL_PREFETCHING
describe('Validations for <Link legacyBehavior>', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})
if (skipped) return
let previousOutputIndex = 0
beforeEach(() => {
previousOutputIndex = next.cliOutput.length
})
function newConsoleOutput() {
return next.cliOutput.slice(previousOutputIndex)
}
describe('When rendering from a Server Component', () => {
describe('Rendering <Link> directly', () => {
it('warns if the child is a synchronous server component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/synchronous'
)
if (isNextDev) {
await expect(browser).toDisplayCollapsedRedbox(`
[
{
"code": "E394",
"description": "Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": "Prerender",
"label": "Console Error",
"source": "app/validations/rsc-that-renders-link/synchronous/page.tsx (7:7) @ Page
> 7 | <Link href="/about" legacyBehavior>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/synchronous/page.tsx (7:7)",
],
},
{
"code": "E394",
"description": "\`legacyBehavior\` is deprecated and will be removed in a future release. A codemod is available to upgrade your components:
npx @next/codemod@latest new-link .
Learn more: https://nextjs.org/docs/app/building-your-application/upgrading/codemods#remove-a-tags-from-link-components",
"environmentLabel": null,
"label": "Console Error",
"source": "app/validations/rsc-that-renders-link/synchronous/page.tsx (7:7) @ Page
> 7 | <Link href="/about" legacyBehavior>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/synchronous/page.tsx (7:7)",
],
},
]
`)
} else {
if (partialPrefetching) {
// In Partial Prefetching, we do a second render to produce the embedded
// runtime prefetch stream, which results in a second log.
expect(
getDeterministicOutput(newConsoleOutput(), { isMinified: true })
).toMatchInlineSnapshot(`
"Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag.
Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag."
`)
} else {
expect(
getDeterministicOutput(newConsoleOutput(), { isMinified: true })
).toMatchInlineSnapshot(
`"Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag."`
)
}
}
})
it('warns and throws an error if the child is an asynchronous server component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/asynchronous'
)
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
[
{
"code": "E394",
"description": "Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": "Prerender",
"label": "Console Error",
"source": "app/validations/rsc-that-renders-link/asynchronous/page.tsx (7:7) @ Page
> 7 | <Link href="/about" legacyBehavior>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/asynchronous/page.tsx (7:7)",
],
},
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-link/asynchronous/page.tsx (7:7) @ Page
> 7 | <Link href="/about" legacyBehavior>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/asynchronous/page.tsx (7:7)",
],
},
]
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(`
"Using a Server Component as a direct child of \`<Link legacyBehavior>\` is not supported. If you need legacyBehavior, wrap your Server Component in a Client Component that renders the Link's \`<a>\` tag.
⨯ Error: \`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag."
`)
}
})
describe('does not warn or throw if you pass a client component', () => {
it('with no prefetch config', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/client/default'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-link/client/default/page.tsx (7:7) @ Page
> 7 | <Link href="/about" legacyBehavior passHref>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/client/default/page.tsx (7:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('with runtime prefetch', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/client/runtime'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-link/client/runtime/page.tsx (9:7) @ Page
> 9 | <Link href="/about" legacyBehavior passHref>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/client/runtime/page.tsx (9:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('in dynamic code', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/client/dynamic'
)
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
})
it('does not warn or throw if you pass a server component into a client component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/client-with-rsc-child'
)
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('warns if the child is a lazy component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-link/lazy'
)
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
[
{
"code": "E394",
"description": "Using a Lazy Component as a direct child of \`<Link legacyBehavior>\` from a Server Component is not supported. If you need legacyBehavior, wrap your Lazy Component in a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": "Prerender",
"label": "Console Error",
"source": "app/validations/rsc-that-renders-link/lazy/page.tsx (9:7) @ Page
> 9 | <Link href="/about" legacyBehavior passHref>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/lazy/page.tsx (9:7)",
],
},
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-link/lazy/page.tsx (9:7) @ Page
> 9 | <Link href="/about" legacyBehavior passHref>
| ^",
"stack": [
"Page app/validations/rsc-that-renders-link/lazy/page.tsx (9:7)",
],
},
]
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(`
"Using a Lazy Component as a direct child of \`<Link legacyBehavior>\` from a Server Component is not supported. If you need legacyBehavior, wrap your Lazy Component in a Client Component that renders the Link's \`<a>\` tag.
⨯ Error: \`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag."
`)
}
})
})
describe('Rendering a Client Component that renders <Link>', () => {
it('does not warn if the child is a synchronous server component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/synchronous'
)
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('throws an error if the child is an asynchronous server component', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/asynchronous'
)
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-client/client-link.tsx (7:10) @ ClientLink
> 7 | return <Link legacyBehavior passHref {...props} />
| ^",
"stack": [
"ClientLink app/validations/rsc-that-renders-client/client-link.tsx (7:10)",
"Page app/validations/rsc-that-renders-client/asynchronous/page.tsx (7:7)",
],
}
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(
`"⨯ Error: \`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag."`
)
}
})
describe('does not warn or throw if you pass a client component', () => {
it('with no prefetch config', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client/default'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-client/client-link.tsx (7:10) @ ClientLink
> 7 | return <Link legacyBehavior passHref {...props} />
| ^",
"stack": [
"ClientLink app/validations/rsc-that-renders-client/client-link.tsx (7:10)",
"Page app/validations/rsc-that-renders-client/client/default/page.tsx (7:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('with runtime prefetch', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client/runtime'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-client/client-link.tsx (7:10) @ ClientLink
> 7 | return <Link legacyBehavior passHref {...props} />
| ^",
"stack": [
"ClientLink app/validations/rsc-that-renders-client/client-link.tsx (7:10)",
"Page app/validations/rsc-that-renders-client/client/runtime/page.tsx (9:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('in dynamic code', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client/dynamic'
)
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
})
describe('does not warn or throw if you pass a server component into a client component', () => {
it('with no prefetch config', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client-with-rsc-child/default'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-client/client-link.tsx (7:10) @ ClientLink
> 7 | return <Link legacyBehavior passHref {...props} />
| ^",
"stack": [
"ClientLink app/validations/rsc-that-renders-client/client-link.tsx (7:10)",
"Page app/validations/rsc-that-renders-client/client-with-rsc-child/default/page.tsx (7:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('with runtime prefetch', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client-with-rsc-child/runtime'
)
if (isNextDev) {
// FIXME(NAR-876): false positive due to debug info blocking the child
// await waitForNoRedbox(browser)
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/rsc-that-renders-client/client-link.tsx (7:10) @ ClientLink
> 7 | return <Link legacyBehavior passHref {...props} />
| ^",
"stack": [
"ClientLink app/validations/rsc-that-renders-client/client-link.tsx (7:10)",
"Page app/validations/rsc-that-renders-client/client-with-rsc-child/runtime/page.tsx (9:7)",
],
}
`)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('in dynamic code', async () => {
const browser = await next.browser(
'/validations/rsc-that-renders-client/client-with-rsc-child/dynamic'
)
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
})
})
})
describe('When rendering from a Client Component', () => {
it('errors if there are no children', async () => {
const browser = await next.browser('/validations/client/missing-child')
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
{
"code": "E320",
"description": "No children were passed to <Link> with \`href\` of \`/about\` but one child is required https://nextjs.org/docs/messages/link-no-children",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/client/missing-child/page.tsx (6:10) @ Page
> 6 | return <Link href="/about" legacyBehavior></Link>
| ^",
"stack": [
"Page app/validations/client/missing-child/page.tsx (6:10)",
],
}
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(
`"⨯ Error: React.Children.only expected to receive a single React element child."`
)
}
})
it('errors if there are multiple children', async () => {
const browser = await next.browser(
'/validations/client/multiple-children'
)
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
{
"code": "E266",
"description": "Multiple children were passed to <Link> with \`href\` of \`/about\` but only one child is supported https://nextjs.org/docs/messages/link-multiple-children
Open your browser's console to view the Component stack trace.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/client/multiple-children/page.tsx (7:5) @ Page
> 7 | <Link href="/about" legacyBehavior>
| ^",
"stack": [
"Page app/validations/client/multiple-children/page.tsx (7:5)",
],
}
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(
`"⨯ Error: React.Children.only expected to receive a single React element child."`
)
}
})
it('does not warn or throw if you pass a child component', async () => {
const browser = await next.browser('/validations/client/child-component')
if (isNextDev) {
await waitForNoRedbox(browser)
} else {
expect(newConsoleOutput()).toEqual('')
}
})
it('warns and throws an error if the child is lazy JSX', async () => {
const browser = await next.browser('/validations/client/lazy-jsx')
if (isNextDev) {
await expect(browser).toDisplayRedbox(`
{
"code": "E863",
"description": "\`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag.",
"environmentLabel": null,
"label": "Runtime Error",
"source": "app/validations/client/lazy-jsx/page.tsx (9:7) @ Page
> 9 | <Link href="/about" legacyBehavior passHref>
| ^",
"stack": [
"Page app/validations/client/lazy-jsx/page.tsx (9:7)",
],
}
`)
} else {
const output = getContentBetween({
input: newConsoleOutput(),
endContent: ' at',
})
expect(output).toMatchInlineSnapshot(
`"⨯ Error: \`<Link legacyBehavior>\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \`<a>\` tag."`
)
}
})
})
})
function getContentBetween({
input,
startContent,
endContent,
}: {
input: string
startContent?: string
endContent?: string
}): string {
const lines = input.split('\n')
const startIdx = startContent
? lines.findIndex((line) => line.includes(startContent))
: -1
const endIdx = endContent
? lines.findIndex((line) => line.includes(endContent))
: -1
if (startContent && startIdx < 0) return ''
if (endContent && endIdx >= 0 && endIdx <= startIdx) return ''
const sliceStart = startIdx >= 0 ? startIdx + 1 : 0
const sliceEnd = endIdx > sliceStart ? endIdx : undefined
return lines.slice(sliceStart, sliceEnd).join('\n')
}