Repository: vercel/next.js
Pull request: test: improve router prefetch test reliability

This updates the tests to use the accordion style link reveal technique to ensure that prefetches occur within the act and do not hang waiting for requests to be initiated.

Changed files:
filename: test/e2e/app-dir/app-client-cache/client-cache.parallel-routes.test.ts
patch: @@ -2,6 +2,7 @@ import { nextTestSetup } from 'e2e-utils'
 import { createRouterAct } from 'router-act'
 import { browserConfigWithFixedTime, fastForwardTo } from './test-utils'
 import path from 'path'
+import { Playwright } from 'next-webdriver'
 
 describe('app dir client cache with parallel routes', () => {
   const { next, isNextDev } = nextTestSetup({
@@ -14,6 +15,18 @@ describe('app dir client cache with parallel routes', () => {
     return
   }
 
+  async function reveal(browser: Playwright, href: string) {
+    // Get the reveal element and scroll it into view.
+    const reveal = await browser.elementByCss(`[data-link-accordion="${href}"]`)
+    await reveal.scrollIntoViewIfNeeded()
+
+    // Click the reveal element to reveal the content.
+    await reveal.click()
+
+    // Return the anchor link element.
+    return browser.elementByCss(`a[href="${href}"]`)
+  }
+
   describe('prefetch={true}', () => {
     it('should prefetch the full page', async () => {
       let act: ReturnType<typeof createRouterAct>
@@ -27,11 +40,7 @@ describe('app dir client cache with parallel routes', () => {
       // Reveal the link to trigger prefetch and wait for it to complete
       const link = await act(
         async () => {
-          const reveal = await browser.elementByCss(
-            '[data-link-accordion="/0"]'
-          )
-          await reveal.click()
-          return await browser.elementByCss('[href="/0"]')
+          return reveal(browser, '/0')
         },
         { includes: 'random-number' }
       )
@@ -55,11 +64,8 @@ describe('app dir client cache with parallel routes', () => {
       // Toggle the link, assert on the prefetch content
       const link = await act(
         async () => {
-          const reveal = await browser.elementByCss(
-            '[data-link-accordion="/0"]'
-          )
-          await reveal.click()
-          return await browser.elementByCss('[href="/0"]')
+          await reveal(browser, '/0')
+          return browser.elementByCss('[href="/0"]')
         },
         { includes: 'random-number' }
       )
@@ -68,15 +74,13 @@ describe('app dir client cache with parallel routes', () => {
       const randomNumber = await act(async () => {
         await link.click()
         await browser.waitForElementByCss('#random-number')
-        return await browser.elementByCss('#random-number').text()
+        return browser.elementByCss('#random-number').text()
       }, 'no-requests')
 
       // Toggle the home link, assert on the homepage content
       const homeLink = await act(
         async () => {
-          const reveal = await browser.elementByCss('[data-link-accordion="/"]')
-          await reveal.click()
-          return await browser.elementByCss('[href="/"]')
+          return reveal(browser, '/')
         },
         { includes: 'home-page' }
       )
@@ -89,22 +93,18 @@ describe('app dir client cache with parallel routes', () => {
 
       // Toggle the link to the other page again, navigate, assert no requests (because it's cached)
       const number = await act(async () => {
-        const reveal = await browser.elementByCss('[data-link-accordion="/0"]')
-        await reveal.click()
-        const link = await browser.elementByCss('[href="/0"]')
+        const link = await reveal(browser, '/0')
         await link.click()
         await browser.waitForElementByCss('#random-number')
-        return await browser.elementByCss('#random-number').text()
+        return browser.elementByCss('#random-number').text()
       }, 'no-requests')
 
       expect(number).toBe(randomNumber)
 
       // Navigate back home
       await act(async () => {
-        const reveal = await browser.elementByCss('[data-link-accordion="/"]')
-        await reveal.click()
-        const homeLink = await browser.elementByCss('[href="/"]')
-        await homeLink.click()
+        const link = await reveal(browser, '/')
+        await link.click()
         await browser.waitForElementByCss('#home-page')
       }, 'no-requests')
 
@@ -114,11 +114,7 @@ describe('app dir client cache with parallel routes', () => {
       // Toggle the link to the other page again, assert on prefetch content
       const linkAfterExpiry = await act(
         async () => {
-          const reveal = await browser.elementByCss(
-            '[data-link-accordion="/0"]'
-          )
-          await reveal.click()
-          return await browser.elementByCss('[href="/0"]')
+          return reveal(browser, '/0')
         },
         { includes: 'random-number' }
       )
@@ -127,7 +123,7 @@ describe('app dir client cache with parallel routes', () => {
       const newNumber = await act(async () => {
         await linkAfterExpiry.click()
         await browser.waitForElementByCss('#random-number')
-        return await browser.elementByCss('#random-number').text()
+        return browser.elementByCss('#random-number').text()
       }, 'no-requests')
 
       expect(newNumber).not.toBe(randomNumber)

filename: test/e2e/app-dir/interception-dynamic-segment/app/page.client.tsx
patch: @@ -0,0 +1,94 @@
+'use client'
+
+import Link from 'next/link'
+import { useState } from 'react'
+
+export function LinkAccordion({ href }: { href: string }) {
+  const [isOpen, setIsOpen] = useState(false)
+
+  return (
+    <div
+      data-testid="link-accordion"
+      data-href={href}
+      style={{
+        border: '1px solid #e5e7eb',
+        borderRadius: '12px',
+        padding: '16px',
+        margin: '12px 0',
+        backgroundColor: 'white',
+        boxShadow:
+          '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
+        transition: 'all 0.2s ease',
+        cursor: 'pointer',
+      }}
+    >
+      <div
+        style={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          gap: '12px',
+        }}
+      >
+        <div
+          style={{
+            fontFamily: 'monospace',
+            fontSize: '14px',
+            color: '#374151',
+            fontWeight: '500',
+            flex: 1,
+          }}
+        >
+          {href}
+        </div>
+        {!isOpen && (
+          <button
+            onClick={() => setIsOpen(true)}
+            style={{
+              padding: '8px 16px',
+              borderRadius: '8px',
+              border: 'none',
+              backgroundColor: '#3b82f6',
+              color: 'white',
+              fontSize: '14px',
+              fontWeight: '600',
+              cursor: 'pointer',
+              transition: 'all 0.2s ease',
+              display: 'flex',
+              alignItems: 'center',
+              gap: '6px',
+            }}
+          >
+            <span>▶</span>
+            Open
+          </button>
+        )}
+      </div>
+      {isOpen && (
+        <div
+          style={{
+            marginTop: '16px',
+            paddingTop: '16px',
+            borderTop: '1px solid #e5e7eb',
+          }}
+        >
+          <Link
+            href={href}
+            style={{
+              display: 'inline-block',
+              padding: '10px 20px',
+              backgroundColor: '#10b981',
+              color: 'white',
+              textDecoration: 'none',
+              borderRadius: '8px',
+              fontSize: '14px',
+              fontWeight: '600',
+            }}
+          >
+            Navigate to {href}
+          </Link>
+        </div>
+      )}
+    </div>
+  )
+}

filename: test/e2e/app-dir/interception-dynamic-segment/app/page.tsx
patch: @@ -1,4 +1,4 @@
-import Link from 'next/link'
+import { LinkAccordion } from './page.client'
 
 export default function Page() {
   return (
@@ -8,17 +8,15 @@ export default function Page() {
 
         <div>
           <h3>Test Case 1a: Simple page (no parallel routes)</h3>
-          <Link href="/simple-page" style={{ color: 'blue', fontSize: '16px' }}>
-            → /simple-page
-          </Link>
+          <LinkAccordion href="/simple-page" />
           <p>
             Interception route with just a page.tsx, no parallel routes at all.
           </p>
         </div>
 
         <div>
           <h3>Test Case 1b: Has page.tsx</h3>
-          <Link href="/has-page">→ /has-page</Link>
+          <LinkAccordion href="/has-page" />
           <p>
             Interception route has page.tsx at root level. No children slot
             exists.
@@ -27,15 +25,13 @@ export default function Page() {
 
         <div>
           <h3>Test Case 2: No parallel routes</h3>
-          <Link href="/no-parallel-routes/deeper">
-            → /no-parallel-routes/deeper
-          </Link>
+          <LinkAccordion href="/no-parallel-routes/deeper" />
           <p>No @parallel routes exist, so no implicit layout created.</p>
         </div>
 
         <div>
           <h3>Test Case 3: Has both @sidebar AND page.tsx</h3>
-          <Link href="/has-both">→ /has-both</Link>
+          <LinkAccordion href="/has-both" />
           <p>
             Has @sidebar parallel route BUT page.tsx fills the children slot.
           </p>
@@ -47,14 +43,14 @@ export default function Page() {
 
         <div>
           <h3>Test Case 4a: Has @sidebar but NO page.tsx (implicit layout)</h3>
-          <Link href="/test-nested">→ /test-nested</Link>
+          <LinkAccordion href="/test-nested" />
           <p>Has @sidebar (creates implicit layout) but NO page.tsx.</p>
           <p>✓ Auto-uses null default (no explicit files needed)</p>
         </div>
 
         <div>
           <h3>Test Case 4b: Has explicit layout.tsx but NO parallel routes</h3>
-          <Link href="/explicit-layout/deeper">→ /explicit-layout/deeper</Link>
+          <LinkAccordion href="/explicit-layout/deeper" />
           <p>
             Has explicit layout.tsx with children slot, but NO parallel routes
             like @sidebar.
@@ -69,18 +65,16 @@ export default function Page() {
         <h2>Original Tests</h2>
         <ul>
           <li>
-            <Link href="/foo/1">/foo/1</Link>
+            <LinkAccordion href="/foo/1" />
           </li>
           <li>
-            <Link href="/bar/1">/bar/1</Link>
+            <LinkAccordion href="/bar/1" />
           </li>
           <li>
-            <Link href="/test-nested/deeper">/test-nested/deeper</Link>
+            <LinkAccordion href="/test-nested/deeper" />
           </li>
           <li>
-            <Link href="/generate-static-params/foo">
-              /generate-static-params/foo
-            </Link>
+            <LinkAccordion href="/generate-static-params/foo" />
           </li>
         </ul>
       </section>

filename: test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts
patch: @@ -1,12 +1,50 @@
 import { nextTestSetup } from 'e2e-utils'
 import { retry } from 'next-test-utils'
+import { Playwright } from 'next-webdriver'
 import { createRouterAct } from 'router-act'
 
 describe('interception-dynamic-segment', () => {
   const { next, isNextStart, isNextDev } = nextTestSetup({
     files: __dirname,
   })
 
+  /**
+   * Returns true if the given href should already be opened. This allows us to
+   * condition on whether to expect any additional network requests.
+   */
+  async function isAccordionClosed(
+    browser: Playwright,
+    href: string
+  ): Promise<boolean> {
+    const selector = `[data-testid="link-accordion"][data-href="${href}"]`
+
+    // Check if the button is already open
+    return await browser.hasElementByCss(`${selector} button`)
+  }
+
+  /**
+   * Helper to navigate via the LinkAccordion component.
+   * Scrolls to the accordion, opens it, and clicks the link.
+   */
+  async function navigate(browser: Playwright, href: string) {
+    const selector = `[data-testid="link-accordion"][data-href="${href}"]`
+
+    // Find and scroll to accordion
+    const accordion = await browser.elementByCss(selector)
+    await accordion.scrollIntoViewIfNeeded()
+
+    // Click the "Open" button, it may already be open, so we don't need to
+    // click it again.
+    if (await isAccordionClosed(browser, href)) {
+      const button = await browser.elementByCss(`${selector} button`)
+      await button.click()
+    }
+
+    // Click the actual link
+    const link = await browser.elementByCss(`${selector} a`)
+    await link.click()
+  }
+
   /**
    * Create a browser with router act that will FAIL if any 404s occur during navigation.
    * This is critical because if a 404 occurs, the client will perform MPA navigation
@@ -27,7 +65,7 @@ describe('interception-dynamic-segment', () => {
   it('should work when interception route is paired with a dynamic segment', async () => {
     const browser = await next.browser('/')
 
-    await browser.elementByCss('[href="/foo/1"]').click()
+    await navigate(browser, '/foo/1')
     await browser.waitForIdleNetwork()
 
     await retry(async () => {
@@ -52,7 +90,7 @@ describe('interception-dynamic-segment', () => {
     const browser = await next.browser('/')
 
     // Navigate with interception
-    await browser.elementByCss('[href="/foo/1"]').click()
+    await navigate(browser, '/foo/1')
     await browser.waitForIdleNetwork()
 
     await retry(async () => {
@@ -82,7 +120,7 @@ describe('interception-dynamic-segment', () => {
     const browser = await next.browser('/')
 
     for (let i = 0; i < 2; i++) {
-      await browser.elementByCss('[href="/foo/1"]').click()
+      await navigate(browser, '/foo/1')
       await browser.waitForIdleNetwork()
 
       await retry(async () => {
@@ -126,7 +164,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/foo/1"]').click()
+          await navigate(browser, '/foo/1')
         })
 
         await retry(async () => {
@@ -145,7 +183,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/simple-page"]').click()
+          await navigate(browser, '/simple-page')
         })
 
         await retry(async () => {
@@ -165,7 +203,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/has-page"]').click()
+          await navigate(browser, '/has-page')
         })
 
         await retry(async () => {
@@ -185,9 +223,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser
-            .elementByCss('[href="/no-parallel-routes/deeper"]')
-            .click()
+          await navigate(browser, '/no-parallel-routes/deeper')
         })
 
         await retry(async () => {
@@ -207,7 +243,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/has-both"]').click()
+          await navigate(browser, '/has-both')
         })
 
         await retry(async () => {
@@ -235,7 +271,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/test-nested"]').click()
+          await navigate(browser, '/test-nested')
         })
 
         await retry(async () => {
@@ -260,7 +296,7 @@ describe('interception-dynamic-segment', () => {
 
         // Navigate directly to the deeper page from home
         await act(async () => {
-          await browser.elementByCss('[href="/test-nested/deeper"]').click()
+          await navigate(browser, '/test-nested/deeper')
         })
 
         await retry(async () => {
@@ -290,7 +326,7 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         await act(async () => {
-          await browser.elementByCss('[href="/explicit-layout/deeper"]').click()
+          await navigate(browser, '/explicit-layout/deeper')
         })
 
         await retry(async () => {
@@ -310,12 +346,18 @@ describe('interception-dynamic-segment', () => {
         const { act, browser } = await createBrowserWithRouterAct('/')
 
         for (let i = 0; i < 3; i++) {
+          const isAccordionOpen = i > 0
+
+          await expect(
+            isAccordionClosed(browser, '/test-nested')
+          ).resolves.toBe(!isAccordionOpen)
+
           // Forward navigation: triggers RSC request (validates no 404)
           await act(
             async () => {
-              await browser.elementByCss('[href="/test-nested"]').click()
+              await navigate(browser, '/test-nested')
             },
-            i > 0 ? 'no-requests' : undefined
+            !isAccordionOpen ? undefined : 'no-requests'
           )
 
           await retry(async () => {
@@ -343,7 +385,7 @@ describe('interception-dynamic-segment', () => {
 
         // First interception
         await act(async () => {
-          await browser.elementByCss('[href="/test-nested"]').click()
+          await navigate(browser, '/test-nested')
         })
 
         await retry(async () => {
@@ -353,8 +395,8 @@ describe('interception-dynamic-segment', () => {
 
         // Second interception
         await act(async () => {
-          await browser.elementByCss('[href="/has-both"]').click()
-        }, 'no-requests')
+          await navigate(browser, '/has-both')
+        })
 
         await retry(async () => {
           const modalContent = await browser.elementByCss('#modal').text()