Files
next.js/test/e2e/app-dir/parallel-routes-root-param-dynamic-child/parallel-routes-root-param-dynamic-child.test.ts
Andrew ClarkandGitHub c099530eb7 Prefetch links nearest the top of the document first (#95393)
### Why

Most viewport prefetches are scheduled via a shared
`IntersectionObserver`. When several links enter the viewport at once
(e.g. on initial load), the observer reports them in document order and
we scheduled them in that order. Because the prefetch scheduler gives
the highest priority to the *most recently scheduled* task, this meant
the link **lowest** in the document ended up with the highest priority —
the opposite of a sensible default.

### What

Iterate each observer batch in reverse, so the link nearest the **top**
of the document is scheduled last and therefore prioritized. The topmost
link isn't guaranteed to be the most important, but as a default
heuristic it's more reasonable than favoring whichever link happens to
be lowest in the document.

Also updates the two hover tests in `prefetch-scheduling`, whose
expected ordering was coupled to the old bottom-first default (the set
of route trees prefetched within the concurrency limit flips from pages
7,6,5,4 to 1,2,3,4). The behaviors they assert are unchanged, and a new
test covers the top-of-document prioritization.

---

Supersedes #94902, which was opened from a fork. The "Test new and
changed tests when deployed" jobs cannot pass on fork PRs — `vercel
link` fails with "No existing credentials found" because
`secrets.VERCEL_ADAPTER_TEST_TOKEN` is not available to `pull_request`
runs from forks. This PR is the same change (rebased onto latest canary)
on an upstream branch so the deploy jobs get credentials.

<!-- NEXT_JS_LLM_PR -->
2026-07-02 08:17:05 -04:00

211 lines
7.1 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { createRouterAct } from 'router-act'
import { setTimeout } from 'node:timers/promises'
import { retry } from 'next-test-utils'
describe('parallel-routes-root-param-dynamic-child', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
async function createBrowserActor(
url: string,
{
errorPage = false,
waitForPrefetch = false,
}: {
errorPage?: boolean
waitForPrefetch?: boolean
} = {}
) {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser(url, {
beforePageLoad(page) {
act = createRouterAct(page, { allowErrorStatusCodes: [404] })
},
// throttling the CPU to rule out flakiness based on how quickly the page loads
cpuThrottleRate: 6,
})
// If we're on the error page, we don't have a nav element to wait for.
if (errorPage) {
return { act, browser }
}
if (waitForPrefetch) {
// The page has navigation links that will be automatically prefetched.
// Some routes will 404 (like /es which isn't in generateStaticParams),
// so we allow 404 status codes. Let's reveal the navigation links and let
// the prefetching occur.
await act(
async () => {
await browser.elementByCss('#reveal').click()
// Ensure the navigation is visible
await browser.elementByCss('#nav')
// Wait for 500ms to ensure all links are visible
await setTimeout(500)
// Scroll to bottom to ensure all links enter viewport and trigger prefetches
await browser.eval('window.scrollTo(0, document.body.scrollHeight)')
// Wait for 500ms to ensure all links are visible
await setTimeout(500)
},
// If we're in development, we don't have any prefetching to wait for.
isNextDev ? 'no-requests' : undefined
)
} else {
// Most assertions only need the nav revealed, and should not block on
// background prefetch request completion.
await browser.elementByCss('#reveal').click()
await browser.elementByCss('#nav')
await setTimeout(500)
await browser.eval('window.scrollTo(0, document.body.scrollHeight)')
await setTimeout(500)
}
return { act, browser }
}
if (!isNextDev) {
describe('Prefetching', () => {
it('prefetches the 404 pages correctly', async () => {
await createBrowserActor('/en', { waitForPrefetch: true })
// If we got here without errors, the prefetch responses completed successfully
// without problematic redirects (like 307). Those will throw because
// it'll hit the net::ERR_TOO_MANY_REDIRECTS error.
})
})
}
describe('Base Routes', () => {
it.each(['en', 'fr'])(
'should render a 200 for /%s (in generateStaticParams)',
async (locale) => {
const { browser } = await createBrowserActor(`/${locale}`)
await retry(async () => {
expect(await browser.elementByCss('#locale-page').text()).toBe(
`Locale: ${locale}`
)
})
}
)
it('should render a 404 for /es (not in generateStaticParams)', async () => {
const { browser } = await createBrowserActor('/es', { errorPage: true })
expect(await browser.elementByCss('.next-error-h1').text()).toBe('404')
})
})
describe('Without generateStaticParams (no-gsp)', () => {
it.each(['en', 'fr', 'es'])(
'should render a 200 for /%s/no-gsp/stories/dynamic-123',
async (locale) => {
const { browser, act } = await createBrowserActor('/en')
await act(async () => {
await browser
.elementByCss(`[href="/${locale}/no-gsp/stories/dynamic-123"]`)
.click()
})
await retry(async () => {
expect(await browser.elementByCss('#story-locale').text()).toBe(
`Locale: ${locale}`
)
expect(await browser.elementByCss('#story-slug').text()).toBe(
'Story: dynamic-123'
)
})
}
)
it('should allow dynamic params even with /es locale', async () => {
// Even though /es is not in the root generateStaticParams,
// no-gsp routes should still work because they don't enforce static params
const { browser } = await createBrowserActor(
'/es/no-gsp/stories/dynamic-123'
)
expect(await browser.elementByCss('#story-locale').text()).toBe(
'Locale: es'
)
expect(await browser.elementByCss('#story-slug').text()).toBe(
'Story: dynamic-123'
)
})
})
describe('With generateStaticParams (gsp)', () => {
describe('Static params (static-123)', () => {
it.each(['en', 'fr'])(
'should render a 200 for /%s/gsp/stories/static-123',
async (locale) => {
const { browser, act } = await createBrowserActor(`/${locale}`)
await act(async () => {
await browser
.elementByCss(`[href="/${locale}/gsp/stories/static-123"]`)
.click()
})
await retry(async () => {
expect(await browser.elementByCss('#story-locale').text()).toBe(
`Locale: ${locale}`
)
expect(await browser.elementByCss('#story-slug').text()).toBe(
'Story: static-123'
)
})
}
)
it('should render a 404 for /es/gsp/stories/static-123 (locale not in generateStaticParams)', async () => {
const response = await next.fetch('/es/gsp/stories/static-123')
expect(response.status).toBe(404)
})
})
describe('Dynamic params (dynamic-123)', () => {
it.each(['en', 'fr'])(
'should render a 404 for /%s/gsp/stories/dynamic-123 (slug not in generateStaticParams)',
async (locale) => {
// Navigate from a valid locale page to a slug that isn't in
// generateStaticParams and assert the client renders the 404.
//
// We deliberately don't use `createRouterAct` here. The page eagerly
// prefetches its sibling nav links — several of which are themselves
// 404 routes — so the prefetch network doesn't reliably go idle.
// Wrapping the navigation in `act` (which waits for a quiet network)
// would time out. This test only cares about the rendered result,
// not the prefetch traffic.
const browser = await next.browser(`/${locale}`, {
cpuThrottleRate: 6,
})
await browser.elementByCss('#reveal').click()
await browser.elementByCss('#nav')
await browser
.elementByCss(`[href="/${locale}/gsp/stories/dynamic-123"]`)
.click()
await retry(async () => {
expect(await browser.elementByCss('.next-error-h1').text()).toBe(
'404'
)
})
}
)
it('should render a 404 for /es/gsp/stories/dynamic-123 (both locale and slug not allowed)', async () => {
const response = await next.fetch('/es/gsp/stories/dynamic-123')
expect(response.status).toBe(404)
})
})
})
})