Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled
575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
import { FileRef, nextTestSetup } from 'e2e-utils'
|
|
import { waitFor, retry } from 'next-test-utils'
|
|
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'
|
|
import { computeCacheBustingSearchParam } from 'next/dist/shared/lib/router/utils/cache-busting-search-param'
|
|
import { createRouterAct } from 'router-act'
|
|
import { createTimeController } from './test-utils'
|
|
import { join } from 'path'
|
|
|
|
const itHeaded = process.env.HEADLESS ? it.skip : it
|
|
|
|
describe('app dir - prefetching', () => {
|
|
const { next, isNextDev, isNextDeploy } = nextTestSetup({
|
|
files: {
|
|
app: new FileRef(join(__dirname, 'app')),
|
|
},
|
|
})
|
|
|
|
// 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 () => {
|
|
let act: ReturnType<typeof createRouterAct>
|
|
const timeController = createTimeController()
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(page) {
|
|
act = createRouterAct(page)
|
|
},
|
|
})
|
|
|
|
await timeController.install(browser)
|
|
|
|
// Reveal the dashboard accordion and wait for prefetch to complete
|
|
const dashboardLink = await act(
|
|
async () => {
|
|
const reveal = await browser.elementByCss('#accordion-to-dashboard')
|
|
await reveal.click()
|
|
await browser.waitForElementByCss('#to-dashboard')
|
|
return await browser.elementByCss('#to-dashboard')
|
|
},
|
|
{ includes: '[dashboard-prefetch-sentinel]' }
|
|
)
|
|
|
|
const before = Date.now()
|
|
await dashboardLink.click()
|
|
await browser.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 [dashboard-prefetch-sentinel]'
|
|
)
|
|
})
|
|
|
|
it('should not have prefetch error for static path', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.eval('window.next.router.prefetch("/dashboard/123")')
|
|
await waitFor(3000)
|
|
await browser.eval('window.next.router.push("/dashboard/123")')
|
|
expect(next.cliOutput).not.toContain('ReferenceError')
|
|
expect(next.cliOutput).not.toContain('is not defined')
|
|
})
|
|
|
|
it('should not have prefetch error when reloading before prefetch request is finished', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.eval('window.next.router.prefetch("/dashboard/123")')
|
|
await browser.refresh()
|
|
const logs = await browser.log()
|
|
|
|
expect(logs).not.toMatchObject(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('Failed to fetch RSC payload'),
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
itHeaded('should not suppress prefetches after navigating back', async () => {
|
|
// Force headed mode, as bfcache is not available in headless mode.
|
|
const browser = await next.browser('/', { headless: false })
|
|
|
|
// Trigger a hard navigation.
|
|
await browser.elementById('to-static-page-hard').click()
|
|
|
|
// Go back, utilizing the bfcache.
|
|
await browser.elementById('go-back').click()
|
|
|
|
let requests: string[] = []
|
|
browser.on('request', (req) => {
|
|
requests.push(new URL(req.url()).pathname)
|
|
})
|
|
|
|
await browser.eval('window.next.router.prefetch("/dashboard/123")')
|
|
await browser.waitForIdleNetwork()
|
|
|
|
expect(requests).toInclude('/dashboard/123')
|
|
})
|
|
|
|
it('should not fetch again when a static page was prefetched', async () => {
|
|
let act: ReturnType<typeof createRouterAct>
|
|
const timeController = createTimeController()
|
|
const browser = await next.browser('/404', {
|
|
beforePageLoad(page) {
|
|
act = createRouterAct(page)
|
|
},
|
|
})
|
|
|
|
await browser.eval('location.href = "/"')
|
|
await browser.waitForElementByCss('#accordion-to-static-page')
|
|
await timeController.install(browser)
|
|
|
|
// Reveal the static-page accordion to trigger prefetch
|
|
await act(
|
|
async () => {
|
|
const reveal = await browser.elementByCss('#accordion-to-static-page')
|
|
await reveal.click()
|
|
await browser.waitForElementByCss('#to-static-page')
|
|
},
|
|
{ includes: 'Static Page [prefetch-sentinel]' }
|
|
)
|
|
|
|
// Navigate to static page using cached prefetch
|
|
await act(async () => {
|
|
await browser.elementByCss('#to-static-page').click()
|
|
await browser.waitForElementByCss('#static-page')
|
|
}, 'no-requests')
|
|
|
|
// Return to the home page - reveal accordion and navigate
|
|
const reveal = await browser.elementByCss('#accordion-to-home')
|
|
await reveal.click()
|
|
const homeLink = await browser.waitForElementByCss('#to-home')
|
|
|
|
await homeLink.click()
|
|
await browser.waitForElementByCss('#accordion-to-static-page')
|
|
|
|
// Reveal the static-page accordion again - should not trigger new prefetch (cache still fresh)
|
|
await browser.elementByCss('#accordion-to-static-page').click()
|
|
await browser.waitForElementByCss('#to-static-page')
|
|
|
|
// Navigate to the static page again using cached data
|
|
await act(async () => {
|
|
await browser.elementByCss('#to-static-page').click()
|
|
await browser.waitForElementByCss('#static-page')
|
|
}, 'no-requests')
|
|
})
|
|
|
|
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)'
|
|
)}"`
|
|
)
|
|
|
|
// Reveal the static-page accordion
|
|
await browser.elementByCss('#accordion-to-static-page').click()
|
|
await browser.waitForElementByCss('#to-static-page')
|
|
|
|
// Hover over the link - bot agents should not trigger prefetch
|
|
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', null],
|
|
{ children: ['__PAGE__', {}] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
null,
|
|
16, // PrefetchHint.IsRootLayout
|
|
])
|
|
)
|
|
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('Page Data!')
|
|
expect(prefetchResponse).not.toContain('Layout Data!')
|
|
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', null],
|
|
{ children: ['__PAGE__', {}] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
null,
|
|
16, // PrefetchHint.IsRootLayout
|
|
])
|
|
)
|
|
|
|
const headers = {
|
|
rsc: '1',
|
|
'next-router-prefetch': '1',
|
|
'next-router-state-tree': stateTree,
|
|
'next-url': '/prefetch-auto/vercel',
|
|
}
|
|
|
|
const url = new URL('/prefetch-auto/justputit', 'http://localhost')
|
|
const cacheBustingParam = computeCacheBustingSearchParam(
|
|
headers['next-router-prefetch'] ? '1' : '0',
|
|
undefined,
|
|
headers['next-router-state-tree'],
|
|
headers['next-url']
|
|
)
|
|
if (cacheBustingParam) {
|
|
url.searchParams.set('_rsc', cacheBustingParam)
|
|
}
|
|
|
|
const response = await next.fetch(url.toString(), { headers })
|
|
|
|
const prefetchResponse = await response.text()
|
|
expect(prefetchResponse).not.toContain('Page Data!')
|
|
expect(prefetchResponse).toContain('Loading Prefetch Auto')
|
|
})
|
|
|
|
it('should not re-render error component when triggering a prefetch action', async () => {
|
|
const browser = await next.browser('/with-error')
|
|
|
|
const initialRandom = await browser
|
|
.elementByCss('button')
|
|
.click()
|
|
.waitForElementByCss('#random-number')
|
|
.text()
|
|
|
|
await browser.eval('window.next.router.prefetch("/")')
|
|
|
|
// confirm the error component was not re-rendered
|
|
expect(await browser.elementById('random-number').text()).toBe(
|
|
initialRandom
|
|
)
|
|
})
|
|
|
|
it('should immediately render the loading state for a dynamic segment when fetched from higher up in the tree', async () => {
|
|
let act: ReturnType<typeof createRouterAct>
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(page) {
|
|
act = createRouterAct(page)
|
|
},
|
|
})
|
|
|
|
// Reveal the accordion and wait for prefetch - should get loading state
|
|
const link = await act(
|
|
async () => {
|
|
const reveal = await browser.elementByCss('#accordion-to-dynamic-page')
|
|
await reveal.click()
|
|
return browser.elementByCss('#to-dynamic-page')
|
|
},
|
|
{ includes: 'Loading Prefetch Auto' }
|
|
)
|
|
|
|
// Click the link to navigate - should trigger dynamic data fetch
|
|
const loadingText = await act(
|
|
async () => {
|
|
await link.click()
|
|
return browser.elementByCss('#loading-text').text()
|
|
},
|
|
{ includes: 'prefetch-auto-page-data' }
|
|
)
|
|
expect(loadingText).toBe('Loading Prefetch Auto')
|
|
|
|
// Wait for final data to appear
|
|
await browser.waitForElementByCss('#prefetch-auto-page-data')
|
|
})
|
|
|
|
it('should not unintentionally modify the requested prefetch by escaping the uri encoded query params', async () => {
|
|
const rscRequests = []
|
|
const browser = await next.browser('/uri-encoded-prefetch', {
|
|
beforePageLoad(page) {
|
|
page.on('request', async (req) => {
|
|
const url = new URL(req.url())
|
|
if (url.searchParams.has('_rsc')) {
|
|
rscRequests.push(url.pathname + url.search)
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
// sanity check: the link should be present
|
|
expect(await browser.elementById('prefetch-via-link')).toBeDefined()
|
|
|
|
await browser.waitForIdleNetwork()
|
|
|
|
// The space encoding of the prefetch request should be the same as the href, and should not be replaced with a +
|
|
await retry(async () => {
|
|
expect(
|
|
rscRequests.filter((req) => req.includes('/?param=with%20space')).length
|
|
).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
const initialRequestCount = rscRequests.filter((req) =>
|
|
req.includes('/?param=with%20space')
|
|
).length
|
|
|
|
// Click the link
|
|
await browser.elementById('prefetch-via-link').click()
|
|
|
|
// Assert that we're on the homepage (check for accordion since links are hidden)
|
|
expect(
|
|
await browser.hasElementByCssSelector('#accordion-to-dashboard')
|
|
).toBe(true)
|
|
|
|
await browser.waitForIdleNetwork()
|
|
|
|
// No new requests should be made since it is correctly prefetched
|
|
await retry(async () => {
|
|
expect(
|
|
rscRequests.filter((req) => req.includes('/?param=with%20space')).length
|
|
).toBe(initialRequestCount)
|
|
})
|
|
})
|
|
|
|
// These tests are skipped when deployed as they rely on runtime logs
|
|
if (!isNextDeploy) {
|
|
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 retry(async () => {
|
|
expect(await browser.hasElementByCssSelector('#sub-page')).toBe(
|
|
true
|
|
)
|
|
})
|
|
|
|
const newRandomNumber = await browser
|
|
.elementById('random-number')
|
|
.text()
|
|
|
|
expect(initialRandomNumber).toBe(newRandomNumber)
|
|
|
|
await retry(async () => {
|
|
const logOccurrences =
|
|
next.cliOutput.slice(logStartIndex).split('re-fetching in layout')
|
|
.length - 1
|
|
|
|
expect(logOccurrences).toBe(1)
|
|
})
|
|
})
|
|
|
|
it('should update search params following a link click', async () => {
|
|
const browser = await next.browser(`${basePath}/search-params`)
|
|
await retry(async () => {
|
|
const text = await browser.elementById('search-params-data').text()
|
|
expect(text).toMatch(/{}/)
|
|
})
|
|
await browser.elementByCss('[href="?foo=true"]').click()
|
|
await retry(async () => {
|
|
const text = await browser.elementById('search-params-data').text()
|
|
expect(text).toMatch(/{"foo":"true"}/)
|
|
})
|
|
await browser
|
|
.elementByCss(`[href="${basePath}/search-params"]`)
|
|
.click()
|
|
await retry(async () => {
|
|
const text = await browser.elementById('search-params-data').text()
|
|
expect(text).toMatch(/{}/)
|
|
})
|
|
await browser.elementByCss('[href="?foo=true"]').click()
|
|
await retry(async () => {
|
|
const text = await browser.elementById('search-params-data').text()
|
|
expect(text).toMatch(/{"foo":"true"}/)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
describe('invalid URLs', () => {
|
|
it('should not throw when an invalid URL is passed to Link', async () => {
|
|
const browser = await next.browser('/invalid-url/from-link')
|
|
|
|
await retry(async () => {
|
|
expect(await browser.hasElementByCssSelector('h1')).toBe(true)
|
|
})
|
|
expect(await browser.elementByCss('h1').text()).toEqual('Hello, world!')
|
|
})
|
|
|
|
it('should throw when an invalid URL is passed to router.prefetch', async () => {
|
|
const browser = await next.browser('/invalid-url/from-router-prefetch')
|
|
|
|
await retry(async () => {
|
|
expect(await browser.hasElementByCssSelector('h1')).toBe(true)
|
|
})
|
|
expect(await browser.elementByCss('h1').text()).toEqual(
|
|
'A prefetch threw an error'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('fetch priority', () => {
|
|
it('should prefetch links in viewport with low priority', async () => {
|
|
const requests: { priority: string; url: string }[] = []
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(page) {
|
|
page.on('request', async (req) => {
|
|
const url = new URL(req.url())
|
|
const headers = await req.allHeaders()
|
|
if (headers['rsc']) {
|
|
requests.push({
|
|
priority: headers['next-test-fetch-priority'],
|
|
url: url.pathname,
|
|
})
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
// Reveal an accordion to trigger prefetch
|
|
await browser.elementByCss('#accordion-to-static-page').click()
|
|
await browser.waitForIdleNetwork()
|
|
|
|
await retry(async () => {
|
|
const staticPageRequests = requests.filter(
|
|
(req) => req.url === '/static-page'
|
|
)
|
|
expect(staticPageRequests.length).toBeGreaterThan(0)
|
|
expect(staticPageRequests.every((req) => req.priority === 'low')).toBe(
|
|
true
|
|
)
|
|
})
|
|
})
|
|
|
|
it('should have an auto priority for all other fetch operations', async () => {
|
|
const requests: { priority: string; url: string }[] = []
|
|
const browser = await next.browser('/', {
|
|
beforePageLoad(page) {
|
|
page.on('request', async (req) => {
|
|
const url = new URL(req.url())
|
|
const headers = await req.allHeaders()
|
|
if (headers['rsc']) {
|
|
requests.push({
|
|
priority: headers['next-test-fetch-priority'],
|
|
url: url.pathname,
|
|
})
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
// Reveal the dashboard accordion
|
|
await browser.elementByCss('#accordion-to-dashboard').click()
|
|
await browser.waitForElementByCss('#to-dashboard')
|
|
|
|
// Click to navigate
|
|
await browser.elementByCss('#to-dashboard').click()
|
|
await browser.waitForIdleNetwork()
|
|
|
|
await retry(async () => {
|
|
const dashboardRequests = requests.filter(
|
|
(req) => req.url === '/dashboard'
|
|
)
|
|
expect(dashboardRequests.length).toBeGreaterThanOrEqual(2)
|
|
// Should have at least one low priority prefetch request
|
|
expect(dashboardRequests.some((req) => req.priority === 'low')).toBe(
|
|
true
|
|
)
|
|
// Should have at least one auto priority fetch to fill in missing data
|
|
expect(dashboardRequests.some((req) => req.priority === 'auto')).toBe(
|
|
true
|
|
)
|
|
})
|
|
})
|
|
|
|
it('should respect multiple prefetch types to the same URL', async () => {
|
|
let interceptRequests = false
|
|
|
|
const browser = await next.browser('/prefetch-race', {
|
|
beforePageLoad(page) {
|
|
page.route('**/force-dynamic/**', async (route) => {
|
|
if (!interceptRequests) {
|
|
return route.continue()
|
|
}
|
|
|
|
const request = route.request()
|
|
const headers = await request.allHeaders()
|
|
|
|
if (headers['rsc'] === '1') {
|
|
// intentionally stall the request,
|
|
// as after the initial page load, there shouldn't be any additional fetches
|
|
// since the data should already be available.
|
|
} else {
|
|
await route.continue()
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
await browser.waitForIdleNetwork()
|
|
interceptRequests = true
|
|
|
|
await browser.elementByCss('[href="/force-dynamic/test-page"]').click()
|
|
await browser.waitForElementByCss('#test-page')
|
|
})
|
|
})
|
|
})
|