rsnext/test/e2e/app-dir/app-prefetch/prefetching.test.ts
Zack Tanner b9861fd2cd
only prefix prefetch cache entries if they vary based on Next-URL (#61235)
### What
Prefetches to pages within a shared layout would frequently cache miss
despite having the data available. This causes the "instant navigation"
behavior (with the 30s/5min TTL) to not be effective on these pages.

### Why
In #59861, `nextUrl` was added as a prefetch cache key prefix to ensure
multiple interception routes that correspond to the same URL wouldn't
clash in the prefetch cache. However this causes a problem in the case
where you're navigating between sub-pages. To illustrate the issue,
consider the case where you load `/foo`. This will populate the prefetch
cache with an entry of `{foo: <PrefetchCacheNode}`. Navigating to
`/foo/bar`, with a link that prefetches back to `/foo`, will now result
in a new cache node: `{foo: <PrefetchCacheNode>, /foo/bar%/foo:
<PrefetchCacheNode>}` (where `Next-URL` is `/foo/bar`). Now we have a
cache entry for the full data, as well as a cache entry for a partial
prefetch up to the nearest loading boundary. Now when we navigate back
to `/foo`, the router will see that it's missing data, and need to
lazy-fetch the data triggering the loading boundary.

This was especially noticeable in the case where you have a route group
with it's own loading.js file because it creates a level of hierarchy in
the React tree, and suspending on the data fetch would result in the
group's loading boundary to be triggered. In the non-route group
scenario, there's still a bug here but it would stall on the data fetch
rather than triggering a boundary.

### How
In #61794 we conditionally send `Next-URL` as part of the `Vary` header
if we detect it could be intercepted. We use this information when
creating the prefetch entry to prefix it, in case it corresponds with an
intercepted route.

Closes NEXT-2193
2024-02-13 15:03:37 +00:00

398 lines
13 KiB
TypeScript

import { createNextDescribe } from 'e2e-utils'
import { check, waitFor } from 'next-test-utils'
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
const browserConfigWithFixedTime = {
beforePageLoad: (page) => {
page.addInitScript(() => {
const startTime = new Date()
const fixedTime = new Date('2023-04-17T00:00:00Z')
// Override the Date constructor
// @ts-ignore
// eslint-disable-next-line no-native-reassign
Date = class extends Date {
constructor() {
super()
// @ts-ignore
return new startTime.constructor(fixedTime)
}
static now() {
return fixedTime.getTime()
}
}
})
},
}
createNextDescribe(
'app dir - prefetching',
{
files: __dirname,
skipDeployment: true,
},
({ next, isNextDev }) => {
// TODO: re-enable for dev after https://vercel.slack.com/archives/C035J346QQL/p1663822388387959 is resolved (Sep 22nd 2022)
if (isNextDev) {
it('should skip next dev for now', () => {})
return
}
it('NEXT_RSC_UNION_QUERY query name is _rsc', async () => {
expect(NEXT_RSC_UNION_QUERY).toBe('_rsc')
})
it('should show layout eagerly when prefetched with loading one level down', async () => {
const browser = await next.browser('/', browserConfigWithFixedTime)
// Ensure the page is prefetched
await waitFor(1000)
const before = Date.now()
await browser
.elementByCss('#to-dashboard')
.click()
.waitForElementByCss('#dashboard-layout')
const after = Date.now()
const timeToComplete = after - before
expect(timeToComplete).toBeLessThan(1000)
expect(await browser.elementByCss('#dashboard-layout').text()).toBe(
'Dashboard Hello World'
)
await browser.waitForElementByCss('#dashboard-page')
expect(await browser.waitForElementByCss('#dashboard-page').text()).toBe(
'Welcome to the dashboard'
)
})
it('should not have prefetch error for static path', async () => {
const browser = await next.browser('/')
await browser.eval('window.nd.router.prefetch("/dashboard/123")')
await waitFor(3000)
await browser.eval('window.nd.router.push("/dashboard/123")')
expect(next.cliOutput).not.toContain('ReferenceError')
expect(next.cliOutput).not.toContain('is not defined')
})
it('should not fetch again when a static page was prefetched', async () => {
const browser = await next.browser('/404', browserConfigWithFixedTime)
let requests: string[] = []
browser.on('request', (req) => {
requests.push(new URL(req.url()).pathname)
})
await browser.eval('location.href = "/"')
await browser.eval(
'window.nd.router.prefetch("/static-page", {kind: "auto"})'
)
await check(() => {
return requests.some(
(req) =>
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
)
? 'success'
: JSON.stringify(requests)
}, 'success')
await browser
.elementByCss('#to-static-page')
.click()
.waitForElementByCss('#static-page')
expect(
requests.filter((request) => request === '/static-page').length
).toBe(1)
})
it('should not fetch again when a static page was prefetched when navigating to it twice', async () => {
const browser = await next.browser('/404', browserConfigWithFixedTime)
let requests: string[] = []
browser.on('request', (req) => {
requests.push(new URL(req.url()).pathname)
})
await browser.eval('location.href = "/"')
await browser.eval(
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
)
await check(() => {
return requests.some(
(req) =>
req.includes('static-page') && !req.includes(NEXT_RSC_UNION_QUERY)
)
? 'success'
: JSON.stringify(requests)
}, 'success')
await browser
.elementByCss('#to-static-page')
.click()
.waitForElementByCss('#static-page')
await browser
.elementByCss('#to-home')
// Go back to home page
.click()
// Wait for homepage to load
.waitForElementByCss('#to-static-page')
// Click on the link to the static page again
.click()
// Wait for the static page to load again
.waitForElementByCss('#static-page')
expect(
requests.filter(
(request) =>
request === '/static-page' || request.includes(NEXT_RSC_UNION_QUERY)
).length
).toBe(1)
})
it('should calculate `_rsc` query based on `Next-Url`', async () => {
const browser = await next.browser('/404', browserConfigWithFixedTime)
let staticPageRequests: string[] = []
browser.on('request', (req) => {
const url = new URL(req.url())
if (url.toString().includes(`/static-page?${NEXT_RSC_UNION_QUERY}=`)) {
staticPageRequests.push(`${url.pathname}${url.search}`)
}
})
await browser.eval('location.href = "/"')
await browser.eval(
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
)
await check(() => {
return staticPageRequests.length === 1
? 'success'
: JSON.stringify(staticPageRequests)
}, 'success')
// Unable to clear router cache so mpa navigation
await browser.eval('location.href = "/dashboard"')
await browser.eval(
`window.nd.router.prefetch("/static-page", {kind: "auto"})`
)
await check(() => {
return staticPageRequests.length === 2
? 'success'
: JSON.stringify(staticPageRequests)
}, 'success')
expect(staticPageRequests[0]).toMatch('/static-page?_rsc=')
expect(staticPageRequests[1]).toMatch('/static-page?_rsc=')
// `_rsc` does not match because it depends on the `Next-Url`
expect(staticPageRequests[0]).not.toBe(staticPageRequests[1])
})
it('should not prefetch for a bot user agent', async () => {
const browser = await next.browser('/404')
let requests: string[] = []
browser.on('request', (req) => {
requests.push(new URL(req.url()).pathname)
})
await browser.eval(
`location.href = "/?useragent=${encodeURIComponent(
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
)}"`
)
await browser.elementByCss('#to-static-page').moveTo()
// check five times to ensure prefetch didn't occur
for (let i = 0; i < 5; i++) {
await waitFor(500)
expect(
requests.filter(
(request) =>
request === '/static-page' ||
request.includes(NEXT_RSC_UNION_QUERY)
).length
).toBe(0)
}
})
it('should navigate when prefetch is false', async () => {
const browser = await next.browser('/prefetch-false/initial')
await browser
.elementByCss('#to-prefetch-false-result')
.click()
.waitForElementByCss('#prefetch-false-page-result')
expect(
await browser.elementByCss('#prefetch-false-page-result').text()
).toBe('Result page')
})
it('should not need to prefetch the layout if the prefetch is initiated at the same segment', async () => {
const stateTree = encodeURIComponent(
JSON.stringify([
'',
{
children: [
'prefetch-auto',
{
children: [
['slug', 'justputit', 'd'],
{ children: ['__PAGE__', {}] },
],
},
],
},
null,
null,
true,
])
)
const response = await next.fetch(`/prefetch-auto/justputit?_rsc=dcqtr`, {
headers: {
RSC: '1',
'Next-Router-Prefetch': '1',
'Next-Router-State-Tree': stateTree,
'Next-Url': '/prefetch-auto/justputit',
},
})
const prefetchResponse = await response.text()
expect(prefetchResponse).not.toContain('Hello World')
expect(prefetchResponse).not.toContain('Loading Prefetch Auto')
})
it('should only prefetch the loading state and not the component tree when prefetching at the same segment', async () => {
const stateTree = encodeURIComponent(
JSON.stringify([
'',
{
children: [
'prefetch-auto',
{
children: [
['slug', 'vercel', 'd'],
{ children: ['__PAGE__', {}] },
],
},
],
},
null,
null,
true,
])
)
const response = await next.fetch(`/prefetch-auto/justputit?_rsc=dcqtr`, {
headers: {
RSC: '1',
'Next-Router-Prefetch': '1',
'Next-Router-State-Tree': stateTree,
'Next-Url': '/prefetch-auto/vercel',
},
})
const prefetchResponse = await response.text()
expect(prefetchResponse).not.toContain('Hello World')
expect(prefetchResponse).toContain('Loading Prefetch Auto')
})
describe('dynamic rendering', () => {
describe.each(['/force-dynamic', '/revalidate-0'])('%s', (basePath) => {
it('should not re-render layout when navigating between sub-pages', async () => {
const logStartIndex = next.cliOutput.length
const browser = await next.browser(`${basePath}/test-page`)
let initialRandomNumber = await browser
.elementById('random-number')
.text()
await browser
.elementByCss(`[href="${basePath}/test-page/sub-page"]`)
.click()
await check(() => browser.hasElementByCssSelector('#sub-page'), true)
const newRandomNumber = await browser
.elementById('random-number')
.text()
expect(initialRandomNumber).toBe(newRandomNumber)
await check(() => {
const logOccurrences =
next.cliOutput.slice(logStartIndex).split('re-fetching in layout')
.length - 1
return logOccurrences
}, 1)
})
it('should update search params following a link click', async () => {
const browser = await next.browser(`${basePath}/search-params`)
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
await browser
.elementByCss(`[href="${basePath}/search-params"]`)
.click()
await check(
() => browser.elementById('search-params-data').text(),
/{}/
)
await browser.elementByCss('[href="?foo=true"]').click()
await check(
() => browser.elementById('search-params-data').text(),
/{"foo":"true"}/
)
})
})
it('should not re-fetch cached data when navigating back to a route group', async () => {
const browser = await next.browser('/prefetch-auto-route-groups')
// once the page has loaded, we expect a data fetch
expect(await browser.elementById('count').text()).toBe('1')
// once navigating to a sub-page, we expect another data fetch
await browser
.elementByCss("[href='/prefetch-auto-route-groups/sub/foo']")
.click()
// navigating back to the route group page shouldn't trigger any data fetch
await browser
.elementByCss("[href='/prefetch-auto-route-groups']")
.click()
// confirm that the dashboard page is still rendering the stale fetch count, as it should be cached
expect(await browser.elementById('count').text()).toBe('1')
// navigating to a new sub-page, we expect another data fetch
await browser
.elementByCss("[href='/prefetch-auto-route-groups/sub/bar']")
.click()
// finally, going back to the route group page shouldn't trigger any data fetch
await browser
.elementByCss("[href='/prefetch-auto-route-groups']")
.click()
// confirm that the dashboard page is still rendering the stale fetch count, as it should be cached
expect(await browser.elementById('count').text()).toBe('1')
await browser.refresh()
// reloading the page, we should now get an accurate total number of fetches
// the initial fetch, 2 sub-page fetches, and a final fetch when reloading the page
expect(await browser.elementById('count').text()).toBe('4')
})
})
}
)