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

Add api-utils helper for testing #34078

Merged
merged 1 commit into from Feb 8, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/next/build/index.ts
Expand Up @@ -550,7 +550,14 @@ export default async function build(
.traceChild('generate-required-server-files')
.traceFn(() => ({
version: 1,
config: { ...config, configFile: undefined },
config: {
...config,
configFile: undefined,
experimental: {
...config.experimental,
trustHostHeader: ciEnvironment.hasNextSupport,
},
},
appDir: dir,
files: [
ROUTES_MANIFEST,
Expand Down
44 changes: 43 additions & 1 deletion packages/next/server/api-utils.ts
Expand Up @@ -26,7 +26,11 @@ export async function apiResolver(
res: ServerResponse,
query: any,
resolverModule: any,
apiContext: __ApiPreviewProps,
apiContext: __ApiPreviewProps & {
trustHostHeader?: boolean
hostname?: string
port?: number
},
propagateError: boolean,
dev?: boolean,
page?: string
Expand Down Expand Up @@ -95,6 +99,8 @@ export async function apiResolver(
apiRes.setPreviewData = (data, options = {}) =>
setPreviewData(apiRes, data, Object.assign({}, apiContext, options))
apiRes.clearPreviewData = () => clearPreviewData(apiRes)
apiRes.unstable_revalidate = (urlPath: string) =>
unstable_revalidate(urlPath, req, apiContext)

const resolver = interopDefault(resolverModule)
let wasPiped = false
Expand Down Expand Up @@ -334,6 +340,42 @@ export function sendJson(res: NextApiResponse, jsonBody: any): void {
res.send(jsonBody)
}

const PRERENDER_REVALIDATE_HEADER = 'x-prerender-revalidate'

export function checkIsManualRevalidate(
req: IncomingMessage | BaseNextRequest,
previewProps: __ApiPreviewProps
): boolean {
return req.headers[PRERENDER_REVALIDATE_HEADER] === previewProps.previewModeId
}

async function unstable_revalidate(
urlPath: string,
req: IncomingMessage | BaseNextRequest,
context: {
hostname?: string
port?: number
previewModeId: string
trustHostHeader?: boolean
}
) {
if (!context.trustHostHeader && (!context.hostname || !context.port)) {
throw new Error(
`"hostname" and "port" must be provided when starting next to use "unstable_revalidate". See more here https://nextjs.org/docs/advanced-features/custom-server`
)
}

const baseUrl = context.trustHostHeader
? `https://${req.headers.host}`
: `http://${context.hostname}:${context.port}`

return fetch(`${baseUrl}${urlPath}`, {
headers: {
[PRERENDER_REVALIDATE_HEADER]: context.previewModeId,
},
})
}

const COOKIE_NAME_PRERENDER_BYPASS = `__prerender_bypass`
const COOKIE_NAME_PRERENDER_DATA = `__next_preview_data`

Expand Down
23 changes: 21 additions & 2 deletions packages/next/server/base-server.ts
Expand Up @@ -58,6 +58,7 @@ import { MIDDLEWARE_ROUTE } from '../lib/constants'
import { addRequestMeta, getRequestMeta } from './request-meta'
import { createHeaderRoute, createRedirectRoute } from './server-route-utils'
import { PrerenderManifest } from '../build'
import { checkIsManualRevalidate } from '../server/api-utils'

export type FindComponentsResult = {
components: LoadComponentsReturnType
Expand Down Expand Up @@ -1165,6 +1166,15 @@ export default abstract class Server {
isPreviewMode = previewData !== false
}

let isManualRevalidate = false

if (isSSG) {
isManualRevalidate = checkIsManualRevalidate(
req,
this.renderOpts.previewProps
)
}

// Compute the iSSG cache key. We use the rewroteUrl since
// pages with fallback: false are allowed to be rewritten to
// and we need to look up the path by the rewritten path
Expand Down Expand Up @@ -1230,7 +1240,7 @@ export default abstract class Server {

let ssgCacheKey =
isPreviewMode || !isSSG || this.minimalMode || opts.supportsDynamicHTML
? null // Preview mode bypasses the cache
? null // Preview mode and manual revalidate bypasses the cache
: `${locale ? `/${locale}` : ''}${
(pathname === '/' || resolvedUrlPathname === '/') && locale
? ''
Expand Down Expand Up @@ -1356,7 +1366,7 @@ export default abstract class Server {

const cacheEntry = await this.responseCache.get(
ssgCacheKey,
async (hasResolved) => {
async (hasResolved, hadCache) => {
const isProduction = !this.renderOpts.dev
const isDynamicPathname = isDynamicRoute(pathname)
const didRespond = hasResolved || res.sent
Expand All @@ -1372,6 +1382,12 @@ export default abstract class Server {
fallbackMode = 'blocking'
}

// only allow manual revalidate for fallback: true/blocking
// or for prerendered fallback: false paths
if (isManualRevalidate && (fallbackMode !== false || hadCache)) {
fallbackMode = 'blocking'
}

// When we did not respond from cache, we need to choose to block on
// rendering or return a skeleton.
//
Expand Down Expand Up @@ -1456,6 +1472,9 @@ export default abstract class Server {
? result.revalidate
: /* default to minimum revalidate (this should be an invariant) */ 1,
}
},
{
isManualRevalidate,
}
)

Expand Down
8 changes: 7 additions & 1 deletion packages/next/server/next-server.ts
Expand Up @@ -451,7 +451,13 @@ export default class NextNodeServer extends BaseServer {
res.originalResponse,
query,
pageModule,
this.renderOpts.previewProps,
{
...this.renderOpts.previewProps,
port: this.port,
hostname: this.hostname,
// internal config so is not typed
trustHostHeader: (this.nextConfig.experimental as any).trustHostHeader,
},
this.minimalMode,
this.renderOpts.dev,
page
Expand Down
14 changes: 10 additions & 4 deletions packages/next/server/response-cache.ts
Expand Up @@ -20,7 +20,8 @@ export type ResponseCacheEntry = {
}

type ResponseGenerator = (
hasResolved: boolean
hasResolved: boolean,
hadCache: boolean
) => Promise<ResponseCacheEntry | null>

export default class ResponseCache {
Expand All @@ -34,7 +35,8 @@ export default class ResponseCache {

public get(
key: string | null,
responseGenerator: ResponseGenerator
responseGenerator: ResponseGenerator,
context: { isManualRevalidate?: boolean }
): Promise<ResponseCacheEntry | null> {
const pendingResponse = key ? this.pendingResponses.get(key) : null
if (pendingResponse) {
Expand Down Expand Up @@ -71,7 +73,11 @@ export default class ResponseCache {
;(async () => {
try {
const cachedResponse = key ? await this.incrementalCache.get(key) : null
if (cachedResponse) {
if (
cachedResponse &&
(!context.isManualRevalidate ||
cachedResponse.revalidateAfter === false)
) {
resolve({
revalidate: cachedResponse.curRevalidate,
value:
Expand All @@ -90,7 +96,7 @@ export default class ResponseCache {
}
}

const cacheEntry = await responseGenerator(resolved)
const cacheEntry = await responseGenerator(resolved, !!cachedResponse)
resolve(cacheEntry)

if (key && cacheEntry && typeof cacheEntry.revalidate !== 'undefined') {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/shared/lib/utils.ts
Expand Up @@ -293,6 +293,8 @@ export type NextApiResponse<T = any> = ServerResponse & {
}
) => NextApiResponse<T>
clearPreviewData: () => NextApiResponse<T>

unstable_revalidate: (urlPath: string) => Promise<Response>
}

/**
Expand Down
141 changes: 141 additions & 0 deletions test/e2e/prerender.test.ts
Expand Up @@ -1890,6 +1890,147 @@ describe('Prerender', () => {
}
})
}

if (!(global as any).isNextDev) {
it('should handle manual revalidate for fallback: blocking', async () => {
const html = await renderViaHTTP(
next.url,
'/blocking-fallback/test-manual-1'
)
const $ = cheerio.load(html)
const initialTime = $('#time').text()

expect($('p').text()).toMatch(/Post:.*?test-manual-1/)

const html2 = await renderViaHTTP(
next.url,
'/blocking-fallback/test-manual-1'
)
const $2 = cheerio.load(html2)

expect(initialTime).toBe($2('#time').text())

const res = await fetchViaHTTP(
next.url,
'/api/manual-revalidate',
{
pathname: '/blocking-fallback/test-manual-1',
},
{ redirect: 'manual' }
)

expect(res.status).toBe(200)
const revalidateData = await res.json()
const revalidatedText = revalidateData.text
const $3 = cheerio.load(revalidatedText)
expect(revalidateData.status).toBe(200)
expect($3('#time').text()).not.toBe(initialTime)

const html4 = await renderViaHTTP(
next.url,
'/blocking-fallback/test-manual-1'
)
const $4 = cheerio.load(html4)
expect($4('#time').text()).not.toBe(initialTime)
expect($3('#time').text()).toBe($4('#time').text())
})

it('should not manual revalidate for revalidate: false', async () => {
const html = await renderViaHTTP(
next.url,
'/blocking-fallback-once/test-manual-1'
)
const $ = cheerio.load(html)
const initialTime = $('#time').text()

expect($('p').text()).toMatch(/Post:.*?test-manual-1/)

const html2 = await renderViaHTTP(
next.url,
'/blocking-fallback-once/test-manual-1'
)
const $2 = cheerio.load(html2)

expect(initialTime).toBe($2('#time').text())

const res = await fetchViaHTTP(
next.url,
'/api/manual-revalidate',
{
pathname: '/blocking-fallback-once/test-manual-1',
},
{ redirect: 'manual' }
)

expect(res.status).toBe(200)
const revalidateData = await res.json()
const revalidatedText = revalidateData.text
const $3 = cheerio.load(revalidatedText)
expect(revalidateData.status).toBe(200)
expect($3('#time').text()).toBe(initialTime)

const html4 = await renderViaHTTP(
next.url,
'/blocking-fallback-once/test-manual-1'
)
const $4 = cheerio.load(html4)
expect($4('#time').text()).toBe(initialTime)
expect($3('#time').text()).toBe($4('#time').text())
})

it('should handle manual revalidate for fallback: false', async () => {
const res = await fetchViaHTTP(
next.url,
'/catchall-explicit/test-manual-1'
)
expect(res.status).toBe(404)

// fallback: false pages should only manually revalidate
// prerendered paths
const res2 = await fetchViaHTTP(
next.url,
'/api/manual-revalidate',
{
pathname: '/catchall-explicity/test-manual-1',
},
{ redirect: 'manual' }
)

expect(res2.status).toBe(200)
const revalidateData = await res2.json()
expect(revalidateData.status).toBe(404)

const res3 = await fetchViaHTTP(
next.url,
'/catchall-explicit/test-manual-1'
)
expect(res3.status).toBe(404)

const res4 = await fetchViaHTTP(next.url, '/catchall-explicit/first')
expect(res4.status).toBe(200)
const html = await res4.text()
const $ = cheerio.load(html)
const initialTime = $('#time').text()

const res5 = await fetchViaHTTP(
next.url,
'/api/manual-revalidate',
{
pathname: '/catchall-explicit/first',
},
{ redirect: 'manual' }
)
expect(res5.status).toBe(200)
expect((await res5.json()).status).toBe(200)

const res6 = await fetchViaHTTP(next.url, '/catchall-explicit/first')
expect(res6.status).toBe(200)
const html2 = await res6.text()
const $2 = cheerio.load(html2)

expect(initialTime).not.toBe($2('#time').text())
})
}
}
runTests((global as any).isNextDev)
})
10 changes: 10 additions & 0 deletions test/e2e/prerender/pages/api/manual-revalidate.js
@@ -0,0 +1,10 @@
export default async function handler(req, res) {
// WARNING: don't use user input in production
// make sure to use trusted value for revalidating
const revalidateRes = await res.unstable_revalidate(req.query.pathname)
res.json({
revalidated: true,
status: revalidateRes.status,
text: await revalidateRes.text(),
})
}
Expand Up @@ -32,7 +32,7 @@ export default ({ post, time, params }) => {
return (
<>
<p>Post: {post}</p>
<span>time: {time}</span>
<span id="time">time: {time}</span>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/prerender/pages/blocking-fallback/[slug].js
Expand Up @@ -32,7 +32,7 @@ export default ({ post, time, params }) => {
return (
<>
<p>Post: {post}</p>
<span>time: {time}</span>
<span id="time">time: {time}</span>
<div id="params">{JSON.stringify(params)}</div>
<div id="query">{JSON.stringify(useRouter().query)}</div>
<Link href="/">
Expand Down