import { nextTestSetup } from 'e2e-utils' import { createRouterAct } from 'router-act' import type { CDPSession, Page, Request as PlaywrightRequest } from 'playwright' describe('segment cache memory pressure', () => { const { next, isNextDev } = nextTestSetup({ files: __dirname, }) if (isNextDev) { test('disabled in development', () => {}) return } it('evicts least recently used prefetch data once cache size exceeds limit', async () => { let act: ReturnType const browser = await next.browser('/memory-pressure', { beforePageLoad(page) { act = createRouterAct(page) }, }) const switchToTab1 = await browser.elementByCss( 'input[type="radio"][value="1"]' ) const switchToTab2 = await browser.elementByCss( 'input[type="radio"][value="2"]' ) // Switch to tab 1 to kick off a prefetch for a link to Page 0. await act( async () => { await switchToTab1.click() }, { includes: 'Page 0.' } ) // Switching to tab 2 causes the cache to overflow, evicting the prefetch // for the Page 0 link. await act( async () => { await switchToTab2.click() }, { includes: 'Page 1.' } ) // Switching back to tab 1 initiates a new prefetch for Page 0. If // there are no requests, that means the prefetch was not evicted correctly. await act( async () => { await switchToTab1.click() }, { includes: 'Page 0.', } ) // Switching back to tab 2 should not evict and re-fetch the prefetches for // Page 0 and Page 1, since they were recently accessed. await act(async () => { await switchToTab2.click() }, [ { includes: 'Page 0.', block: 'reject' }, { includes: 'Page 1.', block: 'reject' }, ]) }) it('does not leak memory when repeatedly triggering prefetches', async () => { let cdpSession: CDPSession let page: Page const browser = await next.browser('/memory-pressure', { async beforePageLoad(p: Page) { page = p cdpSession = await page.context().newCDPSession(page) await cdpSession.send('HeapProfiler.enable') }, }) const tab0Radio = await browser.elementByCss( 'input[type="radio"][value="0"]' ) const tab2Radio = await browser.elementByCss( 'input[type="radio"][value="2"]' ) const totalCycles = 10 const measurements: number[] = [] for (let i = 0; i < totalCycles; i++) { // Switch to Tab 2 (links mount, prefetches are triggered) const prefetchesSettled = waitForPrefetchesToSettle(page) await tab2Radio.click() await browser.waitForElementByCss('#tab-content a') await prefetchesSettled // Switch back to Tab 0 (links unmount) await tab0Radio.click() await browser.waitForElementByCss('#tab-0-content') measurements.push(await getHeapMB(cdpSession)) } // Use linear regression on measurements (skipping the first as warmup) // to compute the slope (MB/cycle). If evicted prefetch entries are not // properly garbage-collected, the heap will grow steadily across cycles // instead of plateauing. const afterWarmup = measurements.slice(1) const n = afterWarmup.length let sumX = 0 let sumY = 0 let sumXY = 0 let sumXX = 0 for (let j = 0; j < n; j++) { sumX += j sumY += afterWarmup[j] sumXY += j * afterWarmup[j] sumXX += j * j } const growthPerCycle = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) try { expect(growthPerCycle).toBeLessThan(0.1) } catch (e) { throw new Error( `Heap grew ${growthPerCycle.toFixed(3)} MB/cycle.\n` + `Measurements (MB): ${measurements.map((m) => m.toFixed(2)).join(', ')}` ) } }, 120_000) }) async function getHeapMB(cdpSession: CDPSession): Promise { await cdpSession.send('HeapProfiler.collectGarbage') await cdpSession.send('HeapProfiler.collectGarbage') const { usedSize } = await cdpSession.send('Runtime.getHeapUsage') return usedSize / 1024 / 1024 } /** * Returns a promise that resolves once all in-flight prefetch requests have * received responses and no new ones have been initiated for `quietMs`. Must be * called *before* the action that triggers prefetches so that no requests are * missed. */ function waitForPrefetchesToSettle(page: Page, quietMs = 200): Promise { let inFlight = 0 let resolve: () => void let timer: ReturnType | null = null const promise = new Promise((r) => { resolve = r }) function done() { page.off('request', onRequest) page.off('requestfinished', onRequestDone) page.off('requestfailed', onRequestDone) resolve() } function check() { if (inFlight === 0) { if (timer) { clearTimeout(timer) } timer = setTimeout(done, quietMs) } } function onRequest(req: PlaywrightRequest) { if (req.headers()['next-router-segment-prefetch']) { inFlight++ if (timer) { clearTimeout(timer) } } } function onRequestDone(req: PlaywrightRequest) { if (req.headers()['next-router-segment-prefetch']) { inFlight-- check() } } page.on('request', onRequest) page.on('requestfinished', onRequestDone) page.on('requestfailed', onRequestDone) return promise }