From 533c242094a08a41bd8cc89958c438998fbc27f0 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 17 Nov 2022 13:22:36 -0800 Subject: [PATCH] Add middleware prefetching config (#42936) This adds a new `experimental.middlewarePrefetch` config with two modes with the default being the `flexible` config. - `strict` only prefetches when the `href` explicitly matches an SSG route (won't prefetch for middleware rewrite usage unless manual `href`/`as` values are used) - `flexible` always prefetches ensuring middleware rewrite usage is handled and also prevents executing SSR routes during prefetch to avoid unexpected invocations x-ref: https://github.com/vercel/next.js/pull/39920 x-ref: [slack thread](https://vercel.slack.com/archives/C047HMFN58X/p1668473101696689?thread_ts=1667856323.709179&cid=C047HMFN58X) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/build/webpack-config.ts | 3 + packages/next/server/base-server.ts | 9 ++ packages/next/server/config-schema.ts | 5 + packages/next/server/config-shared.ts | 2 + packages/next/server/next-server.ts | 8 ++ packages/next/shared/lib/router/router.ts | 136 ++++++++++++++---- .../middleware-rewrites/app/pages/about.js | 4 +- .../middleware-rewrites/app/pages/index.js | 8 ++ .../app/pages/static-ssg/[slug].js | 16 +++ .../dynamic-routing/test/index.test.js | 54 +++++-- .../middleware-prefetch/tests/index.test.js | 4 +- 11 files changed, 202 insertions(+), 47 deletions(-) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index a47ea333684a8..8c2a23e9918b7 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -229,6 +229,9 @@ export function getDefineEnv({ 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( config.experimental.optimisticClientCache ), + 'process.env.__NEXT_MIDDLEWARE_PREFETCH': JSON.stringify( + config.experimental.middlewarePrefetch + ), 'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(config.crossOrigin), 'process.browser': JSON.stringify(isClient), 'process.env.__NEXT_TEST_MODE': JSON.stringify( diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 1139b72994e82..a6a17c56c0b67 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -1054,6 +1054,15 @@ export default abstract class Server { ) && (isSSG || hasServerProps) + // when we are handling a middleware prefetch and it doesn't + // resolve to a static data route we bail early to avoid + // unexpected SSR invocations + if (!isSSG && req.headers['x-middleware-prefetch']) { + res.setHeader('x-middleware-skip', '1') + res.body('{}').send() + return null + } + if (isAppPath) { res.setHeader('vary', RSC_VARY_HEADER) diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index ed2f9d9e82ed3..634b01a293561 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -304,6 +304,11 @@ const configSchema = { manualClientBasePath: { type: 'boolean', }, + middlewarePrefetch: { + // automatic typing doesn't like enum + enum: ['strict', 'flexible'] as any, + type: 'string', + }, modularizeImports: { type: 'object', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index b3b0878401da2..f27fbefad845e 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -83,6 +83,7 @@ export interface ExperimentalConfig { skipMiddlewareUrlNormalize?: boolean skipTrailingSlashRedirect?: boolean optimisticClientCache?: boolean + middlewarePrefetch?: 'strict' | 'flexible' legacyBrowsers?: boolean manualClientBasePath?: boolean newNextLinkBehavior?: boolean @@ -563,6 +564,7 @@ export const defaultConfig: NextConfig = { swcMinify: true, output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, experimental: { + middlewarePrefetch: 'flexible', optimisticClientCache: true, runtime: undefined, manualClientBasePath: false, diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 8117a78fa0327..db89c1cfe9dc2 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -1975,6 +1975,14 @@ export default class NextNodeServer extends BaseServer { ? `${parsedDestination.hostname}:${parsedDestination.port}` : parsedDestination.hostname) !== req.headers.host ) { + // when we are handling a middleware prefetch and it doesn't + // resolve to a static data route we bail early to avoid + // unexpected SSR invocations + if (req.headers['x-middleware-prefetch']) { + res.setHeader('x-middleware-skip', '1') + res.body('{}').send() + return { finished: true } + } return this.proxyRequest( req as NodeNextRequest, res as NodeNextResponse, diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 71a53ccdd07ff..cbedc1eec9cff 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -635,8 +635,6 @@ function fetchRetry( }) } -const backgroundCache: Record> = {} - interface FetchDataOutput { dataHref: string json: Record | null @@ -687,7 +685,11 @@ function fetchNextData({ const { href: cacheKey } = new URL(dataHref, window.location.href) const getData = (params?: { method?: 'HEAD' | 'GET' }) => fetchRetry(dataHref, isServerRender ? 3 : 1, { - headers: isPrefetch ? { purpose: 'prefetch' } : {}, + headers: Object.assign( + {} as HeadersInit, + isPrefetch ? { purpose: 'prefetch' } : {}, + isPrefetch && hasMiddleware ? { 'x-middleware-prefetch': '1' } : {} + ), method: params?.method ?? 'GET', }) .then((response) => { @@ -756,7 +758,12 @@ function fetchNextData({ return data }) .catch((err) => { - delete inflightCache[cacheKey] + if (!unstable_skipClientCache) { + delete inflightCache[cacheKey] + } + if (err.message === 'Failed to fetch') { + markAssetError(err) + } throw err }) @@ -839,8 +846,10 @@ export default class Router implements BaseRouter { * Map of all components loaded in `Router` */ components: { [pathname: string]: PrivateRouteInfo } - // Server Data Cache + // Server Data Cache (full data requests) sdc: NextDataCache = {} + // Server Background Cache (HEAD requests) + sbc: NextDataCache = {} sub: Subscription clc: ComponentLoadCancel @@ -1966,6 +1975,7 @@ export default class Router implements BaseRouter { ? existingInfo : undefined + const isBackground = isQueryUpdating const fetchNextDataParams: FetchNextDataParams = { dataHref: this.pageLoader.getDataHref({ href: formatWithValidation({ pathname, query }), @@ -1976,11 +1986,11 @@ export default class Router implements BaseRouter { hasMiddleware: true, isServerRender: this.isSsr, parseJSON: true, - inflightCache: this.sdc, + inflightCache: isBackground ? this.sbc : this.sdc, persistCache: !isPreview, isPrefetch: false, unstable_skipClientCache, - isBackground: isQueryUpdating, + isBackground, } const data = @@ -2071,26 +2081,36 @@ export default class Router implements BaseRouter { ) } } + const wasBailedPrefetch = data?.response?.headers.get('x-middleware-skip') const shouldFetchData = routeInfo.__N_SSG || routeInfo.__N_SSP + // For non-SSG prefetches that bailed before sending data + // we clear the cache to fetch full response + if (wasBailedPrefetch) { + delete this.sdc[data?.dataHref] + } + const { props, cacheKey } = await this._getData(async () => { if (shouldFetchData) { - const { json, cacheKey: _cacheKey } = data?.json - ? data - : await fetchNextData({ - dataHref: this.pageLoader.getDataHref({ - href: formatWithValidation({ pathname, query }), - asPath: resolvedAs, - locale, - }), - isServerRender: this.isSsr, - parseJSON: true, - inflightCache: this.sdc, - persistCache: !isPreview, - isPrefetch: false, - unstable_skipClientCache, - }) + const { json, cacheKey: _cacheKey } = + data?.json && !wasBailedPrefetch + ? data + : await fetchNextData({ + dataHref: + data?.dataHref || + this.pageLoader.getDataHref({ + href: formatWithValidation({ pathname, query }), + asPath: resolvedAs, + locale, + }), + isServerRender: this.isSsr, + parseJSON: true, + inflightCache: wasBailedPrefetch ? {} : this.sdc, + persistCache: !isPreview, + isPrefetch: false, + unstable_skipClientCache, + }) return { cacheKey: _cacheKey, @@ -2135,7 +2155,7 @@ export default class Router implements BaseRouter { Object.assign({}, fetchNextDataParams, { isBackground: true, persistCache: false, - inflightCache: backgroundCache, + inflightCache: this.sbc, }) ).catch(() => {}) } @@ -2278,6 +2298,12 @@ export default class Router implements BaseRouter { ? options.locale || undefined : this.locale + const isMiddlewareMatch = await matchesMiddleware({ + asPath: asPath, + locale: locale, + router: this, + }) + if (process.env.__NEXT_HAS_REWRITES && asPath.startsWith('/')) { let rewrites: any ;({ __rewrites: rewrites } = await getClientBuildManifest()) @@ -2305,7 +2331,9 @@ export default class Router implements BaseRouter { pathname = rewritesResult.resolvedHref parsed.pathname = pathname - url = formatWithValidation(parsed) + if (!isMiddlewareMatch) { + url = formatWithValidation(parsed) + } } } parsed.pathname = resolveDynamicRoute(parsed.pathname, pages) @@ -2320,7 +2348,9 @@ export default class Router implements BaseRouter { ) || {} ) - url = formatWithValidation(parsed) + if (!isMiddlewareMatch) { + url = formatWithValidation(parsed) + } } // Prefetch is not supported in development mode because it would trigger on-demand-entries @@ -2328,17 +2358,63 @@ export default class Router implements BaseRouter { return } + const data = + process.env.__NEXT_MIDDLEWARE_PREFETCH === 'strict' + ? ({} as any) + : await withMiddlewareEffects({ + fetchData: () => + fetchNextData({ + dataHref: this.pageLoader.getDataHref({ + href: formatWithValidation({ pathname, query }), + skipInterpolation: true, + asPath: resolvedAs, + locale, + }), + hasMiddleware: true, + isServerRender: this.isSsr, + parseJSON: true, + inflightCache: this.sdc, + persistCache: !this.isPreview, + isPrefetch: true, + }), + asPath: asPath, + locale: locale, + router: this, + }) + + /** + * If there was a rewrite we apply the effects of the rewrite on the + * current parameters for the prefetch. + */ + if (data?.effect.type === 'rewrite') { + parsed.pathname = data.effect.resolvedHref + pathname = data.effect.resolvedHref + query = { ...query, ...data.effect.parsedAs.query } + resolvedAs = data.effect.parsedAs.pathname + url = formatWithValidation(parsed) + } + + /** + * If there is a redirect to an external destination then we don't have + * to prefetch content as it will be unused. + */ + if (data?.effect.type === 'redirect-external') { + return + } + const route = removeTrailingSlash(pathname) await Promise.all([ this.pageLoader._isSsg(route).then((isSsg) => { return isSsg ? fetchNextData({ - dataHref: this.pageLoader.getDataHref({ - href: url, - asPath: resolvedAs, - locale: locale, - }), + dataHref: + data?.dataHref || + this.pageLoader.getDataHref({ + href: url, + asPath: resolvedAs, + locale: locale, + }), isServerRender: false, parseJSON: true, inflightCache: this.sdc, diff --git a/test/e2e/middleware-rewrites/app/pages/about.js b/test/e2e/middleware-rewrites/app/pages/about.js index 4eff796b31d59..852d269472e7c 100644 --- a/test/e2e/middleware-rewrites/app/pages/about.js +++ b/test/e2e/middleware-rewrites/app/pages/about.js @@ -1,15 +1,17 @@ -export default function Main({ message, middleware }) { +export default function Main({ message, middleware, now }) { return (

About Page

{message}

{middleware}

+

{now}

) } export const getServerSideProps = ({ query }) => ({ props: { + now: Date.now(), middleware: query.middleware || '', message: query.message || '', }, diff --git a/test/e2e/middleware-rewrites/app/pages/index.js b/test/e2e/middleware-rewrites/app/pages/index.js index 817ddf40778c4..a29960fbe7306 100644 --- a/test/e2e/middleware-rewrites/app/pages/index.js +++ b/test/e2e/middleware-rewrites/app/pages/index.js @@ -51,6 +51,14 @@ export default function Home() { Rewrite me to internal path
+ + Rewrite me to static + +
+ + Rewrite me to /about (SSR) + +
normal SSG link diff --git a/test/e2e/middleware-rewrites/app/pages/static-ssg/[slug].js b/test/e2e/middleware-rewrites/app/pages/static-ssg/[slug].js index 7f3907df0a85b..f40437654ab42 100644 --- a/test/e2e/middleware-rewrites/app/pages/static-ssg/[slug].js +++ b/test/e2e/middleware-rewrites/app/pages/static-ssg/[slug].js @@ -12,3 +12,19 @@ export default function Page() { ) } + +export function getStaticPaths() { + return { + paths: ['/static-ssg/first'], + fallback: 'blocking', + } +} + +export function getStaticProps({ params }) { + return { + props: { + now: Date.now(), + params, + }, + } +} diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index e2a63c3cb4095..745f1b936d270 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -46,12 +46,36 @@ function runTests({ dev }) { } const cacheKeys = await getCacheKeys() - expect(cacheKeys).toEqual([ - '/_next/data/BUILD_ID/p1/p2/all-ssg/hello.json?rest=hello', - '/_next/data/BUILD_ID/p1/p2/all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', - '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello.json?rest=hello', - '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', - ]) + expect(cacheKeys).toEqual( + process.env.__MIDDLEWARE_TEST + ? [ + '/_next/data/BUILD_ID/[name].json?another=value&name=%5Bname%5D', + '/_next/data/BUILD_ID/added-later/first.json?name=added-later&comment=first', + '/_next/data/BUILD_ID/blog/321/comment/123.json?name=321&id=123', + '/_next/data/BUILD_ID/d/dynamic-1.json?id=dynamic-1', + '/_next/data/BUILD_ID/on-mount/test-w-hash.json?post=test-w-hash', + '/_next/data/BUILD_ID/p1/p2/all-ssg/hello.json?rest=hello', + '/_next/data/BUILD_ID/p1/p2/all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', + '/_next/data/BUILD_ID/p1/p2/all-ssr/:42.json?rest=%3A42', + '/_next/data/BUILD_ID/p1/p2/all-ssr/hello.json?rest=hello', + '/_next/data/BUILD_ID/p1/p2/all-ssr/hello1%2F/he%2Fllo2.json?rest=hello1%2F&rest=he%2Fllo2', + '/_next/data/BUILD_ID/p1/p2/all-ssr/hello1/hello2.json?rest=hello1&rest=hello2', + '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello.json?rest=hello', + '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', + '/_next/data/BUILD_ID/post-1.json?fromHome=true&name=post-1', + '/_next/data/BUILD_ID/post-1.json?hidden=value&name=post-1', + '/_next/data/BUILD_ID/post-1.json?name=post-1', + '/_next/data/BUILD_ID/post-1.json?name=post-1&another=value', + '/_next/data/BUILD_ID/post-1/comment-1.json?name=post-1&comment=comment-1', + '/_next/data/BUILD_ID/post-1/comments.json?name=post-1', + ] + : [ + '/_next/data/BUILD_ID/p1/p2/all-ssg/hello.json?rest=hello', + '/_next/data/BUILD_ID/p1/p2/all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', + '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello.json?rest=hello', + '/_next/data/BUILD_ID/p1/p2/nested-all-ssg/hello1/hello2.json?rest=hello1&rest=hello2', + ] + ) // ensure no new cache entries after navigation const links = [ @@ -92,14 +116,16 @@ function runTests({ dev }) { await browser.waitForElementByCss(linkSelector) } const newCacheKeys = await getCacheKeys() - expect(newCacheKeys).toEqual([ - ...(process.env.__MIDDLEWARE_TEST - ? // data route is fetched with middleware due to query hydration - // since middleware matches the index route - ['/_next/data/BUILD_ID/index.json'] - : []), - ...cacheKeys, - ]) + expect(newCacheKeys).toEqual( + [ + ...(process.env.__MIDDLEWARE_TEST + ? // data route is fetched with middleware due to query hydration + // since middleware matches the index route + ['/_next/data/BUILD_ID/index.json'] + : []), + ...cacheKeys, + ].sort() + ) }) } diff --git a/test/integration/middleware-prefetch/tests/index.test.js b/test/integration/middleware-prefetch/tests/index.test.js index 22a424c14c557..a42a121d64db8 100644 --- a/test/integration/middleware-prefetch/tests/index.test.js +++ b/test/integration/middleware-prefetch/tests/index.test.js @@ -61,7 +61,7 @@ describe('Middleware Production Prefetch', () => { }, 'yes') }) - it(`prefetches provided path even if it will be rewritten`, async () => { + it(`does not prefetch provided path if it will be rewritten`, async () => { const browser = await webdriver(context.appPort, `/`) await browser.elementByCss('#ssg-page-2').moveTo() await check(async () => { @@ -69,7 +69,7 @@ describe('Middleware Production Prefetch', () => { const attrs = await Promise.all( scripts.map((script) => script.getAttribute('src')) ) - return attrs.find((src) => src.includes('/ssg-page-2')) ? 'yes' : 'nope' + return attrs.find((src) => src.includes('/ssg-page-2')) ? 'nope' : 'yes' }, 'yes') }) })