Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to not trigger revalidation during prefetch #37201

Merged
merged 4 commits into from
May 29, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions packages/next/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,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 @@ -535,7 +535,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 @@ -550,6 +550,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 @@ -565,47 +571,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 @@ -1617,7 +1640,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 @@ -1851,6 +1875,7 @@ export default class Router implements BaseRouter {
false,
false, // text
this.sdc,
!this.isPreview,
true
)
: false
Expand Down Expand Up @@ -1915,7 +1940,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')
})
})