Skip to content

Commit 66faa48

Browse files
committed
fix(docs): reuse in-flight intent prefetches during navigation
1 parent b313fcc commit 66faa48

2 files changed

Lines changed: 102 additions & 2 deletions

File tree

‎apps/docs/components/docs/navigation.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function NavigationItem({ node, pathname, onNavigate }: NavigationItemProps) {
7474
>
7575
<Link
7676
href={node.url}
77-
prefetch={prefetch ? null : false}
77+
prefetch={prefetch}
7878
onMouseEnter={() => setPrefetch(true)}
7979
onFocus={() => setPrefetch(true)}
8080
onTouchStart={() => setPrefetch(true)}

‎apps/docs/scripts/test-navigation-e2e.ts‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
expect,
88
type Locator,
99
type Page,
10+
type Request,
1011
type Video,
1112
webkit,
1213
} from '@playwright/test'
@@ -213,6 +214,105 @@ try {
213214
} finally {
214215
await noScript.close()
215216
}
217+
const prefetchContext = await browser.newContext({
218+
viewport: { width: 390, height: 844 },
219+
reducedMotion: 'reduce',
220+
})
221+
try {
222+
await check(
223+
'In-flight intent prefetch transfers a large reference body only once',
224+
async () => {
225+
const page = await prefetchContext.newPage()
226+
const destination = '/integrations/cloudflare'
227+
const requests: Array<{ request: Request; beforeClick: boolean }> = []
228+
const finished = new Set<Request>()
229+
const transfers: Array<{
230+
url: string
231+
beforeClick: boolean
232+
segment?: string
233+
prefetch?: string
234+
status: number
235+
encodedBytes: number
236+
}> = []
237+
let clicked = false
238+
let inFlightAtClick = false
239+
try {
240+
await open(page, '/integrations/apollo')
241+
await page.getByRole('button', { name: 'Menu', exact: true }).click()
242+
const link = page
243+
.getByRole('navigation', { name: 'Documentation sidebar', exact: true })
244+
.getByRole('link', { name: 'Cloudflare', exact: true })
245+
await link.scrollIntoViewIfNeeded()
246+
const session = await prefetchContext.newCDPSession(page)
247+
await session.send('Network.enable')
248+
await session.send('Network.emulateNetworkConditions', {
249+
offline: false,
250+
latency: 150,
251+
downloadThroughput: 100 * 1024,
252+
uploadThroughput: 100 * 1024,
253+
})
254+
const isDestination = (request: Request) =>
255+
new URL(request.url()).pathname === destination && request.headers().rsc === '1'
256+
page.on('request', (request) => {
257+
if (isDestination(request)) requests.push({ request, beforeClick: !clicked })
258+
})
259+
page.on('requestfinished', (request) => finished.add(request))
260+
const prefetched = page.waitForRequest((request) => {
261+
if (!isDestination(request)) return false
262+
const segment = request.headers()['next-router-segment-prefetch']
263+
return !segment || segment.endsWith('/__PAGE__')
264+
})
265+
await link.focus()
266+
const pending = await prefetched
267+
inFlightAtClick = !finished.has(pending)
268+
expect(inFlightAtClick, 'The regression must navigate during a real body transfer').toBe(
269+
true
270+
)
271+
clicked = true
272+
await link.click()
273+
await expect(
274+
page.getByRole('heading', { level: 1, name: 'Cloudflare', exact: true })
275+
).toBeVisible({ timeout: 15000 })
276+
await expect(page).toHaveURL(new URL(destination, target).href)
277+
await expect(page.locator('main article')).not.toBeEmpty()
278+
for (const { request, beforeClick } of requests) {
279+
const response = await request.response()
280+
if (!response)
281+
throw new Error(`Destination request failed: ${request.failure()?.errorText}`)
282+
const failure = await response.finished()
283+
if (failure) throw failure
284+
const sizes = await request.sizes()
285+
transfers.push({
286+
url: request.url(),
287+
beforeClick,
288+
segment: request.headers()['next-router-segment-prefetch'],
289+
prefetch: request.headers()['next-router-prefetch'],
290+
status: response.status(),
291+
encodedBytes: sizes.responseBodySize,
292+
})
293+
expect(response.ok()).toBe(true)
294+
}
295+
const bodies = transfers.filter((transfer) => transfer.encodedBytes > 64 * 1024)
296+
expect(
297+
bodies,
298+
'Navigation must reuse the large in-flight prefetched response'
299+
).toHaveLength(1)
300+
expect(bodies[0]?.beforeClick, 'The large response must originate from intent').toBe(true)
301+
} finally {
302+
await writeFile(
303+
join(directory, 'in-flight-prefetch.json'),
304+
`${JSON.stringify({ source: '/integrations/apollo', destination, latencyMs: 150, bytesPerSecond: 100 * 1024, inFlightAtClick, transfers }, null, 2)}\n`
305+
)
306+
await page.close()
307+
}
308+
},
309+
engine === 'webkit'
310+
? 'Controlled network throttling uses Chromium CDP; Playwright WebKit cannot exercise this transport regression.'
311+
: undefined
312+
)
313+
} finally {
314+
await prefetchContext.close()
315+
}
216316
const sidebarContext = await browser.newContext({
217317
viewport: { width: 1440, height: 640 },
218318
reducedMotion: 'reduce',
@@ -1308,7 +1408,7 @@ try {
13081408
await browser?.close()
13091409
await writeFile(
13101410
reportPath,
1311-
`${JSON.stringify({ origin: target.origin, engine, limitations: engine === 'webkit' ? ['Clipboard readback and reduced-transparency emulation remain Chromium-only; this is Playwright WebKit, not native Safari.'] : [], results }, null, 2)}\n`
1411+
`${JSON.stringify({ origin: target.origin, engine, limitations: engine === 'webkit' ? ['Clipboard readback, reduced-transparency emulation and controlled network throttling remain Chromium-only; this is Playwright WebKit, not native Safari.'] : [], results }, null, 2)}\n`
13121412
)
13131413
}
13141414
logger.info('Verified documentation navigation', { checks: results.length, reportPath })

0 commit comments

Comments
 (0)