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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isPrefetchTaskDirty,
type PrefetchTask,
type PrefetchSubtaskResult,
startRevalidationCooldown,
} from './scheduler'
import { getAppBuildId } from '../../app-build-id'
import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
Expand Down Expand Up @@ -327,6 +328,9 @@ export function revalidateEntireCache(
) {
currentCacheVersion++

// Start a cooldown before re-prefetching to allow CDN cache propagation.
startRevalidationCooldown()

// Clearing the cache also effectively rejects any pending requests, because
// when the response is received, it gets written into a cache entry that is
// no longer reachable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,33 @@ let didScheduleMicrotask = false
// priority at a time. We reserve special network bandwidth for this task only.
let mostRecentlyHoveredLink: PrefetchTask | null = null

// CDN cache propagation delay after revalidation (in milliseconds)
const REVALIDATION_COOLDOWN_MS = 300

// Timeout handle for the revalidation cooldown. When non-null, prefetch
// requests are blocked to allow CDN cache propagation.
let revalidationCooldownTimeoutHandle: ReturnType<typeof setTimeout> | null =
null

/**
* Called by the cache when revalidation occurs. Starts a cooldown period
* during which prefetch requests are blocked to allow CDN cache propagation.
*/
export function startRevalidationCooldown(): void {
// Clear any existing timeout in case multiple revalidations happen
// in quick succession.
if (revalidationCooldownTimeoutHandle !== null) {
clearTimeout(revalidationCooldownTimeoutHandle)
}

// Schedule the cooldown to expire after the delay.
revalidationCooldownTimeoutHandle = setTimeout(() => {
revalidationCooldownTimeoutHandle = null
// Retry the prefetch queue now that the cooldown has expired.
ensureWorkIsScheduled()
}, REVALIDATION_COOLDOWN_MS)
}

export type IncludeDynamicData = null | 'full' | 'dynamic'

/**
Expand Down Expand Up @@ -348,8 +375,19 @@ function ensureWorkIsScheduled() {
* to avoid saturating the browser's internal network queue. This is a
* cooperative limit — prefetch tasks should check this before issuing
* new requests.
*
* Also checks if we're within the revalidation cooldown window, during which
* prefetch requests are delayed to allow CDN cache propagation.
*/
function hasNetworkBandwidth(task: PrefetchTask): boolean {
// Check if we're within the revalidation cooldown window
if (revalidationCooldownTimeoutHandle !== null) {
// We're within the cooldown window. Return false to prevent prefetching.
// When the cooldown expires, the timeout will call ensureWorkIsScheduled()
// to retry the queue.
return false
}

// TODO: Also check if there's an in-progress navigation. We should never
// add prefetch requests to the network queue if an actual navigation is
// taking place, to ensure there's sufficient bandwidth for render-blocking
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,54 @@ describe('segment cache (revalidation)', () => {
'no-requests'
)
})

it('delay re-prefetch after revalidation to allow CDN propagation', async () => {
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})

const linkVisibilityToggle = await browser.elementByCss(
'input[data-link-accordion="/greeting"]'
)

// Reveal the link the target page to trigger a prefetch
await act(
async () => {
await linkVisibilityToggle.click()
},
{
includes: 'random-greeting',
}
)

// Perform an action that calls revalidatePath. This triggers a 300ms
// cooldown before any new prefetch requests can be made.
const revalidateByPath = await browser.elementById('revalidate-by-path')
await revalidateByPath.click()

// Immediately after revalidation, no prefetch should have occurred yet
await new Promise((resolve) => setTimeout(resolve, 50))
TestLog.assert([])

// Halfway through cooldown (150ms), still no prefetch
await new Promise((resolve) => setTimeout(resolve, 100))
TestLog.assert([])

// After cooldown expires (300ms + buffer), prefetch should have occurred
await new Promise((resolve) => setTimeout(resolve, 200))
TestLog.assert(['REQUEST: random-greeting'])

// Navigate to the target page.
await act(async () => {
const link = await browser.elementByCss('a[href="/greeting"]')
await link.click()
// Navigation should finish immediately because the page is
// fully prefetched.
const greeting = await browser.elementById('greeting')
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
}, 'no-requests')
})
})
27 changes: 26 additions & 1 deletion test/lib/router-act.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,32 @@ export function createRouterAct(
const remaining = new Set<PendingRSCRequest>()
let actualResponses: Array<ExpectedResponseConfig> = []
let alreadyMatched = new Map<string, string>()
while (batch.pendingRequests.size > 0) {

// Track when the queue was last empty to implement a settling period
let queueEmptyStartTime: number | null = null
const SETTLING_PERIOD_MS = 500 // Wait 500ms after queue empties

while (
batch.pendingRequests.size > 0 ||
queueEmptyStartTime === null ||
Date.now() - queueEmptyStartTime < SETTLING_PERIOD_MS
) {
if (batch.pendingRequests.size > 0) {
// Queue has requests, reset settling timer
queueEmptyStartTime = null
} else if (queueEmptyStartTime === null) {
// Queue just became empty, start settling timer
queueEmptyStartTime = Date.now()
}

if (batch.pendingRequests.size === 0) {
// Queue is empty during settling period, wait a bit and check again
await new Promise((resolve) => setTimeout(resolve, 50))
await waitForIdleCallback()
await waitForPendingRequestChecks()
continue
}

const pending = batch.pendingRequests
batch.pendingRequests = new Set()
for (const item of pending) {
Expand Down
Loading