Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 206 additions & 46 deletions packages/next/src/client/dev/debug-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,44 +46,141 @@ function persistDebugChannelToSessionStorage(requestId: string): void {
}
}

function wasServedFromCache(): boolean {
try {
// There is exactly one PerformanceNavigationTiming entry per page load.
const entry = performance.getEntriesByType('navigation')[0]
const enum ExecTimeCacheDecision {
/**
* The HTML document was served from the browser's cache; replay the
* previously persisted chunks instead of waiting for the WebSocket-backed
* channel.
*/
CacheRestore,

if (!entry) {
return false
}
/**
* The HTML document came fresh from the server. The live WebSocket-backed
* channel will deliver the debug chunks.
*/
FreshResponse,

// HTTP cache restore detection isn't uniform across browsers, so we combine
// two signals:
//
// 1. type === 'back_forward' — set on browser-history navigations
// (back/forward) in all three browsers, and on tab duplication in
// Chrome and Firefox. This only matters when scripts actually
// re-execute; a bfcache restore preserves the entire JS context and
// never reaches this code. The HMR WebSocket disqualifies bfcache in
// Chrome and Firefox, so back/forward falls back to an HTTP cache
// restore and we land here with this type set. Safari is more lenient
// and often still uses bfcache for back/forward despite the WebSocket,
// in which case this function isn't called and no recovery is needed.
// 2. responseStart === 0 && responseEnd > 0 — Safari uses type='navigate'
// on tab duplication. It sets responseStart to 0 when no
// first-body-byte arrived over the network; fresh loads always have
// responseStart > 0.
//
// Neither fires on Firefox's fresh streaming load, where transferSize is
// transiently 0. That case has type='navigate' with a non-zero
// responseStart, so it correctly returns false and avoids a
// location.reload() loop that earlier (transferSize-only) versions of this
// check triggered.
return (
entry.type === 'back_forward' ||
(entry.responseStart === 0 && entry.responseEnd > 0)
)
} catch {
/**
* Can't tell from the navigation entry as it stands now. Caller should defer
* to `pageshow` and re-check there with `wasServedFromCacheAtPageshow`.
*/
Undecided,
}

/**
* Decide at script-execution time whether the document was served from the
* browser's cache or freshly fetched from the server. `type === 'back_forward'`
* alone isn't enough: a back/forward navigation can also be a fresh server
* re-fetch when the HTTP cache entry was evicted (long-lived tab, storage
* pressure, manual cache clear), and treating that as a cache restore would
* trigger an unnecessary `location.reload()` when no persisted chunks are
* found.
*/
function wasServedFromCacheKnownAtExec(
entry: NavigationEntry | undefined
): ExecTimeCacheDecision {
if (!entry) {
return ExecTimeCacheDecision.FreshResponse
}

// Safari tab-duplication cache restore: type='navigate' paired with
// responseStart=0 (no first-body-byte over the network) and a non-zero
// responseEnd. Fresh navigations always have responseStart > 0.
if (
entry.type === 'navigate' &&
entry.responseStart === 0 &&
entry.responseEnd > 0
) {
return ExecTimeCacheDecision.CacheRestore
}

// Every remaining cache-restore signal requires a back/forward navigation.
// (bfcache restores don't re-execute scripts and never reach this code.)
if (entry.type !== 'back_forward') {
return ExecTimeCacheDecision.FreshResponse
}

// Chrome ≥109 and Safari ≥17 populate `deliveryType` at exec time even when
// the size fields aren't filled in yet. This is the only exec-time fast path
// for real Safari ≥17 cache restores (Safari leaves encodedBodySize at 0 at
// exec).
if (entry.deliveryType === 'cache') {
return ExecTimeCacheDecision.CacheRestore
}

// Chrome and Firefox publish an HTTP cache restore as transferSize=0 (no
// bytes over the wire) plus a non-zero cached body size at exec time.
if (entry.transferSize === 0 && entry.encodedBodySize > 0) {
return ExecTimeCacheDecision.CacheRestore
}

// No body bytes measured yet. Either the response is still streaming, or
// WebKit is reporting transferSize=0 and encodedBodySize=0 at exec time
// regardless of whether the document was cached or re-fetched. Defer to
// `pageshow` where the two cases become distinguishable.
if (entry.encodedBodySize === 0) {
return ExecTimeCacheDecision.Undecided
}

// Body bytes already measured at exec time with no other cache signal: a
// re-fetched back-nav whose response happened to complete before our script
// ran. The deferred branch above would have caught the same case if the
// response had still been streaming.
return ExecTimeCacheDecision.FreshResponse
}

/**
* Re-check the cache-restore decision at `pageshow`, when every browser has
* populated the navigation-entry size fields. Only called when
* `wasServedFromCacheKnownAtExec` returned `ExecTimeCacheDecision.Undecided`.
*/
function wasServedFromCacheAtPageshow(
entry: NavigationEntry | undefined
): boolean {
if (!entry) {
return false
}

// Safari tab-duplication signature; see the matching branch in
// `wasServedFromCacheKnownAtExec`.
if (
entry.type === 'navigate' &&
entry.responseStart === 0 &&
entry.responseEnd > 0
) {
return true
}

// A back/forward navigation where at least one of the size fields is zero
// means the body didn't come over the wire. Browsers signal a cache restore
// differently — Chrome/Firefox zero `transferSize` and keep a non-zero cached
// `encodedBodySize`; Safari does the inverse with a small `transferSize`
// (header overhead) and `encodedBodySize=0`; WebKit under Playwright zeros
// both. A fresh re-fetch populates both with the response size.
return (
entry.type === 'back_forward' &&
(entry.transferSize === 0 || entry.encodedBodySize === 0)
)
}

/**
* The DOM lib's `PerformanceNavigationTiming` doesn't include the
* `deliveryType` property yet, even though it's shipped in Chrome ≥109,
* Firefox ≥115, and Safari ≥17. See
* https://w3c.github.io/navigation-timing/#dom-performancenavigationtiming-deliverytype.
*/
type NavigationEntry = PerformanceNavigationTiming & {
readonly deliveryType?: string
}

function getNavigationEntry(): NavigationEntry | undefined {
try {
return performance.getEntriesByType('navigation')[0] as
| NavigationEntry
| undefined
} catch {
return undefined
}
}

function restoreDebugChannelFromSessionStorage(
Expand Down Expand Up @@ -183,22 +280,85 @@ export function createDebugChannel(
// Only attempt to restore the sessionStorage debug channel entry for the
// initial document load (no request headers). Client-side navigations pass
// request headers and should always use the WebSocket-backed debug channel.
if (!requestHeaders && wasServedFromCache()) {
const readable = restoreDebugChannelFromSessionStorage(requestId)

if (readable) {
return { readable }
if (!requestHeaders) {
switch (wasServedFromCacheKnownAtExec(getNavigationEntry())) {
case ExecTimeCacheDecision.CacheRestore:
return { readable: restoreDebugChannelOrReload(requestId) }
case ExecTimeCacheDecision.Undecided:
// Body bytes haven't been measured on the navigation entry yet. Suspend
// the stream until pageshow, re-check there, then source from the
// persisted chunks or the WebSocket-backed pair accordingly.
return { readable: createDeferredDebugChannelReadable(requestId) }
case ExecTimeCacheDecision.FreshResponse:
// Fall through to the shared WebSocket-backed channel below.
break
}

// Debug channel can't be restored — debug deps would block hydration.
// Force a fresh page load from the server. Return a never-closing stream
// so the Flight client stays parked until the reload tears the document
// down, instead of synchronously erroring with "Connection closed.".
location.reload()
return { readable: new ReadableStream() }
}

const { readable } = getOrCreateDebugChannelReadableWriterPair(requestId)

return { readable }
}

/**
* Try to restore the debug channel from the persisted chunks. If none are
* found, force a fresh page load.
*/
function restoreDebugChannelOrReload(
requestId: string
): ReadableStream<Uint8Array> {
const readable = restoreDebugChannelFromSessionStorage(requestId)

if (readable) {
return readable
}

// No persisted entry. Typically this happens when the HTTP cache held the
// HTML but the persisted entry was never written, or was overwritten by a
// newer document in this tab.
location.reload()

// Never-closing stream. Keeps the Flight client suspended until the reload
// tears the document down, instead of letting it synchronously error with
// "Connection closed.".
return new ReadableStream<Uint8Array>()
}

/**
* Used when `wasServedFromCacheKnownAtExec` returns
* `ExecTimeCacheDecision.Undecided`. Waits for `pageshow`, re-runs the check,
* and forwards data from either the persisted chunks or the WebSocket.
*/
function createDeferredDebugChannelReadable(
requestId: string
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async start(controller) {
// By `pageshow` every browser has populated the navigation-entry size
// fields, so the re-check below is unambiguous.
await new Promise<void>((resolve) => {
window.addEventListener('pageshow', () => resolve(), { once: true })
})

const source = wasServedFromCacheAtPageshow(getNavigationEntry())
? restoreDebugChannelOrReload(requestId)
: getOrCreateDebugChannelReadableWriterPair(requestId).readable

const reader = source.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
return
}
controller.enqueue(value)
}
} catch (error) {
controller.error(error)
} finally {
reader.releaseLock()
}
},
})
}
85 changes: 77 additions & 8 deletions test/e2e/app-dir/bfcache-regression/bfcache-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { nextTestSetup } from 'e2e-utils'
import { assertNoConsoleErrors, retry } from 'next-test-utils'

describe('bfcache-regression', () => {
const { next, isTurbopack } = nextTestSetup({
const { next, isTurbopack, isNextDev } = nextTestSetup({
files: __dirname,
})

Expand Down Expand Up @@ -48,13 +48,13 @@ describe('bfcache-regression', () => {
})

// Regression test for an infinite refresh loop on the initial load of a
// streaming page. wasServedFromCache() in debug-channel.ts must not treat
// a still-in-flight streaming response as an HTTP cache restore, or it
// calls location.reload() and the next load hits the same condition. The
// bug only manifests in browsers where PerformanceNavigationTiming
// reports transferSize/encodedBodySize as 0 until the body finishes
// arriving — Firefox in practice. Chrome and Safari report non-zero
// values during streaming and aren't affected.
// streaming page. The cache-restore detection in debug-channel.ts must not
// treat a still-in-flight streaming response as an HTTP cache restore, or it
// triggers a location.reload() that lands in the same condition. Only
// manifests in browsers where PerformanceNavigationTiming reports
// transferSize/encodedBodySize as 0 until the body finishes arriving —
// Firefox in practice. Chrome and Safari populate those fields during
// streaming and aren't affected.
it('should not enter a refresh loop on initial load of a page with streaming dynamic content', async () => {
let loadCount = 0
const browser = await next.browser('/streaming', {
Expand Down Expand Up @@ -82,4 +82,73 @@ describe('bfcache-regression', () => {

await assertNoConsoleErrors(browser)
})

if (isNextDev && global.browserName === 'chrome') {
// Verifies the eviction edge case in the cache-restore detection. When the
// HTTP cache entry for the back-navigation target has been evicted between
// forward visit and back-press (long-lived tab, storage pressure, manual
// cache clear), the browser re-fetches the document fresh from the server.
// The debug-channel restore must NOT mistake that re-fetch for a cache
// restore and trigger a spurious location.reload() — the live
// WebSocket-backed channel already has the debug data for the fresh
// response.
//
// Chromium-only because clearing the browser cache via the test harness
// uses CDP, which Playwright only exposes for Chromium. The same exec-time
// code path is exercised by Safari whenever its navigation entry's size
// fields are still zero at script-execution time (the deferred-to-pageshow
// branch), but the harness can't deterministically force the eviction
// there.
it('should recover via the live debug channel when the back-navigation target was evicted from the HTTP cache', async () => {
const outputIndex = next.cliOutput.length
// Use /streaming as the back-nav target so the body is still streaming
// when our inline script reads PerformanceNavigationTiming — that forces
// the deferred branch (encodedBodySize === 0 at exec).
const browser = await next.browser('/streaming', {
pushErrorAsConsoleLog: true,
})

await retry(async () => {
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content'
)
})

// Navigate forward via the layout's MPA link (full page navigation, not a
// client-side transition).
await browser.elementByCss('a[href="/target-page"]').click()
expect(await (await browser.elementByCss('h2')).text()).toBe(
'Target Page'
)

// Simulate cache eviction by clearing the browser HTTP cache via CDP.
// With the cached body gone, the browser back-navigation falls back to a
// fresh server fetch instead of an HTTP cache restore.
await browser.clearBrowserCache()

await browser.back()

// The page should render the dynamic content without a spurious reload.
await retry(async () => {
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content'
)
})

// '/streaming' should have been requested exactly twice: the initial
// forward load and the back-navigation re-fetch. A third request
// would indicate that the debug-channel restore mistook the re-fetch
// for a cache restore and triggered a spurious location.reload().
const output = next.cliOutput.slice(outputIndex)
const counts: Record<string, number> = {}
for (const [, path] of output.matchAll(
/GET (\/(?:streaming|target-page)) /g
)) {
counts[path] = (counts[path] ?? 0) + 1
}
expect(counts).toEqual({ '/streaming': 2, '/target-page': 1 })

await assertNoConsoleErrors(browser)
})
}
})
15 changes: 15 additions & 0 deletions test/lib/browsers/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,21 @@ export class Playwright<TCurrent = undefined> {
await page.reload()
})
}
/**
* Evict the browser HTTP cache via CDP (`Network.clearBrowserCache`). This is
* only supported in Chromium; gate the calling test on `global.browserName
* === 'chrome'` (Playwright's Firefox and WebKit don't expose CDP).
*/
clearBrowserCache() {
return this.startChain(async () => {
const session = await context!.newCDPSession(page)
try {
await session.send('Network.clearBrowserCache')
} finally {
await session.detach()
}
})
}
setDimensions({ width, height }: { height: number; width: number }) {
return this.startOrPreserveChain(() =>
page.setViewportSize({ width, height })
Expand Down
Loading