Skip to content

Commit

Permalink
Update to not trigger revalidation during prefetch (#37201)
Browse files Browse the repository at this point in the history
Continuation of #34498 this updates to send a `purpose: prefetch` header (related [w3c discussion](w3c/resource-hints#74)) when prefetching data routes and then on the server we skip revalidating when this header is set. 

When a client-transition is actually made we send a background HEAD (non-blocking) request to the data route without the `purpose: prefetch` header which signals a revalidation should occur if the data is stale.  

This helps alleviate the number of revalidations occurring currently caused by prefetches as a path can be prefetched but not visited during a session so may not need to be revalidated yet. 

Fixes: #17758
x-ref: #20521
x-ref: [slack thread](https://vercel.slack.com/archives/C031QM96T0A/p1645129445819139?thread_ts=1645124478.067049&cid=C031QM96T0A)
  • Loading branch information
ijjk authored May 29, 2022
1 parent db805ce commit 923fdcd
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 32 deletions.
1 change: 1 addition & 0 deletions packages/next/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
},
{
isManualRevalidate,
isPrefetch: req.headers.purpose === 'prefetch',
}
)

Expand Down
3 changes: 2 additions & 1 deletion packages/next/server/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default class ResponseCache {
responseGenerator: ResponseGenerator,
context: {
isManualRevalidate?: boolean
isPrefetch?: boolean
}
): Promise<ResponseCacheEntry | null> {
// ensure manual revalidate doesn't block normal requests
Expand Down Expand Up @@ -175,7 +176,7 @@ export default class ResponseCache {
}
: cachedResponse.value,
})
if (!cachedResponse.isStale) {
if (!cachedResponse.isStale || context.isPrefetch) {
// The cached value is still valid, so we don't need
// to update it yet.
return
Expand Down
85 changes: 55 additions & 30 deletions packages/next/shared/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ const SSG_DATA_NOT_FOUND = Symbol('SSG_DATA_NOT_FOUND')
function fetchRetry(
url: string,
attempts: number,
opts: { text?: boolean }
opts: { text?: boolean; isPrefetch?: boolean; method?: string }
): Promise<any> {
return fetch(url, {
// Cookies are required to be present for Next.js' SSG "Preview Mode".
Expand All @@ -548,6 +548,12 @@ function fetchRetry(
// > option instead of relying on the default.
// https://github.com/github/fetch#caveats
credentials: 'same-origin',
method: opts.method || 'GET',
headers: opts.isPrefetch
? {
purpose: 'prefetch',
}
: {},
}).then((res) => {
if (!res.ok) {
if (attempts > 1 && res.status >= 500) {
Expand All @@ -563,47 +569,64 @@ function fetchRetry(
}
throw new Error(`Failed to load static props`)
}
return opts.text ? res.text() : res.json()

if (opts.method !== 'HEAD') {
return opts.text ? res.text() : res.json()
}
})
}

const backgroundCache: Record<string, Promise<any>> = {}

function fetchNextData(
dataHref: string,
isServerRender: boolean,
text: boolean | undefined,
inflightCache: NextDataCache,
persistCache: boolean
persistCache: boolean,
isPrefetch: boolean
) {
const { href: cacheKey } = new URL(dataHref, window.location.href)
const getData = (background = false) =>
fetchRetry(dataHref, isServerRender ? 3 : 1, {
text,
isPrefetch,
method: background ? 'HEAD' : 'GET',
})
.catch((err: Error) => {
// We should only trigger a server-side transition if this was caused
// on a client-side transition. Otherwise, we'd get into an infinite
// loop.

if (!isServerRender) {
markAssetError(err)
}
throw err
})
.then((data) => {
if (!persistCache || process.env.NODE_ENV !== 'production') {
delete inflightCache[cacheKey]
}
return data
})
.catch((err) => {
delete inflightCache[cacheKey]
throw err
})

if (inflightCache[cacheKey] !== undefined) {
// we kick off a HEAD request in the background
// when a non-prefetch request is made to signal revalidation
if (!isPrefetch && persistCache && !backgroundCache[cacheKey]) {
backgroundCache[cacheKey] = getData(true)
.catch(() => {})
.then(() => {
delete backgroundCache[cacheKey]
})
}
return inflightCache[cacheKey]
}
return (inflightCache[cacheKey] = fetchRetry(
dataHref,
isServerRender ? 3 : 1,
{ text }
)
.catch((err: Error) => {
// We should only trigger a server-side transition if this was caused
// on a client-side transition. Otherwise, we'd get into an infinite
// loop.

if (!isServerRender) {
markAssetError(err)
}
throw err
})
.then((data) => {
if (!persistCache || process.env.NODE_ENV !== 'production') {
delete inflightCache[cacheKey]
}
return data
})
.catch((err) => {
delete inflightCache[cacheKey]
throw err
}))
return (inflightCache[cacheKey] = getData())
}

interface NextDataCache {
Expand Down Expand Up @@ -1626,7 +1649,8 @@ export default class Router implements BaseRouter {
this.isSsr,
false,
__N_SSG ? this.sdc : this.sdr,
!!__N_SSG && !isPreview
!!__N_SSG && !isPreview,
false
)
: this.getInitialProps(
Component,
Expand Down Expand Up @@ -1860,6 +1884,7 @@ export default class Router implements BaseRouter {
false,
false, // text
this.sdc,
!this.isPreview,
true
)
: false
Expand Down Expand Up @@ -1924,7 +1949,7 @@ export default class Router implements BaseRouter {

_getFlightData(dataHref: string): Promise<object> {
// Do not cache RSC flight response since it's not a static resource
return fetchNextData(dataHref, true, true, this.sdc, false).then(
return fetchNextData(dataHref, true, true, this.sdc, false, false).then(
(serialized) => {
return { data: serialized }
}
Expand Down
2 changes: 1 addition & 1 deletion test/integration/preload-viewport/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('Prefetching Links in viewport', () => {
expect(
nextDataRequests.filter((reqUrl) => reqUrl.includes('/ssg/slow.json'))
.length
).toBe(1)
).toBe(2)
})

it('should handle timed out prefetch correctly', async () => {
Expand Down
26 changes: 26 additions & 0 deletions test/production/prerender-prefetch/app/pages/blog/[slug].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export default function Page(props) {
return (
<>
<p id="page">blog/[slug]</p>
<p id="props">{JSON.stringify(props)}</p>
</>
)
}

export function getStaticProps({ params }) {
console.log('revalidating /blog', params.slug)
return {
props: {
params,
now: Date.now(),
},
revalidate: 2,
}
}

export function getStaticPaths() {
return {
paths: ['/blog/first', '/blog/second'],
fallback: false,
}
}
28 changes: 28 additions & 0 deletions test/production/prerender-prefetch/app/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Link from 'next/link'

export default function Page(props) {
return (
<>
<p id="page">index</p>
<p id="props">{JSON.stringify(props)}</p>
<Link href="/blog/first">
<a id="to-blog-first">/blog/first</a>
</Link>
<br />
<Link href="/blog/second">
<a id="to-blog-second">/blog/second</a>
</Link>
<br />
</>
)
}

export function getStaticProps() {
console.log('revalidating /')
return {
props: {
now: Date.now(),
},
revalidate: 1,
}
}
91 changes: 91 additions & 0 deletions test/production/prerender-prefetch/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NextInstance } from 'test/lib/next-modes/base'
import { createNext, FileRef } from 'e2e-utils'
import { check, fetchViaHTTP, waitFor } from 'next-test-utils'
import cheerio from 'cheerio'
import { join } from 'path'
import webdriver from 'next-webdriver'
import assert from 'assert'

describe('Prerender prefetch', () => {
let next: NextInstance

beforeAll(async () => {
next = await createNext({
files: {
pages: new FileRef(join(__dirname, 'app/pages')),
},
dependencies: {},
})
})
afterAll(() => next.destroy())

it('should not revalidate during prefetching', async () => {
const reqs = {}

// get initial values
for (const path of ['/blog/first', '/blog/second']) {
const res = await fetchViaHTTP(next.url, path)
expect(res.status).toBe(200)

const $ = cheerio.load(await res.text())
const props = JSON.parse($('#props').text())
reqs[path] = props
}

const browser = await webdriver(next.url, '/')

// wait for prefetch to occur
await check(async () => {
const cache = await browser.eval('JSON.stringify(window.next.router.sdc)')
return cache.includes('/blog/first') && cache.includes('/blog/second')
? 'success'
: cache
}, 'success')

await waitFor(3000)
await browser.refresh()

// reload after revalidate period and wait for prefetch again
await check(async () => {
const cache = await browser.eval('JSON.stringify(window.next.router.sdc)')
return cache.includes('/blog/first') && cache.includes('/blog/second')
? 'success'
: cache
}, 'success')

// ensure revalidate did not occur from prefetch
for (const path of ['/blog/first', '/blog/second']) {
const res = await fetchViaHTTP(next.url, path)
expect(res.status).toBe(200)

const $ = cheerio.load(await res.text())
const props = JSON.parse($('#props').text())
expect(props).toEqual(reqs[path])
}
})

it('should trigger revalidation after navigation', async () => {
const getData = () =>
fetchViaHTTP(
next.url,
`/_next/data/${next.buildId}/blog/first.json`,
undefined,
{
headers: {
purpose: 'prefetch',
},
}
)
const initialDataRes = await getData()
const initialData = await initialDataRes.json()
const browser = await webdriver(next.url, '/')

await browser.elementByCss('#to-blog-first').click()

await check(async () => {
const data = await getData()
assert.notDeepEqual(initialData, data)
return 'success'
}, 'success')
})
})

0 comments on commit 923fdcd

Please sign in to comment.