first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
'use cache'
import { cacheTag } from 'next/cache'
import { Suspense } from 'react'
const TEST_DATA_SERVICE_URL = process.env.TEST_DATA_SERVICE_URL
const ARTIFICIAL_DELAY = 3000
async function Greeting() {
cacheTag('random-greeting')
if (!TEST_DATA_SERVICE_URL) {
// If environment variable is not set, resolve automatically after a delay.
// This is so you can run the test app locally without spinning up a
// data server.
await new Promise<void>((resolve) =>
setTimeout(() => resolve(), ARTIFICIAL_DELAY)
)
// Return a random greeting
return ['Hello', 'Hi', 'Hey', 'Howdy'][Math.floor(Math.random() * 4)]
}
const response = await fetch(TEST_DATA_SERVICE_URL + '?key=random-greeting')
const text = await response.text()
if (response.status !== 200) {
throw new Error(text)
}
return (
<>
<h1>Greeting</h1>
<div id="greeting">{text}</div>
</>
)
}
export default async function Page() {
return (
<Suspense fallback="Loading...">
<Greeting />
</Suspense>
)
}

View File

@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,57 @@
import { revalidatePath, updateTag } from 'next/cache'
import {
LinkAccordion,
FormAccordion,
ManualPrefetchLinkAccordion,
} from '../components/link-accordion'
import Link from 'next/link'
export default async function Page() {
return (
<>
<form>
<button
id="revalidate-by-path"
formAction={async function () {
'use server'
revalidatePath('/greeting')
}}
>
Revalidate by path
</button>
<button
id="revalidate-by-tag"
formAction={async function () {
'use server'
updateTag('random-greeting')
}}
>
Revalidate by tag
</button>
</form>
<ul>
<li>
<LinkAccordion href="/greeting">
Link to target page with prefetching enabled
</LinkAccordion>
</li>
<li>
<FormAccordion action="/greeting">
Form pointing to target page with prefetching enabled
</FormAccordion>
</li>
<li>
<ManualPrefetchLinkAccordion href="/greeting">
Manual link (router.prefetch) to target page with prefetching
enabled
</ManualPrefetchLinkAccordion>
</li>
<li>
<Link prefetch={false} href="/greeting">
Link to target with prefetching disabled
</Link>
</li>
</ul>
</>
)
}

View File

@@ -0,0 +1,15 @@
import { connection } from 'next/server'
import { Suspense } from 'react'
async function Content() {
await connection()
return <div id="page-a-content">Page A content</div>
}
export default async function PageA() {
return (
<Suspense fallback="Loading...">
<Content />
</Suspense>
)
}

View File

@@ -0,0 +1,15 @@
import { connection } from 'next/server'
import { Suspense } from 'react'
async function Content() {
await connection()
return <div id="page-b-content">Page B content</div>
}
export default async function PageB() {
return (
<Suspense fallback="Loading...">
<Content />
</Suspense>
)
}

View File

@@ -0,0 +1,61 @@
import { LinkAccordion } from '../../components/link-accordion'
export default function RefetchOnNewBaseTreeLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<div style={{ backgroundColor: 'lightgray', padding: '1rem' }}>
<p>
This demonstrates what happens when a link is prefetched using{' '}
<code>{'prefetch={true}'}</code> and the URL changes. Next.js should
re-prefetch the link in case the delta between the base tree and the
target tree has changed.
</p>
<p>
Everything in this gray section is part of a shared layout. The links
below are prefetched using <code>{'prefetch={true}'}</code>. If the
first loaded page is "/refetch-on-new-base-tree/a", the prefetch for
this link will be empty, because there's no delta between the base
tree and the target tree.
</p>
<p>
However, if you then navigate to page B, we should re-prefetch the
link to A, because the delta between the base tree and the target tree
is now different.
</p>
<p>Test steps:</p>
<ul>
<li>Load "/refetch-on-new-base-tree/a" in the browser.</li>
<li>
Click the checkboxes to reveal the links. (These exist so the e2e
test can control the timing of the prefetch.)
</li>
<li>
Observe that the prefetch for page A is empty, i.e. the string "Page
A content" should not appear anywhere in the response.
</li>
<li>Click the link to page B to navigate away.</li>
<li>
Check the network tab to confirm that a new prefetch for page A was
requested.
</li>
<li>Click the link to page A</li>
<li>
Observe that no new request was made when navigating to page A,
because it was fully prefetched.
</li>
</ul>
<LinkAccordion prefetch={true} href="/refetch-on-new-base-tree/a">
Page A
</LinkAccordion>
<LinkAccordion prefetch={true} href="/refetch-on-new-base-tree/b">
Page B
</LinkAccordion>
</div>
<div>{children}</div>
</>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Page(): never {
redirect('/refetch-on-new-base-tree/a')
}

View File

@@ -0,0 +1,138 @@
'use client'
import Link, { type LinkProps } from 'next/link'
import Form from 'next/form'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
export function LinkAccordion({
href,
children,
prefetch,
}: {
href: string
children: React.ReactNode
prefetch?: LinkProps['prefetch']
}) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{children}
</Link>
) : (
<>{children} (link is hidden)</>
)}
</>
)
}
export function FormAccordion({
action,
children,
prefetch,
}: {
action: string
children: React.ReactNode
prefetch?: null | false
}) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-form-accordion={action}
/>
{isVisible ? (
<Form action={action} prefetch={prefetch}>
<button>{children}</button>
</Form>
) : (
<>{children} (form is hidden)</>
)}
</>
)
}
export function ManualPrefetchLinkAccordion({
href,
children,
prefetch,
}: {
href: string
children: React.ReactNode
prefetch?: boolean
}) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-manual-prefetch-link-accordion={href}
/>
{isVisible ? (
<ManualPrefetchLink href={href} prefetch={prefetch}>
{children}
</ManualPrefetchLink>
) : (
<>{children} (form is hidden)</>
)}
</>
)
}
type Router = ReturnType<typeof useRouter>
type PrefetchOptions = Parameters<Router['prefetch']>[1]
function ManualPrefetchLink({
href,
children,
prefetch,
}: {
href: string
children: React.ReactNode
prefetch?: boolean
}) {
const router = useRouter()
useEffect(() => {
if (prefetch !== false) {
// For as long as the link is mounted, poll the prefetch cache whenever
// it's invalidated to ensure the data is fresh.
let didUnmount = false
const pollPrefetch = () => {
if (!didUnmount) {
router.prefetch(href, {
kind: 'auto' as PrefetchOptions['kind'],
onInvalidate: pollPrefetch,
})
}
}
pollPrefetch()
return () => {
didUnmount = true
}
}
}, [href, prefetch, router])
return (
<a
onClick={(event) => {
event.preventDefault()
router.push(href)
}}
href={href}
>
{children}
</a>
)
}

View File

@@ -0,0 +1,8 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
cacheComponents: true,
}
module.exports = nextConfig

View File

@@ -0,0 +1,433 @@
import type * as Playwright from 'playwright'
import { isNextDev, isNextDeploy, createNext } from 'e2e-utils'
import { createRouterAct } from 'router-act'
import { createTestDataServer } from 'test-data-service/writer'
import { createTestLog } from 'test-log'
import { findPort } from 'next-test-utils'
describe('segment cache (revalidation)', () => {
if (isNextDev || isNextDeploy) {
test('disabled in development / deployment', () => {})
return
}
let port = -1
let server
let dataVersions = new Map()
let TestLog = createTestLog()
let next
beforeAll(async () => {
port = await findPort()
server = createTestDataServer(async (key, res) => {
const currentVersion = dataVersions.get(key)
// Increment the version number each time to track how often the
// server renders.
const nextVersion = currentVersion === undefined ? 1 : currentVersion + 1
dataVersions.set(key, nextVersion)
// Append the version number to the response
const response = `${key} [${nextVersion}]`
TestLog.log('REQUEST: ' + key)
res.resolve(response)
})
server.listen(port)
next = await createNext({
files: __dirname,
env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` },
})
})
beforeEach(async () => {
dataVersions = new Map()
TestLog = createTestLog()
})
afterAll(async () => {
await next?.destroy()
server?.close()
})
it('evict client cache when Server Action calls revalidatePath', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('refetch visible Form components after cache is revalidated', async () => {
// This is the same as the previous test, but for forms. Since the
// prefetching implementation is shared between Link and Form, we don't
// bother to test every feature using both Link and Form; this test should
// be sufficient.
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const formVisibilityToggle = await browser.elementByCss(
'input[data-form-accordion="/greeting"]'
)
// Reveal the form that points to the target page to trigger a prefetch
await act(
async () => {
await formVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const button = await browser.elementByCss(
'form[action="/greeting"] button'
)
await button.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('call router.prefetch(..., {onInvalidate}) after cache is revalidated', async () => {
// This is the similar to the previous tests, but uses a custom Link
// implementation that calls router.prefetch manually. It demonstrates it's
// possible to simulate the revalidating behavior of Link using the manual
// prefetch API.
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-manual-prefetch-link-accordion="/greeting"]'
)
// Reveal the link that points to the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidatePath. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('evict client cache when Server Action calls revalidateTag', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch.
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers — freezes the 300ms cooldown setTimeout
await page.clock.install()
// Perform an action that calls revalidateTag. This should cause the
// corresponding entry to be evicted from the client cache, and a new
// prefetch to be requested.
await act(async () => {
const revalidateByTag = await browser.elementById('revalidate-by-tag')
await revalidateByTag.click()
})
// Advance past cooldown inside act() to intercept the re-prefetch
await act(
async () => {
await page.clock.fastForward(300)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immedately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
it('re-fetch visible links after a navigation, if needed', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/refetch-on-new-base-tree/a', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
const linkALinkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/refetch-on-new-base-tree/a"]'
)
const linkBLinkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/refetch-on-new-base-tree/b"]'
)
// Reveal the links to trigger prefetches
await act(async () => {
await linkALinkVisibilityToggle.click()
await linkBLinkVisibilityToggle.click()
}, [
// Page B's content should have been prefetched
{
includes: 'Page B content',
},
// Page A's content should not be prefetched because we're already on that
// page. When prefetching with `prefetch='unstable_forceStale'`, we only prefetch the
// delta between the current route and the target route.
{
includes: 'Page A content',
block: 'reject',
},
])
// Navigate to page B
await act(
async () => {
const link = await browser.elementByCss(
'a[href="/refetch-on-new-base-tree/b"]'
)
await link.click()
const content = await browser.elementById('page-b-content')
expect(await content.innerHTML()).toBe('Page B content')
},
// The link for page A is re-prefetched again, even though it's an
// existing link, because the delta between the current route and the
// target route has changed.
//
// This time, the response does include the content for page A.
//
// TODO: The request is actually skipped entirely because <Link
// prefetch={true} /> now reads from the bfcache before issuing a prefetch
// request, which wasn't true before the test was written. I'm leaving
// the test here for now, though, since we may want to re-write it in
// terms of runtime prefetching at some point. There's other coverage of
// this behavior though so it might be fine to just remove the whole test.
// {
// includes: 'Page A content',
// }
'no-requests'
)
// Navigate to page A
await act(
async () => {
const link = await browser.elementByCss(
'a[href="/refetch-on-new-base-tree/a"]'
)
await link.click()
const content = await browser.elementById('page-a-content')
expect(await content.innerHTML()).toBe('Page A content')
},
// There should be no new requests because everything is fully prefetched.
'no-requests'
)
})
it('delay re-prefetch after revalidation to allow CDN propagation', async () => {
let act: ReturnType<typeof createRouterAct>
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
act = createRouterAct(p)
},
})
const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)
// Reveal the link the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)
// Install fake timers so the 300ms cooldown setTimeout in the
// browser is frozen until we explicitly advance the clock.
await page.clock.install()
// Perform an action that calls revalidatePath. This triggers a 300ms
// cooldown before any new prefetch requests can be made.
await act(async () => {
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()
})
// The cooldown timer is frozen, so no prefetch should have occurred.
TestLog.assert([])
// Advance partway through the cooldown — still no prefetch.
await page.clock.fastForward(150)
TestLog.assert([])
// Advance past the cooldown (300ms total). This fires the cooldown
// callback, which triggers a re-prefetch. Use act() to intercept
// the prefetch request and ensure the response is fully delivered
// to the browser.
await act(
async () => {
await page.clock.fastForward(150)
},
{
includes: 'random-greeting [1]',
}
)
TestLog.assert(['REQUEST: random-greeting'])
// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immediately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
})