diff --git a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx index 08a04ec62454..e94e7d965e2a 100644 --- a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx +++ b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx @@ -5,6 +5,7 @@ related: links: - app/api-reference/components/link - app/api-reference/file-conventions/loading + - app/guides/optimizing-prefetching - app/guides/prefetching --- @@ -95,7 +96,7 @@ By skipping or partially prefetching dynamic routes, Next.js avoids unnecessary height="748" /> -> **Good to know:** See the [Prefetching guide](/docs/app/guides/prefetching) for the full behavior, including how to control prefetching per link and how it changes when you adopt [Partial Prefetching](/docs/app/guides/adopting-partial-prefetching). +> **Good to know:** See the [Prefetching guide](/docs/app/guides/prefetching) for the full behavior, including how to control prefetching per link and how it changes when you adopt [Partial Prefetching](/docs/app/guides/adopting-partial-prefetching). To include URL-dependent content in a link's prefetch, see [Optimizing prefetching](/docs/app/guides/optimizing-prefetching). To improve the navigation experience to dynamic routes, you can use [streaming](#streaming). diff --git a/evals/evals/agent-035-connection-dynamic/EVAL.ts b/evals/evals/agent-035-connection-dynamic/EVAL.ts index f1e42e6c9903..d795003af2eb 100644 --- a/evals/evals/agent-035-connection-dynamic/EVAL.ts +++ b/evals/evals/agent-035-connection-dynamic/EVAL.ts @@ -1,76 +1,50 @@ -/** - * connection() for Dynamic Rendering - * - * Tests whether the agent uses connection() from next/server to force dynamic - * rendering instead of the deprecated unstable_noStore(). - * - * Tricky because agents use unstable_noStore(), force-dynamic segment config, - * or unrelated Dynamic APIs instead of the stable connection() function. - */ +/** Cache Components: synchronous request-time I/O in shared UI. */ import { expect, test } from 'vitest' -import { readFileSync, existsSync, readdirSync } from 'fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' import { join } from 'path' - -function readAppFiles(): string { - const appDir = join(process.cwd(), 'app') - if (!existsSync(appDir)) return '' - const entries = readdirSync(appDir, { recursive: true }) as string[] - const files = entries.filter((f) => f.endsWith('.tsx') || f.endsWith('.ts')) - return files.map((f) => readFileSync(join(appDir, f), 'utf-8')).join('\n') +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set(['.git', '.next', 'node_modules', 'dist', 'build']) + +function readSource(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir).flatMap((entry) => { + if (IGNORE_DIRS.has(entry)) return [] + const path = join(dir, entry) + if (statSync(path).isDirectory()) return readSource(path) + if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return [] + return readFileSync(path, 'utf8') + }) } -test('Component imports connection from next/server', () => { - const content = readAppFiles() +const source = readSource(process.cwd()).join('\n') +const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf8') - // Should import connection from next/server - expect(content).toMatch(/import.*connection.*from\s+['"]next\/server['"]/) +test('enables Cache Components without opt-outs', () => { + expect(config).toMatch(/cacheComponents\s*:\s*true/) + expect(source).not.toMatch(/export\s+(?:const|var|let)\s+instant\s*=\s*false/) }) -test('Component uses await connection()', () => { - const content = readAppFiles() - - // connection() returns a Promise and must be awaited - expect(content).toMatch(/await\s+connection\s*\(\s*\)/) +test('uses an explicit synchronous I/O boundary', () => { + expect(source).toMatch(/\b(?:io|connection)\s*\(/) + expect(source).toMatch(/]/) }) -test('Component is async', () => { - const content = readAppFiles() - - // Component must be async to use await - expect(content).toMatch(/async\s+function|export\s+default\s+async/) +test('keeps the operations frame useful while metadata loads', async () => { + await expect(environment).toSatisfyCriterion( + `The stable Operations navigation and page frame must remain outside the Suspense boundary used for request metadata. Accept equivalent component and file organization.` + ) }) -test('Component uses Date for timestamp', () => { - const content = readAppFiles() - - // Should use Date for generating timestamp - expect(content).toMatch(/new\s+Date\s*\(|Date\.now\s*\(/) +test('keeps request metadata fresh', async () => { + await expect(environment).toSatisfyCriterion( + `The request identifier, current time, and sampling value must remain request-specific. Render them in a focused async child that awaits io() or connection() before calling randomUUID, new Date or Date.now, and Math.random. Prefer io() unless the implementation specifically needs to wait for a real user request. Accept equivalent component and file organization.` + ) }) -test('Does NOT use unstable_noStore (deprecated)', () => { - const content = readAppFiles() - - // Should NOT use deprecated unstable_noStore - expect(content).not.toMatch(/unstable_noStore/) -}) - -test('Does NOT use force-dynamic segment config as primary approach', () => { - const content = readAppFiles() - - // Should use connection() as the primary approach instead of segment config - const hasForceDynamic = - content.includes("dynamic = 'force-dynamic'") || - content.includes('dynamic = "force-dynamic"') - const hasConnection = /connection\s*\(\s*\)/.test(content) - - // Enforce that connection() is the primary dynamic approach - // If force-dynamic is used, it cannot be the only dynamic mechanism - // When connection() is available/used, force-dynamic should not be redundantly added - if (hasForceDynamic && !hasConnection) { - // force-dynamic is being used as the sole dynamic mechanism (wrong) - expect.fail( - 'force-dynamic segment config should not be the primary approach - use connection() instead' - ) - } +test('does not reuse request metadata', async () => { + await expect(environment).toSatisfyCriterion( + `The request identifier, current time, and sampling value must not be cached, evaluated at module scope, or replaced with static values. Reject any solution that can reuse those values across requests. Accept equivalent component and file organization.` + ) }) diff --git a/evals/evals/agent-035-connection-dynamic/PROMPT.md b/evals/evals/agent-035-connection-dynamic/PROMPT.md index 70b64e401bda..bfb437bc2894 100644 --- a/evals/evals/agent-035-connection-dynamic/PROMPT.md +++ b/evals/evals/agent-035-connection-dynamic/PROMPT.md @@ -1 +1 @@ -Create a server component that displays the current server timestamp on each request. The timestamp should be different on every page load (not prerendered at build time). Use Next.js's recommended approach for opting out of static prerendering when you need dynamic data without using cookies/headers. +Enable Cache Components for this operations console. The navigation must stay immediately useful, while the request identifier, current time, and sampling value must still be generated per request. Finish with a production build and do not leave routes opted out. diff --git a/evals/evals/agent-035-connection-dynamic/app/layout.tsx b/evals/evals/agent-035-connection-dynamic/app/layout.tsx index 23429283cae0..03a7ce6ef914 100644 --- a/evals/evals/agent-035-connection-dynamic/app/layout.tsx +++ b/evals/evals/agent-035-connection-dynamic/app/layout.tsx @@ -1,18 +1,14 @@ -import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import { RequestMetadata } from '@/components/request-metadata' -export const metadata: Metadata = { - title: 'Random Number', - description: 'Display a random number on each request', -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + + + {children} + ) } diff --git a/evals/evals/agent-035-connection-dynamic/app/page.tsx b/evals/evals/agent-035-connection-dynamic/app/page.tsx index bc360b667d65..20adbd86f3fd 100644 --- a/evals/evals/agent-035-connection-dynamic/app/page.tsx +++ b/evals/evals/agent-035-connection-dynamic/app/page.tsx @@ -1,8 +1,8 @@ -export default function Home() { +export default function HomePage() { return (
-

Random Number Generator

-

Please implement a random number that changes on each request.

+

Deployment health

+

All systems operational

) } diff --git a/evals/evals/agent-035-connection-dynamic/components/request-metadata.tsx b/evals/evals/agent-035-connection-dynamic/components/request-metadata.tsx new file mode 100644 index 000000000000..6c191a0f78da --- /dev/null +++ b/evals/evals/agent-035-connection-dynamic/components/request-metadata.tsx @@ -0,0 +1,13 @@ +import { randomUUID } from 'node:crypto' + +export function RequestMetadata() { + const generatedAt = new Date().toISOString() + const sample = Math.random() + const requestId = randomUUID() + + return ( + + ) +} diff --git a/evals/evals/agent-035-connection-dynamic/tsconfig.json b/evals/evals/agent-035-connection-dynamic/tsconfig.json index 64c21044c49f..8eb9f7f78971 100644 --- a/evals/evals/agent-035-connection-dynamic/tsconfig.json +++ b/evals/evals/agent-035-connection-dynamic/tsconfig.json @@ -23,5 +23,5 @@ "target": "ES2017" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "EVAL.ts"] } diff --git a/evals/evals/agent-040-instant/EVAL.ts b/evals/evals/agent-040-instant/EVAL.ts index 6764f178f0ba..aceef5bb71d6 100644 --- a/evals/evals/agent-040-instant/EVAL.ts +++ b/evals/evals/agent-040-instant/EVAL.ts @@ -1,67 +1,43 @@ /** - * Instant Navigations + * Instant Validation * - * Verifies that the agent enables instant navigation on the product route. - * - * The eval harness runs `next build` as a script — with `cacheComponents: true`, - * the build validates that the caching structure produces an instant static shell. - * This test checks the agent actually exported `instant` (the build would - * pass without it, since Suspense alone satisfies the build on a static route). + * Starts with a non-instant navigation and verifies that the agent follows the + * framework's default validation feedback to produce a meaningful Cache + * Components shell: the stable title is available immediately while live + * inventory streams behind a Suspense boundary. */ import { expect, test } from 'vitest' -import { readFileSync, existsSync, readdirSync, statSync } from 'fs' -import { join } from 'path' - -type SourceFile = { path: string; content: string } - -const IGNORE_DIRS = new Set([ - '.git', - '.next', - 'node_modules', - 'dist', - 'build', - 'coverage', -]) +import { environment } from '@vercel/agent-eval/eval' -const IGNORE_FILES = new Set(['EVAL.ts', 'PROMPT.md']) +test('keeps the product title in the static shell', async () => { + await expect(environment).toSatisfyCriterion( + `The product title "Premium Widget" must render in the product page's static shell. It must remain outside the Suspense boundary and async work used for the live inventory, so the title is available before the inventory resolves. -function readSourceFiles(dir: string): SourceFile[] { - if (!existsSync(dir)) return [] - - const files: SourceFile[] = [] - for (const entry of readdirSync(dir)) { - if (IGNORE_DIRS.has(entry)) continue - - const fullPath = join(dir, entry) - const stats = statSync(fullPath) +Accept equivalent component and file organization. Reject solutions where the page awaits the inventory before returning or places the title behind the inventory boundary.` + ) +}) - if (stats.isDirectory()) { - files.push(...readSourceFiles(fullPath)) - continue - } +test('streams inventory behind a meaningful fallback', async () => { + await expect(environment).toSatisfyCriterion( + `The inventory count and price must render from an async child beneath a Suspense boundary with a visible, meaningful fallback. The product page must be able to return its title and fallback without waiting for getInventory(). - if (IGNORE_FILES.has(entry)) continue +Accept equivalent component and file organization. Reject a null or empty fallback, or a solution where the page awaits inventory before returning.` + ) +}) - if (/\.(ts|tsx|js|jsx)$/.test(entry)) { - files.push({ - path: fullPath, - content: readFileSync(fullPath, 'utf-8'), - }) - } - } +test('keeps inventory live and uncached', async () => { + await expect(environment).toSatisfyCriterion( + `The randomized inventory must remain request-time data. Preserve the runtime boundary in getInventory(), such as connection(), and do not cache the inventory or replace its count and price with static values. - return files -} +Accept equivalent component and file organization. Reject any solution that makes the randomized inventory reusable across requests.` + ) +}) -const sourceFiles = readSourceFiles(process.cwd()) +test('keeps default instant validation enabled', async () => { + await expect(environment).toSatisfyCriterion( + `The solution must keep the framework's default instant validation enabled and solve the static-shell problem directly. It must not add instant = false, disable Cache Components, or use Partial Prefetching as a substitute for a valid product shell. -test('instant exported from the product route', () => { - const productPage = sourceFiles.find( - (f) => f.path.includes('product') && /page\.(tsx?|jsx?)$/.test(f.path) +Accept equivalent component and file organization.` ) - - expect(productPage).toBeDefined() - expect(productPage!.content).toMatch(/export\s+(const|var|let)\s+instant\b/) - expect(productPage!.content).toMatch(/prefetch\s*:\s*['"]static['"]/) }) diff --git a/evals/evals/agent-040-instant/PROMPT.md b/evals/evals/agent-040-instant/PROMPT.md index f8cf59cb0706..5ea6f2295695 100644 --- a/evals/evals/agent-040-instant/PROMPT.md +++ b/evals/evals/agent-040-instant/PROMPT.md @@ -1 +1 @@ -Navigating from home to the product page is slow. The title should appear immediately. +Navigating from home to the product page is slow. Make the product title appear immediately while the live inventory loads. diff --git a/evals/evals/agent-040-instant/tsconfig.json b/evals/evals/agent-040-instant/tsconfig.json index 64c21044c49f..8eb9f7f78971 100644 --- a/evals/evals/agent-040-instant/tsconfig.json +++ b/evals/evals/agent-040-instant/tsconfig.json @@ -23,5 +23,5 @@ "target": "ES2017" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "EVAL.ts"] } diff --git a/evals/evals/agent-042-enable-ppr/EVAL.ts b/evals/evals/agent-042-enable-ppr/EVAL.ts index 20606268172d..ff209bf284f9 100644 --- a/evals/evals/agent-042-enable-ppr/EVAL.ts +++ b/evals/evals/agent-042-enable-ppr/EVAL.ts @@ -1,31 +1,52 @@ /** - * Enable PPR + * Cache Components: route config semantics * - * Tests whether the agent knows that partial pre-rendering is enabled via - * `cacheComponents: true` in next.config.ts, NOT the old - * `experimental: { ppr: true }` flag. - * - * Tricky because most training data and older docs reference the experimental - * flag. The current way to enable PPR in Next.js 16 is `cacheComponents: true`. + * Verifies that adoption translates legacy configuration instead of deleting + * behavior or silencing validation. */ import { expect, test } from 'vitest' -import { readFileSync } from 'fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set(['.git', '.next', 'node_modules', 'dist', 'build']) + +function readSource(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir).flatMap((entry) => { + if (IGNORE_DIRS.has(entry)) return [] + const path = join(dir, entry) + if (statSync(path).isDirectory()) return readSource(path) + if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return [] + return readFileSync(path, 'utf8') + }) +} -test('Enables PPR via cacheComponents in next.config', () => { - const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') +const source = readSource(process.cwd()).join('\n') +const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf8') +test('enables the current Cache Components config only', () => { expect(config).toMatch(/cacheComponents\s*:\s*true/) + expect(config).not.toMatch(/dynamicIO\s*:/) + expect(config).not.toMatch(/useCache\s*:/) }) -test('Does not use the old experimental.ppr flag', () => { - const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') +test('removes route opt-outs and incompatible segment config', () => { + expect(source).not.toMatch(/export\s+(?:const|var|let)\s+instant\s*=\s*false/) + expect(source).not.toMatch( + /export\s+(?:const|var|let)\s+(?:dynamic|revalidate|fetchCache)\s*=/ + ) +}) - // Strip comments to avoid false positives from explanatory comments - const stripped = config - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/.*$/gm, '') +test('preserves the hourly newsroom feed', async () => { + await expect(environment).toSatisfyCriterion( + `The newsroom feed must remain cached with an explicit approximately-hourly cache lifetime. A focused data-level cache is valid, and a page-level cache is also valid because the Newsroom page contains only the feed and stable framing with the same lifetime. Reject solutions that remove or materially change the feed's hourly refresh behavior.` + ) +}) - expect(stripped).not.toMatch(/ppr\s*:\s*true/) +test('keeps editor preview request-specific', async () => { + await expect(environment).toSatisfyCriterion( + `The editor preview must still read its cookie at request time outside every public cache. Place the request-specific preview below Suspense or equivalent meaningful loading UI while leaving useful stable preview framing in the static shell. Reject solutions that cache the cookie value, hide the entire preview page behind one fallback, or remove the request-specific behavior.` + ) }) diff --git a/evals/evals/agent-042-enable-ppr/PROMPT.md b/evals/evals/agent-042-enable-ppr/PROMPT.md index 2f9a333ba322..97820f8fcf1b 100644 --- a/evals/evals/agent-042-enable-ppr/PROMPT.md +++ b/evals/evals/agent-042-enable-ppr/PROMPT.md @@ -1 +1 @@ -Enable partial pre-rendering for this app. +Move this publishing app to Cache Components in one branch. Keep the newsroom feed on its hourly refresh policy and keep editor preview request-specific. Finish with a production build without leaving routes opted out. diff --git a/evals/evals/agent-042-enable-ppr/app/ProductList.tsx b/evals/evals/agent-042-enable-ppr/app/ProductList.tsx deleted file mode 100644 index 748a9909edfe..000000000000 --- a/evals/evals/agent-042-enable-ppr/app/ProductList.tsx +++ /dev/null @@ -1,18 +0,0 @@ -async function getProducts() { - const res = await fetch('https://api.example.com/products') - return res.json() -} - -export async function ProductList() { - const products = await getProducts() - - return ( - - ) -} diff --git a/evals/evals/agent-042-enable-ppr/app/Recommendations.tsx b/evals/evals/agent-042-enable-ppr/app/Recommendations.tsx deleted file mode 100644 index 61ee9575d6ad..000000000000 --- a/evals/evals/agent-042-enable-ppr/app/Recommendations.tsx +++ /dev/null @@ -1,19 +0,0 @@ -async function getRecommendations() { - const res = await fetch('https://api.example.com/recommendations') - return res.json() -} - -export async function Recommendations() { - const items = await getRecommendations() - - return ( -
-

Recommended for you

-
    - {items.map((item: { id: string; name: string }) => ( -
  • {item.name}
  • - ))} -
-
- ) -} diff --git a/evals/evals/agent-042-enable-ppr/app/layout.tsx b/evals/evals/agent-042-enable-ppr/app/layout.tsx index 6b8b4518030f..91cd6a8db4a9 100644 --- a/evals/evals/agent-042-enable-ppr/app/layout.tsx +++ b/evals/evals/agent-042-enable-ppr/app/layout.tsx @@ -1,18 +1,16 @@ -import type { Metadata } from 'next' +import Link from 'next/link' +import type { ReactNode } from 'react' -export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + + {children} + ) } diff --git a/evals/evals/agent-042-enable-ppr/app/news/page.tsx b/evals/evals/agent-042-enable-ppr/app/news/page.tsx new file mode 100644 index 000000000000..ef8ce563d17c --- /dev/null +++ b/evals/evals/agent-042-enable-ppr/app/news/page.tsx @@ -0,0 +1,19 @@ +export const revalidate = 3600 + +async function getStories() { + await new Promise((resolve) => setTimeout(resolve, 80)) + return ['City council approves new park', 'Night market opens Friday'] +} + +export default async function NewsPage() { + const stories = await getStories() + + return ( +
+

Newsroom

+ {stories.map((story) => ( +
{story}
+ ))} +
+ ) +} diff --git a/evals/evals/agent-042-enable-ppr/app/page.tsx b/evals/evals/agent-042-enable-ppr/app/page.tsx index 971678328582..89fbd7e29342 100644 --- a/evals/evals/agent-042-enable-ppr/app/page.tsx +++ b/evals/evals/agent-042-enable-ppr/app/page.tsx @@ -1,17 +1,3 @@ -import { Suspense } from 'react' -import { ProductList } from './ProductList' -import { Recommendations } from './Recommendations' - -export default function Page() { - return ( -
-

Store

- Loading products...

}> - -
- Loading recommendations...

}> - -
-
- ) +export default function HomePage() { + return

Northstar Publishing

} diff --git a/evals/evals/agent-042-enable-ppr/app/preview/page.tsx b/evals/evals/agent-042-enable-ppr/app/preview/page.tsx new file mode 100644 index 000000000000..6644b603487e --- /dev/null +++ b/evals/evals/agent-042-enable-ppr/app/preview/page.tsx @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers' + +export const dynamic = 'force-dynamic' + +export default async function PreviewPage() { + const draft = (await cookies()).get('draft-title')?.value ?? 'Untitled draft' + + return ( +
+

Editor preview

+

{draft}

+
+ ) +} diff --git a/evals/evals/agent-042-enable-ppr/next.config.ts b/evals/evals/agent-042-enable-ppr/next.config.ts index e4f5738a310b..5c906c55af96 100644 --- a/evals/evals/agent-042-enable-ppr/next.config.ts +++ b/evals/evals/agent-042-enable-ppr/next.config.ts @@ -1,5 +1,10 @@ import type { NextConfig } from 'next' -const nextConfig: NextConfig = {} +const nextConfig: NextConfig = { + experimental: { + // @ts-expect-error -- legacy config retained for the migration exercise + dynamicIO: true, + }, +} export default nextConfig diff --git a/evals/evals/agent-042-enable-ppr/tsconfig.json b/evals/evals/agent-042-enable-ppr/tsconfig.json index 00978ef407fd..cc321ed0658b 100644 --- a/evals/evals/agent-042-enable-ppr/tsconfig.json +++ b/evals/evals/agent-042-enable-ppr/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "EVAL.ts"] } diff --git a/evals/evals/agent-048-prefetch-url-data/EVAL.ts b/evals/evals/agent-048-prefetch-url-data/EVAL.ts new file mode 100644 index 000000000000..c0de1576146b --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/EVAL.ts @@ -0,0 +1,84 @@ +/** + * Prefetch URL data + * + * Verifies the basic Partial Prefetching inclusion case: the agent caches data + * that depends on searchParams and makes it available in a per-link prefetch. + */ + +import { expect, test } from 'vitest' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set([ + '.git', + '.next', + 'node_modules', + 'dist', + 'build', + 'coverage', +]) + +const IGNORE_FILES = new Set(['EVAL.ts', 'PROMPT.md']) + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +} + +function readSourceFiles(dir: string): string[] { + if (!existsSync(dir)) return [] + + const files: string[] = [] + for (const entry of readdirSync(dir)) { + if (IGNORE_DIRS.has(entry)) continue + + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + if (stats.isDirectory()) { + files.push(...readSourceFiles(fullPath)) + } else if (!IGNORE_FILES.has(entry) && /\.(ts|tsx|js|jsx)$/.test(entry)) { + files.push(stripComments(readFileSync(fullPath, 'utf-8'))) + } + } + + return files +} + +const source = readSourceFiles(process.cwd()).join('\n---FILE---\n') +const config = stripComments( + readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') +) + +test('enables Partial Prefetching globally or for the catalog route', () => { + expect( + /partialPrefetching\s*:\s*true/.test(config) || + /export\s+const\s+prefetch\s*=\s*['"]partial['"]/.test(source) + ).toBe(true) +}) + +test('preserves the category URL structure', () => { + expect(source).toMatch(/\/catalog\?category=/) + expect(source).toMatch(/\bsearchParams\b/) +}) + +test('opts the category links into per-link prefetching', () => { + expect(source).toMatch(/]*\bprefetch(?:\s*=\s*\{true\})?(?:\s|>)/) +}) + +test('caches the category data', () => { + expect(source).toMatch(/['"]use cache['"]/) +}) + +test('does not use a navigation-stage boundary', () => { + expect(source).not.toMatch(/\bunstable_navigation\b/) +}) + +test('prefetches the selected category and cached products', async () => { + await expect(environment).toSatisfyCriterion( + `The implementation must make the selected category heading and its cached products available from the completed per-link prefetch before navigation. + +A correct solution preserves the existing /catalog?category=... URLs, enables Partial Prefetching globally or on the catalog route, and opts the category Links into per-link prefetching with prefetch={true} or the equivalent bare prefetch prop. The catalog route reads the category from searchParams beneath Suspense and renders both the matching heading and getProducts(category) result in the prefetched result. The category data must use a use cache scope, with either the default cache profile or an explicit cacheLife(); searchParams must remain outside that cache scope. Keep the Suspense boundary: the shared App Shell uses its fallback, while a completed per-link prefetch can resolve the cached content inside it before navigation. + +This is the basic URL-data inclusion case. Do not use unstable_navigation(), unstable_prefetch(), connection(), or an uncached request boundary to defer the category content. Reject solutions that only prefetch the reusable Catalog shell while leaving the selected heading or product list behind the loading fallback.` + ) +}) diff --git a/evals/evals/agent-048-prefetch-url-data/PROMPT.md b/evals/evals/agent-048-prefetch-url-data/PROMPT.md new file mode 100644 index 000000000000..6c8e5d0309c1 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/PROMPT.md @@ -0,0 +1 @@ +Keep the existing `/catalog?category=...` URLs. Prepare each category link's URL-specific heading and products before the click so selecting it can navigate immediately without showing the loading state. Do not make the click wait for a full server render. diff --git a/evals/evals/agent-048-prefetch-url-data/app/catalog/page.tsx b/evals/evals/agent-048-prefetch-url-data/app/catalog/page.tsx new file mode 100644 index 000000000000..4b39f6859c1c --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/app/catalog/page.tsx @@ -0,0 +1,39 @@ +import { Suspense } from 'react' +import { getProducts } from '@/lib/data' + +type CatalogSearchParams = Promise<{ category?: string }> + +export default function CatalogPage({ + searchParams, +}: { + searchParams: CatalogSearchParams +}) { + return ( +
+

Catalog

+ Loading category...

}> + +
+
+ ) +} + +async function CategoryResults({ + searchParams, +}: { + searchParams: CatalogSearchParams +}) { + const { category = 'featured' } = await searchParams + const products = await getProducts(category) + + return ( +
+

{category === 'sale' ? 'Products on sale' : 'Featured products'}

+
    + {products.map((product) => ( +
  • {product}
  • + ))} +
+
+ ) +} diff --git a/evals/evals/agent-048-prefetch-url-data/app/layout.tsx b/evals/evals/agent-048-prefetch-url-data/app/layout.tsx new file mode 100644 index 000000000000..e6f0fb1729b7 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Product catalog', + description: 'Browse products by category', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/evals/evals/agent-048-prefetch-url-data/app/page.tsx b/evals/evals/agent-048-prefetch-url-data/app/page.tsx new file mode 100644 index 000000000000..989c82c49c6a --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/app/page.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link' + +const categories = [ + { slug: 'featured', label: 'Featured products' }, + { slug: 'sale', label: 'Products on sale' }, +] + +export default function Home() { + return ( +
+

Shop by category

+
    + {categories.map((category) => ( +
  • + + {category.label} + +
  • + ))} +
+
+ ) +} diff --git a/evals/evals/agent-048-prefetch-url-data/lib/data.ts b/evals/evals/agent-048-prefetch-url-data/lib/data.ts new file mode 100644 index 000000000000..d0eb6aac8080 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/lib/data.ts @@ -0,0 +1,9 @@ +const productsByCategory: Record = { + featured: ['Desk Lamp', 'Wool Blanket'], + sale: ['Canvas Tote', 'Travel Mug'], +} + +export async function getProducts(category: string) { + await new Promise((resolve) => setTimeout(resolve, 800)) + return productsByCategory[category] ?? [] +} diff --git a/evals/evals/agent-048-prefetch-url-data/next.config.ts b/evals/evals/agent-048-prefetch-url-data/next.config.ts new file mode 100644 index 000000000000..fa33c7c54f24 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + cacheComponents: true, +} + +export default nextConfig diff --git a/evals/evals/agent-048-prefetch-url-data/package.json b/evals/evals/agent-048-prefetch-url-data/package.json new file mode 100644 index 000000000000..97f5fdec21f9 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.3" + } +} diff --git a/evals/evals/agent-048-prefetch-url-data/tsconfig.json b/evals/evals/agent-048-prefetch-url-data/tsconfig.json new file mode 100644 index 000000000000..8eb9f7f78971 --- /dev/null +++ b/evals/evals/agent-048-prefetch-url-data/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +} diff --git a/evals/evals/agent-049-prefetch-stage/EVAL.ts b/evals/evals/agent-049-prefetch-stage/EVAL.ts new file mode 100644 index 000000000000..6ebb997222ff --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/EVAL.ts @@ -0,0 +1,87 @@ +/** + * Prefetch stage + * + * Verifies that the agent removes cached content from the reusable App Shell + * while keeping it available to a selected per-link prefetch. + */ + +import { expect, test } from 'vitest' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set([ + '.git', + '.next', + 'node_modules', + 'dist', + 'build', + 'coverage', +]) + +const IGNORE_FILES = new Set(['EVAL.ts', 'PROMPT.md']) + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +} + +function readSourceFiles(dir: string): string[] { + if (!existsSync(dir)) return [] + + const files: string[] = [] + for (const entry of readdirSync(dir)) { + if (IGNORE_DIRS.has(entry)) continue + + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + if (stats.isDirectory()) { + files.push(...readSourceFiles(fullPath)) + } else if (!IGNORE_FILES.has(entry) && /\.(ts|tsx|js|jsx)$/.test(entry)) { + files.push(stripComments(readFileSync(fullPath, 'utf-8'))) + } + } + + return files +} + +const source = readSourceFiles(process.cwd()).join('\n---FILE---\n') +const config = stripComments( + readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') +) + +test('enables Partial Prefetching globally or for the product route', () => { + expect( + /partialPrefetching\s*:\s*true/.test(config) || + /export\s+const\s+prefetch\s*=\s*['"]partial['"]/.test(source) + ).toBe(true) +}) + +test('opts the featured product link into per-link prefetching', () => { + expect(source).toMatch(/]*\bprefetch(?:\s*=\s*\{true\})?(?:\s|>)/) +}) + +test('uses the prefetch cache stage', () => { + const prefetchImport = source.match( + /\bunstable_prefetch(?:\s+as\s+([A-Za-z_$][\w$]*))?/ + ) + expect(prefetchImport).not.toBeNull() + + const localName = prefetchImport?.[1] ?? 'unstable_prefetch' + expect(source).toMatch(new RegExp(`await\\s+${localName}\\s*\\(`)) +}) + +test('keeps stable product content in the reusable shell', async () => { + await expect(environment).toSatisfyCriterion( + `The product title and description must remain in the reusable product App Shell. The related-products section must remain outside that shell, beneath its own Suspense boundary. Reject solutions that put the whole product page behind the prefetch stage or move related products into the App Shell. Accept equivalent component and file organization.` + ) +}) + +test('includes related products only in the selected prefetch', async () => { + await expect(environment).toSatisfyCriterion( + `The related-products section must be available before navigation from the featured-product Link without joining the default product App Shell. + +Render related products through an async component that awaits unstable_prefetch() before calling the cached getRelatedProducts() function. The unstable_prefetch() call must be outside every use cache scope, while the existing cached data function stays below it. + +Reject solutions that replace the stage with connection() or unstable_navigation(), make the related-products data uncached, or defer it until after navigation. Accept equivalent component and file organization.` + ) +}) diff --git a/evals/evals/agent-049-prefetch-stage/PROMPT.md b/evals/evals/agent-049-prefetch-stage/PROMPT.md new file mode 100644 index 000000000000..49eda1fba3d1 --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/PROMPT.md @@ -0,0 +1 @@ +Keep related products out of the default product-page prefetch, but have them ready immediately when navigating from the featured-product link. diff --git a/evals/evals/agent-049-prefetch-stage/app/layout.tsx b/evals/evals/agent-049-prefetch-stage/app/layout.tsx new file mode 100644 index 000000000000..39e6ae48c2c2 --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Featured product', + description: 'Shop our featured product', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/evals/evals/agent-049-prefetch-stage/app/page.tsx b/evals/evals/agent-049-prefetch-stage/app/page.tsx new file mode 100644 index 000000000000..090426fbde9a --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/app/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+

Featured product

+ View Premium Widget +
+ ) +} diff --git a/evals/evals/agent-049-prefetch-stage/app/product/page.tsx b/evals/evals/agent-049-prefetch-stage/app/product/page.tsx new file mode 100644 index 000000000000..7c0fb1623c4e --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/app/product/page.tsx @@ -0,0 +1,20 @@ +import { getRelatedProducts } from '@/lib/data' + +export default async function ProductPage() { + const relatedProducts = await getRelatedProducts() + + return ( +
+

Premium Widget

+

A durable widget for everyday projects.

+
+

Related products

+
    + {relatedProducts.map((product) => ( +
  • {product}
  • + ))} +
+
+
+ ) +} diff --git a/evals/evals/agent-049-prefetch-stage/lib/data.ts b/evals/evals/agent-049-prefetch-stage/lib/data.ts new file mode 100644 index 000000000000..239cf973e35f --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/lib/data.ts @@ -0,0 +1,7 @@ +import { cacheLife } from 'next/cache' + +export async function getRelatedProducts() { + 'use cache' + cacheLife('hours') + return ['Compact Widget', 'Widget Carry Case'] +} diff --git a/evals/evals/agent-049-prefetch-stage/next.config.ts b/evals/evals/agent-049-prefetch-stage/next.config.ts new file mode 100644 index 000000000000..fa33c7c54f24 --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + cacheComponents: true, +} + +export default nextConfig diff --git a/evals/evals/agent-049-prefetch-stage/package.json b/evals/evals/agent-049-prefetch-stage/package.json new file mode 100644 index 000000000000..97f5fdec21f9 --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.3" + } +} diff --git a/evals/evals/agent-049-prefetch-stage/tsconfig.json b/evals/evals/agent-049-prefetch-stage/tsconfig.json new file mode 100644 index 000000000000..8eb9f7f78971 --- /dev/null +++ b/evals/evals/agent-049-prefetch-stage/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/EVAL.ts b/evals/evals/agent-051-prefetch-until-navigation/EVAL.ts new file mode 100644 index 000000000000..1db0dec18e34 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/EVAL.ts @@ -0,0 +1,88 @@ +/** + * Prefetch until navigation + * + * Verifies that the agent uses Partial Prefetching to include a selected + * project's title in a per-link prefetch while deferring the rest of the + * project page until navigation. + */ + +import { expect, test } from 'vitest' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set([ + '.git', + '.next', + 'node_modules', + 'dist', + 'build', + 'coverage', +]) + +const IGNORE_FILES = new Set(['EVAL.ts', 'PROMPT.md']) + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +} + +function readSourceFiles(dir: string): string[] { + if (!existsSync(dir)) return [] + + const files: string[] = [] + for (const entry of readdirSync(dir)) { + if (IGNORE_DIRS.has(entry)) continue + + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + if (stats.isDirectory()) { + files.push(...readSourceFiles(fullPath)) + } else if (!IGNORE_FILES.has(entry) && /\.(ts|tsx|js|jsx)$/.test(entry)) { + files.push(stripComments(readFileSync(fullPath, 'utf-8'))) + } + } + + return files +} + +const source = readSourceFiles(process.cwd()).join('\n---FILE---\n') +const config = stripComments( + readFileSync(join(process.cwd(), 'next.config.ts'), 'utf-8') +) + +test('enables Partial Prefetching globally or for the project route', () => { + expect( + /partialPrefetching\s*:\s*true/.test(config) || + /export\s+const\s+prefetch\s*=\s*['"]partial['"]/.test(source) + ).toBe(true) +}) + +test('opts the project link into per-link prefetching', () => { + expect(source).toMatch(/]*\bprefetch(?:\s*=\s*\{true\})?(?:\s|>)/) +}) + +test('uses the navigation cache stage', () => { + const navigationImport = source.match( + /\bunstable_navigation(?:\s+as\s+([A-Za-z_$][\w$]*))?/ + ) + expect(navigationImport).not.toBeNull() + + const localName = navigationImport?.[1] ?? 'unstable_navigation' + expect(source).toMatch(new RegExp(`await\\s+${localName}\\s*\\(`)) +}) + +test('includes the project title in the selected prefetch', async () => { + await expect(environment).toSatisfyCriterion( + `The selected project's title must be available before the click from the dashboard's per-link prefetch. Keep the params-dependent title inside Suspense so the reusable App Shell can show a fallback while the selected prefetch resolves the title. The title must render before the nested boundary that waits for navigation. + +Accept equivalent component and file organization. Reject solutions that leave the title behind the reusable shell fallback, put it behind unstable_navigation(), disable prefetching, or prefetch the entire page.` + ) +}) + +test('waits until navigation for project details', async () => { + await expect(environment).toSatisfyCriterion( + `Project activity, deployments, description, and other detail content must not join the prefetch. Render those details inside a nested Suspense boundary through an async component that awaits unstable_navigation() before loading or rendering them. + +The unstable_navigation() call must remain outside every use cache scope; cached data functions may be called after it. Reject solutions that fetch project details before reaching unstable_navigation(), replace it with connection() or an uncached fetch, or prefetch the entire page. Accept equivalent component and file organization.` + ) +}) diff --git a/evals/evals/agent-051-prefetch-until-navigation/PROMPT.md b/evals/evals/agent-051-prefetch-until-navigation/PROMPT.md new file mode 100644 index 000000000000..2aae89c2e3b1 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/PROMPT.md @@ -0,0 +1 @@ +When navigating from the dashboard to a project, I want the project's title to appear instantly, but I don't want to prefetch the rest of the project page. diff --git a/evals/evals/agent-051-prefetch-until-navigation/app/layout.tsx b/evals/evals/agent-051-prefetch-until-navigation/app/layout.tsx new file mode 100644 index 000000000000..7309bb5579ad --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Projects', + description: 'Project dashboard', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/app/page.tsx b/evals/evals/agent-051-prefetch-until-navigation/app/page.tsx new file mode 100644 index 000000000000..8e4bcaee437c --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/app/page.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link' + +const projects = [ + { slug: 'acme', name: 'Acme Storefront' }, + { slug: 'orbit', name: 'Orbit Analytics' }, +] + +export default function Dashboard() { + return ( +
+

Projects

+
    + {projects.map((project) => ( +
  • + {project.name} +
  • + ))} +
+
+ ) +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/app/projects/[slug]/page.tsx b/evals/evals/agent-051-prefetch-until-navigation/app/projects/[slug]/page.tsx new file mode 100644 index 000000000000..ea3ad54ed029 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/app/projects/[slug]/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react' +import { getProjectDetails, getProjectTitle } from '@/lib/data' + +export default function ProjectPage({ params }: PageProps<'/projects/[slug]'>) { + return ( + Loading project...}> + + + ) +} + +async function Project({ + params, +}: Pick, 'params'>) { + const { slug } = await params + const [title, details] = await Promise.all([ + getProjectTitle(slug), + getProjectDetails(slug), + ]) + + return ( +
+

{title}

+

{details.description}

+

Recent activity

+
    + {details.activity.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/lib/data.ts b/evals/evals/agent-051-prefetch-until-navigation/lib/data.ts new file mode 100644 index 000000000000..7dddf09f7768 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/lib/data.ts @@ -0,0 +1,32 @@ +const projects: Record< + string, + { title: string; description: string; activity: string[] } +> = { + acme: { + title: 'Acme Storefront', + description: 'Commerce storefront and checkout.', + activity: ['Production deployment', 'Checkout updated', 'Domain verified'], + }, + orbit: { + title: 'Orbit Analytics', + description: 'Product analytics and reporting.', + activity: ['Report published', 'Data source added', 'Team invited'], + }, +} + +export async function getProjectTitle(slug: string) { + 'use cache' + return projects[slug]?.title ?? 'Unknown project' +} + +export async function getProjectDetails(slug: string) { + 'use cache' + await new Promise((resolve) => setTimeout(resolve, 800)) + return ( + projects[slug] ?? { + title: 'Unknown project', + description: 'This project could not be found.', + activity: [], + } + ) +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/next.config.ts b/evals/evals/agent-051-prefetch-until-navigation/next.config.ts new file mode 100644 index 000000000000..fa33c7c54f24 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + cacheComponents: true, +} + +export default nextConfig diff --git a/evals/evals/agent-051-prefetch-until-navigation/package.json b/evals/evals/agent-051-prefetch-until-navigation/package.json new file mode 100644 index 000000000000..97f5fdec21f9 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.3" + } +} diff --git a/evals/evals/agent-051-prefetch-until-navigation/tsconfig.json b/evals/evals/agent-051-prefetch-until-navigation/tsconfig.json new file mode 100644 index 000000000000..8eb9f7f78971 --- /dev/null +++ b/evals/evals/agent-051-prefetch-until-navigation/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +} diff --git a/evals/evals/agent-052-cache-components-session-isolation/EVAL.ts b/evals/evals/agent-052-cache-components-session-isolation/EVAL.ts new file mode 100644 index 000000000000..93e0d33a1be6 --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/EVAL.ts @@ -0,0 +1,49 @@ +/** Cache Components: request isolation and reusable data. */ + +import { expect, test } from 'vitest' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set(['.git', '.next', 'node_modules', 'dist', 'build']) + +function readSource(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir).flatMap((entry) => { + if (IGNORE_DIRS.has(entry)) return [] + const path = join(dir, entry) + if (statSync(path).isDirectory()) return readSource(path) + if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return [] + return readFileSync(path, 'utf8') + }) +} + +const source = readSource(process.cwd()).join('\n') +const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf8') + +test('enables Cache Components without opt-outs', () => { + expect(config).toMatch(/cacheComponents\s*:\s*true/) + expect(source).not.toMatch(/export\s+(?:const|var|let)\s+instant\s*=\s*false/) +}) + +test('introduces a reusable cache', () => { + expect(source).toMatch(/['"]use cache['"]/) +}) + +test('keeps a useful dashboard frame in the shell', async () => { + await expect(environment).toSatisfyCriterion( + `The dashboard must keep a useful stable frame, such as the Workspace heading, outside the Suspense boundary used for request-specific member content. The boundary must provide visible, meaningful loading UI rather than a null or empty fallback. Accept equivalent component and file organization.` + ) +}) + +test('keeps member data isolated per request', async () => { + await expect(environment).toSatisfyCriterion( + `Member name, team cookie, and request-region header must be read at request time in the suspended subtree and must not be captured by a public cache. No page or broad request-specific component may be publicly cached. A component that directly calls cookies() or headers() without a use cache directive remains request-specific and needs no additional cache opt-out. Reject any solution that can reuse one member's values for another member. Accept equivalent component and file organization.` + ) +}) + +test('caches only the shared project totals', async () => { + await expect(environment).toSatisfyCriterion( + `Shared project totals must be cached in a focused reusable data function. The cache must not include member name, cookies, headers, or other request-specific values. Accept equivalent component and file organization.` + ) +}) diff --git a/evals/evals/agent-052-cache-components-session-isolation/PROMPT.md b/evals/evals/agent-052-cache-components-session-isolation/PROMPT.md new file mode 100644 index 000000000000..a988a3062e4b --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/PROMPT.md @@ -0,0 +1 @@ +Enable Cache Components for this workspace dashboard. Members must continue seeing their own name, team, and request region, while the shared project totals stay reusable. Keep a useful dashboard frame visible while request-specific content loads, and finish with a production build without route opt-outs. diff --git a/evals/evals/agent-052-cache-components-session-isolation/app/layout.tsx b/evals/evals/agent-052-cache-components-session-isolation/app/layout.tsx new file mode 100644 index 000000000000..07e2db241f59 --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/evals/evals/agent-052-cache-components-session-isolation/app/page.tsx b/evals/evals/agent-052-cache-components-session-isolation/app/page.tsx new file mode 100644 index 000000000000..81dd311c1c8d --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/app/page.tsx @@ -0,0 +1,21 @@ +import { cookies, headers } from 'next/headers' +import { getProjectTotals } from '@/lib/projects' + +export default async function DashboardPage() { + const cookieStore = await cookies() + const requestHeaders = await headers() + const member = cookieStore.get('member')?.value ?? 'Guest' + const team = cookieStore.get('team')?.value ?? 'Community' + const region = requestHeaders.get('x-region') ?? 'global' + const totals = await getProjectTotals() + + return ( +
+

Workspace

+

Welcome, {member}

+

Team: {team}

+

Region: {region}

+

{totals.active} active projects

+
+ ) +} diff --git a/evals/evals/agent-052-cache-components-session-isolation/lib/projects.ts b/evals/evals/agent-052-cache-components-session-isolation/lib/projects.ts new file mode 100644 index 000000000000..437d7ce00624 --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/lib/projects.ts @@ -0,0 +1,4 @@ +export async function getProjectTotals() { + await new Promise((resolve) => setTimeout(resolve, 120)) + return { active: 12, archived: 4 } +} diff --git a/evals/evals/agent-052-cache-components-session-isolation/next-env.d.ts b/evals/evals/agent-052-cache-components-session-isolation/next-env.d.ts new file mode 100644 index 000000000000..1b3be0840f3f --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/evals/evals/agent-052-cache-components-session-isolation/next.config.ts b/evals/evals/agent-052-cache-components-session-isolation/next.config.ts new file mode 100644 index 000000000000..e4f5738a310b --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/evals/evals/agent-052-cache-components-session-isolation/package.json b/evals/evals/agent-052-cache-components-session-isolation/package.json new file mode 100644 index 000000000000..7056dd9ed5f9 --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5", + "vitest": "^3.1.3", + "@vitejs/plugin-react": "^4.4.1", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/evals/evals/agent-052-cache-components-session-isolation/tsconfig.json b/evals/evals/agent-052-cache-components-session-isolation/tsconfig.json new file mode 100644 index 000000000000..e32fb699a1b0 --- /dev/null +++ b/evals/evals/agent-052-cache-components-session-isolation/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +} diff --git a/evals/evals/agent-053-cache-components-url-shell/EVAL.ts b/evals/evals/agent-053-cache-components-url-shell/EVAL.ts new file mode 100644 index 000000000000..0705e5c0e342 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/EVAL.ts @@ -0,0 +1,51 @@ +/** Cache Components: URL data and shell boundaries. */ + +import { expect, test } from 'vitest' +import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { join } from 'path' +import { environment } from '@vercel/agent-eval/eval' + +const IGNORE_DIRS = new Set(['.git', '.next', 'node_modules', 'dist', 'build']) + +function readSource(dir: string): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir).flatMap((entry) => { + if (IGNORE_DIRS.has(entry)) return [] + const path = join(dir, entry) + if (statSync(path).isDirectory()) return readSource(path) + if (entry === 'EVAL.ts' || !/\.(ts|tsx|js|jsx)$/.test(entry)) return [] + return readFileSync(path, 'utf8') + }) +} + +const source = readSource(process.cwd()).join('\n') +const config = readFileSync(join(process.cwd(), 'next.config.ts'), 'utf8') + +test('enables Cache Components without opt-outs', () => { + expect(config).toMatch(/cacheComponents\s*:\s*true/) + expect(source).not.toMatch(/export\s+(?:const|var|let)\s+instant\s*=\s*false/) +}) + +test('keeps a useful shop frame in the static shell', async () => { + await expect(environment).toSatisfyCriterion( + `The product page must return useful route-independent shop framing before awaiting params or searchParams. The solution must not hide the entire page behind one null, empty, or generic Suspense boundary. Accept equivalent component and file organization.` + ) +}) + +test('resolves URL data below meaningful boundaries', async () => { + await expect(environment).toSatisfyCriterion( + `The product page must forward the params and searchParams promises into one or more children below meaningful Suspense fallbacks, where the product slug and currency are resolved. Reject solutions that await either URL promise before returning the useful shop frame. Accept equivalent component and file organization.` + ) +}) + +test('keys reusable product details by every input', async () => { + await expect(environment).toSatisfyCriterion( + `Product lookup must be cached in a focused reusable data function. Every value that can change its output, including the product slug and currency, must be passed into the cached function so it participates in the cache key. Reject broad page caching or a cache that can return one product or currency for another. Accept equivalent component and file organization.` + ) +}) + +test('streams related products independently', async () => { + await expect(environment).toSatisfyCriterion( + `Related products must remain behind their own meaningful Suspense boundary so they can stream independently after the primary product details. Reject solutions that block the primary details on related products or combine the whole page under one fallback. Accept equivalent component and file organization.` + ) +}) diff --git a/evals/evals/agent-053-cache-components-url-shell/PROMPT.md b/evals/evals/agent-053-cache-components-url-shell/PROMPT.md new file mode 100644 index 000000000000..62c3e1507e07 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/PROMPT.md @@ -0,0 +1 @@ +Enable Cache Components for this shop. Product URLs and their currency query must keep rendering the correct details, and product details should stay reusable across requests. Keep the shop frame useful while data loads, and let related products stream afterward. Finish with a production build without route opt-outs. diff --git a/evals/evals/agent-053-cache-components-url-shell/app/layout.tsx b/evals/evals/agent-053-cache-components-url-shell/app/layout.tsx new file mode 100644 index 000000000000..20810c070986 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/app/layout.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ) +} diff --git a/evals/evals/agent-053-cache-components-url-shell/app/page.tsx b/evals/evals/agent-053-cache-components-url-shell/app/page.tsx new file mode 100644 index 000000000000..442c83196608 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/app/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function HomePage() { + return ( +
+

Signal Shop

+ Brass lamp +
+ ) +} diff --git a/evals/evals/agent-053-cache-components-url-shell/app/products/[slug]/page.tsx b/evals/evals/agent-053-cache-components-url-shell/app/products/[slug]/page.tsx new file mode 100644 index 000000000000..71ec832085fb --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/app/products/[slug]/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation' +import { getProduct, getRelatedProducts } from '@/lib/products' + +export default async function ProductPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }> + searchParams: Promise<{ currency?: string }> +}) { + const { slug } = await params + const { currency = 'USD' } = await searchParams + const product = await getProduct(slug, currency) + if (!product) notFound() + const related = await getRelatedProducts(slug) + + return ( +
+

Signal Shop collection

+

{product.name}

+

{product.price}

+
+

Related products

+ {related.map((item) => ( +

{item.name}

+ ))} +
+
+ ) +} diff --git a/evals/evals/agent-053-cache-components-url-shell/lib/products.ts b/evals/evals/agent-053-cache-components-url-shell/lib/products.ts new file mode 100644 index 000000000000..a63b6b77ab3b --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/lib/products.ts @@ -0,0 +1,17 @@ +const products = { + lamp: { slug: 'lamp', name: 'Brass lamp', usd: 120 }, + vase: { slug: 'vase', name: 'Stone vase', usd: 80 }, +} + +export async function getProduct(slug: string, currency: string) { + await new Promise((resolve) => setTimeout(resolve, 120)) + const product = products[slug as keyof typeof products] + if (!product) return null + const rate = currency === 'EUR' ? 0.92 : 1 + return { ...product, price: `${currency} ${(product.usd * rate).toFixed(2)}` } +} + +export async function getRelatedProducts(slug: string) { + await new Promise((resolve) => setTimeout(resolve, 420)) + return Object.values(products).filter((product) => product.slug !== slug) +} diff --git a/evals/evals/agent-053-cache-components-url-shell/next-env.d.ts b/evals/evals/agent-053-cache-components-url-shell/next-env.d.ts new file mode 100644 index 000000000000..1b3be0840f3f --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/evals/evals/agent-053-cache-components-url-shell/next.config.ts b/evals/evals/agent-053-cache-components-url-shell/next.config.ts new file mode 100644 index 000000000000..e4f5738a310b --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/evals/evals/agent-053-cache-components-url-shell/package.json b/evals/evals/agent-053-cache-components-url-shell/package.json new file mode 100644 index 000000000000..7056dd9ed5f9 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5", + "vitest": "^3.1.3", + "@vitejs/plugin-react": "^4.4.1", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/evals/evals/agent-053-cache-components-url-shell/tsconfig.json b/evals/evals/agent-053-cache-components-url-shell/tsconfig.json new file mode 100644 index 000000000000..e32fb699a1b0 --- /dev/null +++ b/evals/evals/agent-053-cache-components-url-shell/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +}