Files
next.js/test/e2e/app-dir/optimistic-routing/optimistic-routing.test.ts
Andrew ClarkandGitHub f27b49280d fix: Don't fall through to catch-all when static child subtree doesn't match (#90957)
When matching a URL against the known route trie, if a URL part matches
a static child but the remaining parts don't resolve within that child's
subtree, the algorithm incorrectly falls back to a dynamic catch-all
sibling at the same level. This causes the catch-all to consume the
already-matched static part plus the remaining parts, producing a
synthetic entry for the wrong route.

For example, given routes `/dashboard/settings/profile` and
`/dashboard/[...catchall]`, navigating to `/dashboard/settings/profile`
could incorrectly match the catch-all with params `["settings",
"profile"]` instead of the intended static route.

The fix treats the static child match as authoritative: if a real
(non-placeholder) static child exists but its subtree can't resolve the
remaining URL parts, bail out to server resolution rather than trying
the dynamic sibling. This is safe because it just means an extra network
request for routes whose subtree hasn't been fully discovered.
2026-03-06 12:21:16 -05:00

508 lines
18 KiB
TypeScript

/**
* Optimistic Routing Tests
*
* These tests verify that route prediction works correctly. The key behavior
* being tested is that after learning a route pattern from one URL, navigating
* to a different URL with the same pattern should show the loading state
* instantly - without waiting for a tree prefetch.
*
* The testing strategy uses the fact that loading boundaries are cached and
* can be reused across different param values. If route prediction works:
* 1. We predict the route structure without a tree prefetch
* 2. We know there's a loading boundary from the predicted structure
* 3. The loading boundary segment is already cached
* 4. The loading UI appears instantly with the new param value
*
* We use RouterAct and assert on the loading state inside the act scope,
* where network responses haven't reached the client yet.
*/
import { nextTestSetup } from 'e2e-utils'
import { createRouterAct } from 'router-act'
import type { Playwright } from 'next-webdriver'
/**
* Reads the rendered route history from the page and returns an array of
* {url, params} objects representing every route state the app rendered.
*/
async function getRenderedRouteHistory(
browser: Playwright
): Promise<Array<{ url: string; params: Record<string, unknown> }>> {
const el = await browser.elementById('rendered-route-history')
const attr = await el.getAttribute('data-history')
return JSON.parse(attr).map((h: string) => JSON.parse(h))
}
describe('optimistic-routing', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
// Route prediction with static siblings requires production build
// because dev mode uses on-demand compilation (staticChildren is null)
test('skipped in dev mode', () => {})
return
}
it('basic dynamic route prediction: shows loading state instantly for unprefetched route', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Reveal and prefetch the first blog post link.
// This learns the /blog/[slug] route pattern and caches the loading boundary.
const revealPost1 = await browser.elementByCss(
'input[data-link-accordion="/blog/post-1"]'
)
await act(
async () => {
await revealPost1.click()
},
{
// Wait for prefetch to complete by matching loading boundary text in response
includes: 'Loading',
}
)
// Step 2: Reveal the second link and navigate to it.
// This link has prefetch={false} to test route prediction - we want to
// confirm the loading state appears instantly WITHOUT any prefetch.
await act(async () => {
const revealPost2 = await browser.elementByCss(
'input[data-link-accordion="/blog/post-2"]'
)
await revealPost2.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkPost2 = await browser.elementByCss('a[href="/blog/post-2"]')
await act(async () => {
await linkPost2.click()
// Assert inside the act scope - at this point, network responses haven't
// reached the client yet. If the loading state is visible, it proves
// route prediction worked.
const loadingMessage = await browser.elementById('loading-message')
expect(await loadingMessage.text()).toBe('Loading post-2...')
})
// Step 3: After act completes, verify the full page eventually loads
const postTitle = await browser.elementById('post-title')
expect(await postTitle.text()).toBe('Blog Post: post-2')
})
it('nested dynamic routes: predicts through multiple dynamic segments', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Reveal and prefetch the first product link.
// This learns the /products/[category]/[id] route pattern.
const revealProduct1 = await browser.elementByCss(
'input[data-link-accordion="/products/electronics/phone-1"]'
)
await act(
async () => {
await revealProduct1.click()
},
{
includes: 'Loading',
}
)
// Step 2: Navigate to a different product with different category AND id.
// This link has prefetch={false} to test route prediction - we want to
// confirm the loading state appears instantly WITHOUT any prefetch.
await act(async () => {
const revealProduct2 = await browser.elementByCss(
'input[data-link-accordion="/products/clothing/shirt-1"]'
)
await revealProduct2.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkProduct2 = await browser.elementByCss(
'a[href="/products/clothing/shirt-1"]'
)
await act(async () => {
await linkProduct2.click()
// Both category and id should be predicted correctly
const loadingMessage = await browser.elementById('loading-message')
expect(await loadingMessage.text()).toBe('Loading clothing/shirt-1...')
})
// Verify final page content
const productTitle = await browser.elementById('product-title')
expect(await productTitle.text()).toBe('Product: clothing/shirt-1')
})
it('optional catch-all: predicts from index to path with segments', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Prefetch /docs (index route, no slug segments)
const revealDocsIndex = await browser.elementByCss(
'input[data-link-accordion="/docs"]'
)
await act(
async () => {
await revealDocsIndex.click()
},
{
includes: 'Loading',
}
)
// Step 2: Navigate to /docs/intro (one segment)
const revealDocsIntro = await browser.elementByCss(
'input[data-link-accordion="/docs/intro"]'
)
await revealDocsIntro.click()
const linkDocsIntro = await browser.elementByCss('a[href="/docs/intro"]')
await act(async () => {
await linkDocsIntro.click()
const loadingMessage = await browser.elementById('loading-message')
expect(await loadingMessage.text()).toBe('Loading docs intro...')
})
const docsTitle = await browser.elementById('docs-title')
expect(await docsTitle.text()).toBe('Docs: intro')
})
it('optional catch-all: predicts between paths with different segment counts', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Prefetch /docs/intro (one segment)
const revealDocsIntro = await browser.elementByCss(
'input[data-link-accordion="/docs/intro"]'
)
await act(
async () => {
await revealDocsIntro.click()
},
{
includes: 'Loading',
}
)
// Step 2: Navigate to /docs/guide/getting-started (two segments).
// This link has prefetch={false} to test route prediction - we want to
// confirm the loading state appears instantly WITHOUT any prefetch.
await act(async () => {
const revealDocsGuide = await browser.elementByCss(
'input[data-link-accordion="/docs/guide/getting-started"]'
)
await revealDocsGuide.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkDocsGuide = await browser.elementByCss(
'a[href="/docs/guide/getting-started"]'
)
await act(async () => {
await linkDocsGuide.click()
const loadingMessage = await browser.elementById('loading-message')
expect(await loadingMessage.text()).toBe(
'Loading docs guide/getting-started...'
)
})
const docsTitle = await browser.elementById('docs-title')
expect(await docsTitle.text()).toBe('Docs: guide/getting-started')
})
it('required catch-all: predicts between paths with different segment counts', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Prefetch /files/documents/report.pdf (three segments)
const revealFiles1 = await browser.elementByCss(
'input[data-link-accordion="/files/documents/report.pdf"]'
)
await act(
async () => {
await revealFiles1.click()
},
{
includes: 'Loading',
}
)
// Step 2: Navigate to /files/a/b/c/d (four segments).
// This link has prefetch={false} to test route prediction - we want to
// confirm the loading state appears instantly WITHOUT any prefetch.
await act(async () => {
const revealFiles2 = await browser.elementByCss(
'input[data-link-accordion="/files/a/b/c/d"]'
)
await revealFiles2.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkFiles2 = await browser.elementByCss('a[href="/files/a/b/c/d"]')
await act(async () => {
await linkFiles2.click()
const loadingMessage = await browser.elementById('loading-message')
expect(await loadingMessage.text()).toBe('Loading file a/b/c/d...')
})
const filesTitle = await browser.elementById('files-title')
expect(await filesTitle.text()).toBe('File: a/b/c/d')
})
it('static sibling detection: does not incorrectly match static route to dynamic pattern', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Prefetch /blog/post-1 to learn the /blog/[slug] pattern.
// This also learns that /blog/featured is a static sibling.
const revealPost1 = await browser.elementByCss(
'input[data-link-accordion="/blog/post-1"]'
)
await act(
async () => {
await revealPost1.click()
},
{
includes: 'Loading',
}
)
// Step 2: Navigate to /blog/featured (static sibling).
// This link has prefetch={false} - route prediction should NOT apply because
// /blog/featured is recognized as a static sibling of /blog/[slug].
await act(async () => {
const revealFeatured = await browser.elementByCss(
'input[data-link-accordion="/blog/featured"]'
)
await revealFeatured.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkFeatured = await browser.elementByCss('a[href="/blog/featured"]')
await act(async () => {
await linkFeatured.click()
// The loading message should NOT be visible because:
// 1. /blog/featured is recognized as a static sibling
// 2. Route prediction doesn't apply
// 3. We need to wait for server response
const loadingMessage = await browser
.elementById('loading-message')
.catch(() => null)
expect(loadingMessage).toBeNull()
})
// After navigation completes, we should see the featured page
const featuredTitle = await browser.elementById('featured-title')
expect(await featuredTitle.text()).toBe('Featured Blog Post')
})
it('rewrite detection: detects dynamic rewrite when URL does not match route structure', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Navigate to /rewritten/first.
// This URL is rewritten by proxy to /actual/first.
// Because the URL path part ("rewritten") doesn't match the route segment
// ("actual"), the route is marked as having a dynamic rewrite.
await act(async () => {
const revealFirst = await browser.elementByCss(
'input[data-link-accordion="/rewritten/first"]'
)
await revealFirst.click()
const linkFirst = await browser.elementByCss('a[href="/rewritten/first"]')
await linkFirst.click()
})
// Wait for navigation to complete
await browser.elementById('actual-page')
// Step 2: Navigate back to home using browser back button
await browser.back()
await browser.elementById('rendered-route-history')
// Step 3: Navigate to /rewritten/second.
// This link has prefetch={false}. Even though we've "learned" the route
// from step 1, the route should be marked as having a dynamic rewrite,
// so we should NOT use the cached pattern.
await act(async () => {
const revealSecond = await browser.elementByCss(
'input[data-link-accordion="/rewritten/second"]'
)
await revealSecond.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkSecond = await browser.elementByCss('a[href="/rewritten/second"]')
await act(async () => {
await linkSecond.click()
})
// Wait for navigation to complete
await browser.elementById('actual-page')
// Verify using rendered route history that no wrong params were rendered.
// If route prediction incorrectly used a cached pattern, we'd see "first"
// briefly flash before "second".
expect(await getRenderedRouteHistory(browser)).toEqual([
{ url: '/', params: {} },
{ url: '/rewritten/first', params: { slug: 'first' } },
// Back to home
{ url: '/', params: {} },
// Should go directly to "second" with no intermediate wrong params
{ url: '/rewritten/second', params: { slug: 'second' } },
])
})
it('rewrite detection (search params): does not use cached pattern when search params cause different rewrite', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Navigate to /search-rewrite?v=alpha
// This is rewritten by proxy to /rewrite-target?content=alpha
// The page is fully static, displaying the content param.
await act(async () => {
const revealAlpha = await browser.elementByCss(
'input[data-link-accordion="/search-rewrite?v=alpha"]'
)
await revealAlpha.click()
const linkAlpha = await browser.elementByCss(
'a[href="/search-rewrite?v=alpha"]'
)
await linkAlpha.click()
})
// Wait for navigation and verify we see "alpha"
const contentAlpha = await browser.elementById('rewrite-content')
expect(await contentAlpha.getAttribute('data-content')).toBe('alpha')
// Step 2: Go back to home
await browser.back()
await browser.elementById('rendered-route-history')
// Step 3: Navigate to /search-rewrite?v=beta.
// This link has prefetch={false} - if the route was incorrectly cached as
// predictable, we'd see "alpha" instead of "beta" because the static page
// would be served from cache.
await act(async () => {
const revealBeta = await browser.elementByCss(
'input[data-link-accordion="/search-rewrite?v=beta"]'
)
await revealBeta.click()
}, 'no-requests') // Assert: prefetch={false} means no requests on reveal
const linkBeta = await browser.elementByCss(
'a[href="/search-rewrite?v=beta"]'
)
await act(async () => {
await linkBeta.click()
})
// Verify we see "beta", not "alpha"
// If this shows "alpha", the route was incorrectly using a cached pattern.
const contentBeta = await browser.elementById('rewrite-content')
expect(await contentBeta.getAttribute('data-content')).toBe('beta')
})
it('static route with catch-all sibling: does not match sub-route against catch-all', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})
// Step 1: Prefetch /dashboard/anything/here to learn the catch-all pattern
// at the "dashboard" trie level.
const revealCatchAll = await browser.elementByCss(
'input[data-link-accordion="/dashboard/anything/here"]'
)
await act(
async () => {
await revealCatchAll.click()
},
{
includes: 'Loading',
}
)
// Step 2: Prefetch /dashboard/settings to populate the static child
// "settings" in the trie at the "dashboard" level. At this point the
// trie knows about the settings page but not its children (like profile).
const revealSettings = await browser.elementByCss(
'input[data-link-accordion="/dashboard/settings"]'
)
await act(
async () => {
await revealSettings.click()
},
{
includes: 'Loading',
}
)
// Step 3: Navigate to /dashboard/settings/profile (prefetch={false}).
// The static child "settings" matches at the dashboard level, but its
// subtree doesn't yet know about "profile". The matcher should treat the
// static match as authoritative and bail out to server resolution rather
// than falling through to the catch-all sibling.
await act(async () => {
const revealProfile = await browser.elementByCss(
'input[data-link-accordion="/dashboard/settings/profile"]'
)
await revealProfile.click()
}, 'no-requests')
const linkProfile = await browser.elementByCss(
'a[href="/dashboard/settings/profile"]'
)
await act(async () => {
await linkProfile.click()
})
// Verify the profile page renders correctly after server resolution.
const profileTitle = await browser.elementById('profile-title')
expect(await profileTitle.text()).toBe('Profile Settings')
// Verify the route history doesn't contain any catch-all param entries.
// If the matcher incorrectly fell through to the catch-all, we'd see
// an entry with catchall=["settings","profile"].
expect(await getRenderedRouteHistory(browser)).toEqual([
{ url: '/', params: {} },
{ url: '/dashboard/settings/profile', params: {} },
])
})
})