diff --git a/docs/advanced-features/security-headers.md b/docs/advanced-features/security-headers.md index 84e04c15d89968b..660c97711c157f6 100644 --- a/docs/advanced-features/security-headers.md +++ b/docs/advanced-features/security-headers.md @@ -81,7 +81,7 @@ This header allows you to control which features and APIs can be used in the bro ```jsx { key: 'Permissions-Policy', - value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' + value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()' } ``` diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index c27c822fb76ef04..b303e54cb63e3b7 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -279,14 +279,16 @@ The Ref must point to a DOM element or a React component that [forwards the Ref] import Image from 'next/image' import React from 'react' -const lazyRoot = React.useRef(null) +const Example = () => { + const lazyRoot = React.useRef(null) -const Example = () => ( -
- - -
-) + return ( +
+ + +
+ ) +} ``` **Example pointing to a React component** diff --git a/docs/testing.md b/docs/testing.md index f185d0eac486511..e3504d418de3ab5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -281,6 +281,7 @@ const createJestConfig = nextJest({ }) // Add any custom config to be passed to Jest +/** @type {import('jest').Config} */ const customJestConfig = { // Add more setup options before each test is run // setupFilesAfterEnv: ['/jest.setup.js'], diff --git a/lerna.json b/lerna.json index d97edfc48be689e..57eb3bf48f24b3e 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "12.2.6-canary.6" + "version": "12.2.6-canary.7" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 3d8df817c26574e..b44bb0d7864b7ae 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index b430e9f1b4ecffa..8e24e00c031b6a8 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "ESLint configuration used by NextJS.", "main": "index.js", "license": "MIT", @@ -9,7 +9,7 @@ "directory": "packages/eslint-config-next" }, "dependencies": { - "@next/eslint-plugin-next": "12.2.6-canary.6", + "@next/eslint-plugin-next": "12.2.6-canary.7", "@rushstack/eslint-patch": "^1.1.3", "@typescript-eslint/parser": "^5.21.0", "eslint-import-resolver-node": "^0.3.6", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 89232255fca7dcc..f2226efd31173a4 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "ESLint plugin for NextJS.", "main": "lib/index.js", "license": "MIT", diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 4287bf3c1d1c6b5..52578c7bf162aa4 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 9a124cc03039629..5874126c6a23a1a 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 46f728b59f6bdc4..a559a569712be42 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 33c71438f10eee6..c5b2c1370b1cfa8 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 4fd90f65419fa02..40258e8f791fa04 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 20cb9a32882fd38..48912ffaa1c2b23 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index b5ef4ed1d5f4558..f0ad046e77b9a8e 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 68abb3a5a023f34..7e226bb671a49da 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "private": true, "scripts": { "build-native": "napi build --platform -p next-swc-napi --cargo-name next_swc_napi native --features plugin", diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 3a24961a3fcaeaf..1cec41de0b50272 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1162,16 +1162,17 @@ export default async function build( const errorPageStaticResult = nonStaticErrorPageSpan.traceAsyncFn( async () => hasCustomErrorPage && - staticWorkers.isPageStatic( - '/_error', + staticWorkers.isPageStatic({ + page: '/_error', distDir, - isLikeServerless, + serverless: isLikeServerless, configFileName, runtimeEnvConfig, - config.httpAgentOptions, - config.i18n?.locales, - config.i18n?.defaultLocale - ) + httpAgentOptions: config.httpAgentOptions, + locales: config.i18n?.locales, + defaultLocale: config.i18n?.defaultLocale, + pageRuntime: config.experimental.runtime, + }) ) // we don't output _app in serverless mode so use _app export @@ -1274,29 +1275,53 @@ export default async function build( // Only calculate page static information if the page is not an // app page. pageType !== 'app' && - !isReservedPage(page) && - // We currently don't support static optimization in the Edge runtime. - pageRuntime !== SERVER_RUNTIME.edge + !isReservedPage(page) ) { try { + let edgeInfo: any + + if (pageRuntime === SERVER_RUNTIME.edge) { + const manifest = require(join( + distDir, + serverDir, + MIDDLEWARE_MANIFEST + )) + + edgeInfo = manifest.functions[page] + } + let isPageStaticSpan = checkPageSpan.traceChild('is-page-static') let workerResult = await isPageStaticSpan.traceAsyncFn( () => { - return staticWorkers.isPageStatic( + return staticWorkers.isPageStatic({ page, distDir, - isLikeServerless, + serverless: isLikeServerless, configFileName, runtimeEnvConfig, - config.httpAgentOptions, - config.i18n?.locales, - config.i18n?.defaultLocale, - isPageStaticSpan.id - ) + httpAgentOptions: config.httpAgentOptions, + locales: config.i18n?.locales, + defaultLocale: config.i18n?.defaultLocale, + parentId: isPageStaticSpan.id, + pageRuntime, + edgeInfo, + }) } ) + if (pageRuntime === SERVER_RUNTIME.edge) { + if (workerResult.hasStaticProps) { + console.warn( + `"getStaticProps" is not yet supported fully with "experimental-edge", detected on ${page}` + ) + } + // TODO: add handling for statically rendering edge + // pages and allow edge with Prerender outputs + workerResult.isStatic = false + workerResult.hasStaticProps = false + } + if (config.outputFileTracing) { pageTraceIncludes.set( page, diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index e37d1fa55420c2b..c050e89d49cb377 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -34,7 +34,10 @@ import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing- import { UnwrapPromise } from '../lib/coalesced-function' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import * as Log from './output/log' -import { loadComponents } from '../server/load-components' +import { + loadComponents, + LoadComponentsReturnType, +} from '../server/load-components' import { trace } from '../trace' import { setHttpAgentOptions } from '../server/config' import { recursiveDelete } from '../lib/recursive-delete' @@ -43,6 +46,7 @@ import { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { AppBuildManifest } from './webpack/plugins/app-build-manifest-plugin' +import { getRuntimeContext } from '../server/web/sandbox' export type ROUTER_TYPE = 'pages' | 'app' @@ -1008,17 +1012,31 @@ export async function buildStaticPaths( } } -export async function isPageStatic( - page: string, - distDir: string, - serverless: boolean, - configFileName: string, - runtimeEnvConfig: any, - httpAgentOptions: NextConfigComplete['httpAgentOptions'], - locales?: string[], - defaultLocale?: string, +export async function isPageStatic({ + page, + distDir, + serverless, + configFileName, + runtimeEnvConfig, + httpAgentOptions, + locales, + defaultLocale, + parentId, + pageRuntime, + edgeInfo, +}: { + page: string + distDir: string + serverless: boolean + configFileName: string + runtimeEnvConfig: any + httpAgentOptions: NextConfigComplete['httpAgentOptions'] + locales?: string[] + defaultLocale?: string parentId?: any -): Promise<{ + edgeInfo?: any + pageRuntime: ServerRuntime +}): Promise<{ isStatic?: boolean isAmpOnly?: boolean isHybridAmp?: boolean @@ -1037,24 +1055,51 @@ export async function isPageStatic( require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig) setHttpAgentOptions(httpAgentOptions) - const mod = await loadComponents(distDir, page, serverless) - const Comp = mod.Component + let componentsResult: LoadComponentsReturnType + + if (pageRuntime === SERVER_RUNTIME.edge) { + const runtime = await getRuntimeContext({ + paths: edgeInfo.files.map((file: string) => path.join(distDir, file)), + env: edgeInfo.env, + edgeFunctionEntry: edgeInfo, + name: edgeInfo.name, + useCache: true, + distDir, + }) + const mod = + runtime.context._ENTRIES[`middleware_${edgeInfo.name}`].ComponentMod + + componentsResult = { + Component: mod.default, + ComponentMod: mod, + pageConfig: mod.config || {}, + // @ts-expect-error this is not needed during require + buildManifest: {}, + reactLoadableManifest: {}, + getServerSideProps: mod.getServerSideProps, + getStaticPaths: mod.getStaticPaths, + getStaticProps: mod.getStaticProps, + } + } else { + componentsResult = await loadComponents(distDir, page, serverless) + } + const Comp = componentsResult.Component if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { throw new Error('INVALID_DEFAULT_EXPORT') } const hasGetInitialProps = !!(Comp as any).getInitialProps - const hasStaticProps = !!mod.getStaticProps - const hasStaticPaths = !!mod.getStaticPaths - const hasServerProps = !!mod.getServerSideProps - const hasLegacyServerProps = !!(await mod.ComponentMod + const hasStaticProps = !!componentsResult.getStaticProps + const hasStaticPaths = !!componentsResult.getStaticPaths + const hasServerProps = !!componentsResult.getServerSideProps + const hasLegacyServerProps = !!(await componentsResult.ComponentMod .unstable_getServerProps) - const hasLegacyStaticProps = !!(await mod.ComponentMod + const hasLegacyStaticProps = !!(await componentsResult.ComponentMod .unstable_getStaticProps) - const hasLegacyStaticPaths = !!(await mod.ComponentMod + const hasLegacyStaticPaths = !!(await componentsResult.ComponentMod .unstable_getStaticPaths) - const hasLegacyStaticParams = !!(await mod.ComponentMod + const hasLegacyStaticParams = !!(await componentsResult.ComponentMod .unstable_getStaticParams) if (hasLegacyStaticParams) { @@ -1121,7 +1166,7 @@ export async function isPageStatic( encodedPaths: encodedPrerenderRoutes, } = await buildStaticPaths( page, - mod.getStaticPaths!, + componentsResult.getStaticPaths!, configFileName, locales, defaultLocale @@ -1129,7 +1174,7 @@ export async function isPageStatic( } const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED - const config: PageConfig = mod.pageConfig + const config: PageConfig = componentsResult.pageConfig return { isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps, isHybridAmp: config.amp === 'hybrid', diff --git a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts index c09e23cb7e68c32..527aae70d77dc26 100644 --- a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts @@ -112,6 +112,8 @@ export default async function edgeSSRLoader(this: any) { config: ${stringifiedConfig}, buildId: ${JSON.stringify(buildId)}, }) + + export const ComponentMod = pageMod export default function(opts) { return adapter({ diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index 0763c0f423cea81..47246669056e63a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -68,6 +68,45 @@ export type ServerlessHandlerCtx = { i18n?: NextConfig['i18n'] } +export function interpolateDynamicPath( + pathname: string, + params: ParsedUrlQuery, + defaultRouteRegex?: ReturnType | undefined +) { + if (!defaultRouteRegex) return pathname + + for (const param of Object.keys(defaultRouteRegex.groups)) { + const { optional, repeat } = defaultRouteRegex.groups[param] + let builtParam = `[${repeat ? '...' : ''}${param}]` + + if (optional) { + builtParam = `[${builtParam}]` + } + + const paramIdx = pathname!.indexOf(builtParam) + + if (paramIdx > -1) { + let paramValue: string + + if (Array.isArray(params[param])) { + paramValue = (params[param] as string[]) + .map((v) => v && encodeURIComponent(v)) + .join('/') + } else { + paramValue = + params[param] && encodeURIComponent(params[param] as string) + } + + pathname = + pathname.slice(0, paramIdx) + + (paramValue || '') + + pathname.slice(paramIdx + builtParam.length) + } + } + + return pathname +} + export function getUtils({ page, i18n, @@ -297,41 +336,6 @@ export function getUtils({ )(req.headers['x-now-route-matches'] as string) as ParsedUrlQuery } - function interpolateDynamicPath(pathname: string, params: ParsedUrlQuery) { - if (!defaultRouteRegex) return pathname - - for (const param of Object.keys(defaultRouteRegex.groups)) { - const { optional, repeat } = defaultRouteRegex.groups[param] - let builtParam = `[${repeat ? '...' : ''}${param}]` - - if (optional) { - builtParam = `[${builtParam}]` - } - - const paramIdx = pathname!.indexOf(builtParam) - - if (paramIdx > -1) { - let paramValue: string - - if (Array.isArray(params[param])) { - paramValue = (params[param] as string[]) - .map((v) => v && encodeURIComponent(v)) - .join('/') - } else { - paramValue = - params[param] && encodeURIComponent(params[param] as string) - } - - pathname = - pathname.slice(0, paramIdx) + - (paramValue || '') + - pathname.slice(paramIdx + builtParam.length) - } - } - - return pathname - } - function normalizeVercelUrl( req: BaseNextRequest | IncomingMessage, trustQuery: boolean, @@ -570,8 +574,11 @@ export function getUtils({ normalizeVercelUrl, dynamicRouteMatcher, defaultRouteMatches, - interpolateDynamicPath, getParamsFromRouteMatches, normalizeDynamicRouteParams, + interpolateDynamicPath: ( + pathname: string, + params: Record + ) => interpolateDynamicPath(pathname, params, defaultRouteRegex), } } diff --git a/packages/next/package.json b/packages/next/package.json index 35cb97df563f0eb..75b8d2b1e3a0c55 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -70,7 +70,7 @@ ] }, "dependencies": { - "@next/env": "12.2.6-canary.6", + "@next/env": "12.2.6-canary.7", "@swc/helpers": "0.4.3", "caniuse-lite": "^1.0.30001332", "postcss": "8.4.14", @@ -121,11 +121,11 @@ "@hapi/accept": "5.0.2", "@napi-rs/cli": "2.7.0", "@napi-rs/triples": "1.1.0", - "@next/polyfill-module": "12.2.6-canary.6", - "@next/polyfill-nomodule": "12.2.6-canary.6", - "@next/react-dev-overlay": "12.2.6-canary.6", - "@next/react-refresh-utils": "12.2.6-canary.6", - "@next/swc": "12.2.6-canary.6", + "@next/polyfill-module": "12.2.6-canary.7", + "@next/polyfill-nomodule": "12.2.6-canary.7", + "@next/react-dev-overlay": "12.2.6-canary.7", + "@next/react-refresh-utils": "12.2.6-canary.7", + "@next/swc": "12.2.6-canary.7", "@segment/ajv-human-errors": "2.1.2", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index df3413e07fafb99..25bc78ad3f25d35 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -946,7 +946,8 @@ export default abstract class Server { // Toggle whether or not this is a Data request const isDataReq = - !!query.__nextDataReq && (isSSG || hasServerProps || isServerComponent) + !!(query.__nextDataReq || req.headers['x-nextjs-data']) && + (isSSG || hasServerProps || isServerComponent) delete query.__nextDataReq @@ -1577,6 +1578,18 @@ export default abstract class Server { if (result !== false) return result } } + + // currently edge functions aren't receiving the x-matched-path + // header so we need to fallback to matching the current page + // when we weren't able to match via dynamic route to handle + // the rewrite case + // @ts-expect-error extended in child class web-server + if (this.serverOptions.webServerConfig) { + // @ts-expect-error extended in child class web-server + ctx.pathname = this.serverOptions.webServerConfig.page + const result = await this.renderPageComponent(ctx, bubbleNoFallback) + if (result !== false) return result + } } catch (error) { const err = getProperError(error) @@ -1608,11 +1621,16 @@ export default abstract class Server { } res.statusCode = 500 + + // if pages/500 is present we still need to trigger + // /_error `getInitialProps` to allow reporting error + if (await this.hasPage('/500')) { + ctx.query.__nextCustomErrorRender = '1' + await this.renderErrorToResponse(ctx, err) + delete ctx.query.__nextCustomErrorRender + } + const isWrappedError = err instanceof WrappedBuildError - const response = await this.renderErrorToResponse( - ctx, - isWrappedError ? (err as WrappedBuildError).innerError : err - ) if (!isWrappedError) { if ( @@ -1624,6 +1642,10 @@ export default abstract class Server { } this.logError(getProperError(err)) } + const response = await this.renderErrorToResponse( + ctx, + isWrappedError ? (err as WrappedBuildError).innerError : err + ) return response } @@ -1712,7 +1734,11 @@ export default abstract class Server { } let statusPage = `/${res.statusCode}` - if (!result && STATIC_STATUS_PAGES.includes(statusPage)) { + if ( + !ctx.query.__nextCustomErrorRender && + !result && + STATIC_STATUS_PAGES.includes(statusPage) + ) { // skip ensuring /500 in dev mode as it isn't used and the // dev overlay is used instead if (statusPage !== '/500' || !this.renderOpts.dev) { diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 461c3b08866b31f..492ab6b93e4b094 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -222,7 +222,6 @@ export default class DevServer extends Server { for (const path in exportPathMap) { const { page, query = {} } = exportPathMap[path] - // We use unshift so that we're sure the routes is defined before Next's default routes this.router.addFsRoute({ match: getPathMatch(path), type: 'route', diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 18d75626b9a81fc..28021d731f7a055 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -96,6 +96,8 @@ import { checkIsManualRevalidate } from './api-utils' import { shouldUseReactRoot, isTargetLikeServerless } from './utils' import ResponseCache from './response-cache' import { IncrementalCache } from './lib/incremental-cache' +import { interpolateDynamicPath } from '../build/webpack/loaders/next-serverless-loader/utils' +import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' if (shouldUseReactRoot) { ;(process.env as any).__NEXT_REACT_ROOT = 'true' @@ -1951,7 +1953,32 @@ export default class NextNodeServer extends BaseServer { } // For middleware to "fetch" we must always provide an absolute URL - const url = getRequestMeta(params.req, '__NEXT_INIT_URL')! + const isDataReq = !!params.query.__nextDataReq + const query = urlQueryToSearchParams( + Object.assign({}, getRequestMeta(params.req, '__NEXT_INIT_QUERY') || {}) + ).toString() + const locale = params.query.__nextLocale + let normalizedPathname = params.page + + if (isDataReq) { + params.req.headers['x-nextjs-data'] = '1' + } + + if (isDynamicRoute(normalizedPathname)) { + const routeRegex = getNamedRouteRegex(params.page) + normalizedPathname = interpolateDynamicPath( + params.page, + Object.assign({}, params.params, params.query), + routeRegex + ) + } + + const url = `${getRequestMeta(params.req, '_protocol')}://${ + this.hostname + }:${this.port}${locale ? `/${locale}` : ''}${normalizedPathname}${ + query ? `?${query}` : '' + }` + if (!url.startsWith('http')) { throw new Error( 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' diff --git a/packages/next/server/request-meta.ts b/packages/next/server/request-meta.ts index 68ddb22a72808f9..39985a49184838f 100644 --- a/packages/next/server/request-meta.ts +++ b/packages/next/server/request-meta.ts @@ -63,6 +63,7 @@ type NextQueryMetadata = { __nextSsgPath?: string _nextBubbleNoFallback?: '1' __nextDataReq?: '1' + __nextCustomErrorRender?: '1' } export type NextParsedUrlQuery = ParsedUrlQuery & diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 0cb309f823afb4c..ca2ce97b1a7dc21 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -40,7 +40,7 @@ export type Route = { res: BaseNextResponse, params: Params, parsedUrl: NextUrlWithParsedQuery, - upgradeHead?: any + upgradeHead?: Buffer ) => Promise | RouteResult } @@ -49,21 +49,37 @@ export type DynamicRoutes = Array<{ page: string; match: RouteMatch }> export type PageChecker = (pathname: string) => Promise export default class Router { - headers: Route[] - fsRoutes: Route[] - redirects: Route[] - rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] + public catchAllMiddleware: ReadonlyArray + + private readonly headers: ReadonlyArray + private readonly fsRoutes: Route[] + private readonly redirects: ReadonlyArray + private readonly rewrites: { + beforeFiles: ReadonlyArray + afterFiles: ReadonlyArray + fallback: ReadonlyArray } - catchAllRoute: Route - catchAllMiddleware: Route[] - pageChecker: PageChecker - dynamicRoutes: DynamicRoutes - useFileSystemPublicRoutes: boolean - seenRequests: Set - nextConfig: NextConfig + private readonly catchAllRoute: Route + private readonly pageChecker: PageChecker + private dynamicRoutes: DynamicRoutes + private readonly useFileSystemPublicRoutes: boolean + private readonly nextConfig: NextConfig + private compiledRoutes: ReadonlyArray + private needsRecompilation: boolean + + /** + * context stores information used by the router. + */ + private readonly context = new WeakMap< + BaseNextRequest, + { + /** + * pageChecks is the memoized record of all checks made against pages to + * help de-duplicate work. + */ + pageChecks: Record + } + >() constructor({ headers = [], @@ -81,16 +97,16 @@ export default class Router { useFileSystemPublicRoutes, nextConfig, }: { - headers: Route[] - fsRoutes: Route[] + headers: ReadonlyArray + fsRoutes: ReadonlyArray rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] + beforeFiles: ReadonlyArray + afterFiles: ReadonlyArray + fallback: ReadonlyArray } - redirects: Route[] + redirects: ReadonlyArray catchAllRoute: Route - catchAllMiddleware: Route[] + catchAllMiddleware: ReadonlyArray dynamicRoutes: DynamicRoutes | undefined pageChecker: PageChecker useFileSystemPublicRoutes: boolean @@ -98,7 +114,7 @@ export default class Router { }) { this.nextConfig = nextConfig this.headers = headers - this.fsRoutes = fsRoutes + this.fsRoutes = [...fsRoutes] this.rewrites = rewrites this.redirects = redirects this.pageChecker = pageChecker @@ -106,7 +122,32 @@ export default class Router { this.catchAllMiddleware = catchAllMiddleware this.dynamicRoutes = dynamicRoutes this.useFileSystemPublicRoutes = useFileSystemPublicRoutes - this.seenRequests = new Set() + + // Perform the initial route compilation. + this.compiledRoutes = this.compileRoutes() + this.needsRecompilation = false + } + + private async checkPage( + req: BaseNextRequest, + pathname: string + ): Promise { + pathname = normalizeLocalePath(pathname, this.locales).pathname + + const context = this.context.get(req) + if (!context) { + throw new Error( + 'Invariant: request is not available inside the context, this is an internal error please open an issue.' + ) + } + + if (context.pageChecks[pathname] !== undefined) { + return context.pageChecks[pathname] + } + + const result = await this.pageChecker(pathname) + context.pageChecks[pathname] = result + return result } get locales() { @@ -117,192 +158,201 @@ export default class Router { return this.nextConfig.basePath || '' } - setDynamicRoutes(routes: DynamicRoutes = []) { - this.dynamicRoutes = routes + public setDynamicRoutes(dynamicRoutes: DynamicRoutes) { + this.dynamicRoutes = dynamicRoutes + this.needsRecompilation = true } - setCatchallMiddleware(route?: Route[]) { - this.catchAllMiddleware = route || [] + public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray) { + this.catchAllMiddleware = catchAllMiddleware + this.needsRecompilation = true } - addFsRoute(fsRoute: Route) { + public addFsRoute(fsRoute: Route) { + // We use unshift so that we're sure the routes is defined before Next's + // default routes. this.fsRoutes.unshift(fsRoute) + this.needsRecompilation = true } - async execute( + private compileRoutes(): ReadonlyArray { + /* + Desired routes order + - headers + - redirects + - Check filesystem (including pages), if nothing found continue + - User rewrites (checking filesystem and pages each match) + */ + + const [middlewareCatchAllRoute] = this.catchAllMiddleware + + return [ + ...(middlewareCatchAllRoute + ? this.fsRoutes + .filter((route) => route.name === '_next/data catchall') + .map((route) => ({ ...route, check: false })) + : []), + ...this.headers, + ...this.redirects, + ...(this.useFileSystemPublicRoutes && middlewareCatchAllRoute + ? [middlewareCatchAllRoute] + : []), + ...this.rewrites.beforeFiles, + ...this.fsRoutes, + // We only check the catch-all route if public page routes hasn't been + // disabled + ...(this.useFileSystemPublicRoutes + ? [ + { + type: 'route', + name: 'page checker', + match: getPathMatch('/:path*'), + fn: async (req, res, params, parsedUrl, upgradeHead) => { + const pathname = removeTrailingSlash(parsedUrl.pathname || '/') + if (!pathname) { + return { finished: false } + } + + if (await this.checkPage(req, pathname)) { + return this.catchAllRoute.fn( + req, + res, + params, + parsedUrl, + upgradeHead + ) + } + + return { finished: false } + }, + } as Route, + ] + : []), + ...this.rewrites.afterFiles, + ...(this.rewrites.fallback.length + ? [ + { + type: 'route', + name: 'dynamic route/page check', + match: getPathMatch('/:path*'), + fn: async (req, res, _params, parsedCheckerUrl, upgradeHead) => { + return { + finished: await this.checkFsRoutes( + req, + res, + parsedCheckerUrl, + upgradeHead + ), + } + }, + } as Route, + ...this.rewrites.fallback, + ] + : []), + + // We only check the catch-all route if public page routes hasn't been + // disabled + ...(this.useFileSystemPublicRoutes ? [this.catchAllRoute] : []), + ] + } + + private async checkFsRoutes( req: BaseNextRequest, res: BaseNextResponse, parsedUrl: NextUrlWithParsedQuery, - upgradeHead?: any - ): Promise { - if (this.seenRequests.has(req)) { - throw new Error( - `Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.` - ) - } - this.seenRequests.add(req) - try { - // memoize page check calls so we don't duplicate checks for pages - const pageChecks: { [name: string]: Promise } = {} - const memoizedPageChecker = async (p: string): Promise => { - p = normalizeLocalePath(p, this.locales).pathname + upgradeHead?: Buffer + ) { + const originalFsPathname = parsedUrl.pathname + const fsPathname = removePathPrefix(originalFsPathname!, this.basePath) + + for (const route of this.fsRoutes) { + const params = route.match(fsPathname) - if (pageChecks[p] !== undefined) { - return pageChecks[p] + if (params) { + parsedUrl.pathname = fsPathname + + const { finished } = await route.fn(req, res, params, parsedUrl) + if (finished) { + return true } - const result = this.pageChecker(p) - pageChecks[p] = result - return result + + parsedUrl.pathname = originalFsPathname } + } + + let matchedPage = await this.checkPage(req, fsPathname) + + // If we didn't match a page check dynamic routes + if (!matchedPage) { + const normalizedFsPathname = normalizeLocalePath( + fsPathname, + this.locales + ).pathname - let parsedUrlUpdated = parsedUrl + for (const dynamicRoute of this.dynamicRoutes) { + if (dynamicRoute.match(normalizedFsPathname)) { + matchedPage = true + } + } + } - const applyCheckTrue = async (checkParsedUrl: NextUrlWithParsedQuery) => { - const originalFsPathname = checkParsedUrl.pathname - const fsPathname = removePathPrefix(originalFsPathname!, this.basePath) + // Matched a page or dynamic route so render it using catchAllRoute + if (matchedPage) { + const params = this.catchAllRoute.match(parsedUrl.pathname) + if (!params) { + throw new Error( + `Invariant: could not match params, this is an internal error please open an issue.` + ) + } - for (const fsRoute of this.fsRoutes) { - const fsParams = fsRoute.match(fsPathname) + parsedUrl.pathname = fsPathname + parsedUrl.query._nextBubbleNoFallback = '1' - if (fsParams) { - checkParsedUrl.pathname = fsPathname + const { finished } = await this.catchAllRoute.fn( + req, + res, + params, + parsedUrl, + upgradeHead + ) - const fsResult = await fsRoute.fn( - req, - res, - fsParams, - checkParsedUrl - ) + return finished + } - if (fsResult.finished) { - return true - } + return false + } - checkParsedUrl.pathname = originalFsPathname - } - } - let matchedPage = await memoizedPageChecker(fsPathname) - - // If we didn't match a page check dynamic routes - if (!matchedPage) { - const normalizedFsPathname = normalizeLocalePath( - fsPathname, - this.locales - ).pathname - - for (const dynamicRoute of this.dynamicRoutes) { - if (dynamicRoute.match(normalizedFsPathname)) { - matchedPage = true - } - } - } + async execute( + req: BaseNextRequest, + res: BaseNextResponse, + parsedUrl: NextUrlWithParsedQuery, + upgradeHead?: Buffer + ): Promise { + // Only recompile if the routes need to be recompiled, this should only + // happen in development. + if (this.needsRecompilation) { + this.compiledRoutes = this.compileRoutes() + this.needsRecompilation = false + } - // Matched a page or dynamic route so render it using catchAllRoute - if (matchedPage) { - const pageParams = this.catchAllRoute.match(checkParsedUrl.pathname) - checkParsedUrl.pathname = fsPathname - checkParsedUrl.query._nextBubbleNoFallback = '1' + if (this.context.has(req)) { + throw new Error( + `Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.` + ) + } + this.context.set(req, { pageChecks: {} }) - const result = await this.catchAllRoute.fn( - req, - res, - pageParams as Params, - checkParsedUrl - ) - return result.finished - } + try { + // Create a deep copy of the parsed URL. + const parsedUrlUpdated = { + ...parsedUrl, + query: { + ...parsedUrl.query, + }, } - /* - Desired routes order - - headers - - redirects - - Check filesystem (including pages), if nothing found continue - - User rewrites (checking filesystem and pages each match) - */ - - const [middlewareCatchAllRoute] = this.catchAllMiddleware - const allRoutes = [ - ...(middlewareCatchAllRoute - ? this.fsRoutes - .filter((r) => r.name === '_next/data catchall') - .map((r) => { - return { - ...r, - check: false, - } - }) - : []), - ...this.headers, - ...this.redirects, - ...(this.useFileSystemPublicRoutes && middlewareCatchAllRoute - ? [middlewareCatchAllRoute] - : []), - ...this.rewrites.beforeFiles, - ...this.fsRoutes, - // We only check the catch-all route if public page routes hasn't been - // disabled - ...(this.useFileSystemPublicRoutes - ? [ - { - type: 'route', - name: 'page checker', - match: getPathMatch('/:path*'), - fn: async ( - checkerReq, - checkerRes, - params, - parsedCheckerUrl - ) => { - let { pathname } = parsedCheckerUrl - pathname = removeTrailingSlash(pathname || '/') - - if (!pathname) { - return { finished: false } - } - - if (await memoizedPageChecker(pathname)) { - return this.catchAllRoute.fn( - checkerReq, - checkerRes, - params, - parsedCheckerUrl - ) - } - return { finished: false } - }, - } as Route, - ] - : []), - ...this.rewrites.afterFiles, - ...(this.rewrites.fallback.length - ? [ - { - type: 'route', - name: 'dynamic route/page check', - match: getPathMatch('/:path*'), - fn: async ( - _checkerReq, - _checkerRes, - _params, - parsedCheckerUrl - ) => { - return { - finished: await applyCheckTrue(parsedCheckerUrl), - } - }, - } as Route, - ...this.rewrites.fallback, - ] - : []), - - // We only check the catch-all route if public page routes hasn't been - // disabled - ...(this.useFileSystemPublicRoutes ? [this.catchAllRoute] : []), - ] - - for (const testRoute of allRoutes) { + for (const route of this.compiledRoutes) { // only process rewrites for upgrade request - if (upgradeHead && testRoute.type !== 'rewrite') { + if (upgradeHead && route.type !== 'rewrite') { continue } @@ -314,7 +364,7 @@ export default class Router { if ( pathnameInfo.locale && - !testRoute.matchesLocaleAPIRoutes && + !route.matchesLocaleAPIRoutes && pathnameInfo.pathname.match(/^\/api(?:\/|$)/) ) { continue @@ -325,20 +375,20 @@ export default class Router { } const basePath = pathnameInfo.basePath - if (!testRoute.matchesBasePath) { + if (!route.matchesBasePath) { pathnameInfo.basePath = '' } if ( - testRoute.matchesLocale && - parsedUrl.query.__nextLocale && + route.matchesLocale && + parsedUrlUpdated.query.__nextLocale && !pathnameInfo.locale ) { - pathnameInfo.locale = parsedUrl.query.__nextLocale + pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale } if ( - !testRoute.matchesLocale && + !route.matchesLocale && pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale && pathnameInfo.locale ) { @@ -346,7 +396,7 @@ export default class Router { } if ( - testRoute.matchesTrailingSlash && + route.matchesTrailingSlash && getRequestMeta(req, '__nextHadTrailingSlash') ) { pathnameInfo.trailingSlash = true @@ -357,13 +407,13 @@ export default class Router { ...pathnameInfo, }) - let newParams = testRoute.match(matchPathname) - if (testRoute.has && newParams) { - const hasParams = matchHas(req, testRoute.has, parsedUrlUpdated.query) + let params = route.match(matchPathname) + if (route.has && params) { + const hasParams = matchHas(req, route.has, parsedUrlUpdated.query) if (hasParams) { - Object.assign(newParams, hasParams) + Object.assign(params, hasParams) } else { - newParams = false + params = false } } @@ -373,35 +423,34 @@ export default class Router { * never there, we consider this an invalid match and keep routing. */ if ( - newParams && + params && this.basePath && - !testRoute.matchesBasePath && + !route.matchesBasePath && !getRequestMeta(req, '_nextDidRewrite') && !basePath ) { continue } - if (newParams) { + if (params) { parsedUrlUpdated.pathname = matchPathname - const result = await testRoute.fn( + const result = await route.fn( req, res, - newParams, + params, parsedUrlUpdated, upgradeHead ) - if (result.finished) { return true } - // since the fs route didn't finish routing we need to re-add the - // basePath to continue checking with the basePath present - parsedUrlUpdated.pathname = originalPathname - if (result.pathname) { parsedUrlUpdated.pathname = result.pathname + } else { + // since the fs route didn't finish routing we need to re-add the + // basePath to continue checking with the basePath present + parsedUrlUpdated.pathname = originalPathname } if (result.query) { @@ -412,16 +461,19 @@ export default class Router { } // check filesystem - if (testRoute.check === true) { - if (await applyCheckTrue(parsedUrlUpdated)) { - return true - } + if ( + route.check && + (await this.checkFsRoutes(req, res, parsedUrlUpdated)) + ) { + return true } } } + + // All routes were tested, none were found. return false } finally { - this.seenRequests.delete(req) + this.context.delete(req) } } } diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 657b05bbc375f4d..7afff576aea23a7 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -66,7 +66,6 @@ export default class NextWebServer extends BaseServer { res: BaseNextResponse, parsedUrl: UrlWithParsedQuery ): Promise { - parsedUrl.pathname = this.serverOptions.webServerConfig.page super.run(req, res, parsedUrl) } protected async hasPage(page: string) { @@ -343,11 +342,10 @@ export default class NextWebServer extends BaseServer { {} as any, pathname, query, - { - ...renderOpts, + Object.assign(renderOpts, { disableOptimizedLoading: true, runtime: 'experimental-edge', - }, + }), !!pagesRenderToHTML ) } else { diff --git a/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 773936c75017435..d232294d5fceab6 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -3,6 +3,7 @@ import { getServerError } from 'next/dist/compiled/@next/react-dev-overlay/dist/ import { getModuleContext } from './context' import { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin' import { requestToBodyStream } from '../../body-streams' +import type { EdgeRuntime } from 'next/dist/compiled/edge-runtime' export const ErrorSource = Symbol('SandboxError') @@ -43,7 +44,15 @@ function withTaggedErrors(fn: RunnerFn): RunnerFn { }) } -export const run = withTaggedErrors(async (params) => { +export const getRuntimeContext = async (params: { + name: string + onWarning?: any + useCache: boolean + env: string[] + edgeFunctionEntry: any + distDir: string + paths: string[] +}): Promise> => { const { runtime, evaluateInContext } = await getModuleContext({ moduleName: params.name, onWarning: params.onWarning ?? (() => {}), @@ -56,7 +65,11 @@ export const run = withTaggedErrors(async (params) => { for (const paramPath of params.paths) { evaluateInContext(paramPath) } + return runtime +} +export const run = withTaggedErrors(async (params) => { + const runtime = await getRuntimeContext(params) const subreq = params.request.headers[`x-middleware-subrequest`] const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] if (subrequests.includes(params.name)) { diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index 5d16661ace2dff2..b31065e23675d4f 100644 --- a/packages/react-dev-overlay/package.json +++ b/packages/react-dev-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-dev-overlay", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "A development-only overlay for developing React applications.", "repository": { "url": "vercel/next.js", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 3e84fb69a567852..9cd9abe590f04ef 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "12.2.6-canary.6", + "version": "12.2.6-canary.7", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 645811547409167..dfe5387e501f5d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,7 +363,7 @@ importers: packages/eslint-config-next: specifiers: - '@next/eslint-plugin-next': 12.2.6-canary.6 + '@next/eslint-plugin-next': 12.2.6-canary.7 '@rushstack/eslint-patch': ^1.1.3 '@typescript-eslint/parser': ^5.21.0 eslint-import-resolver-node: ^0.3.6 @@ -419,12 +419,12 @@ importers: '@hapi/accept': 5.0.2 '@napi-rs/cli': 2.7.0 '@napi-rs/triples': 1.1.0 - '@next/env': 12.2.6-canary.6 - '@next/polyfill-module': 12.2.6-canary.6 - '@next/polyfill-nomodule': 12.2.6-canary.6 - '@next/react-dev-overlay': 12.2.6-canary.6 - '@next/react-refresh-utils': 12.2.6-canary.6 - '@next/swc': 12.2.6-canary.6 + '@next/env': 12.2.6-canary.7 + '@next/polyfill-module': 12.2.6-canary.7 + '@next/polyfill-nomodule': 12.2.6-canary.7 + '@next/react-dev-overlay': 12.2.6-canary.7 + '@next/react-refresh-utils': 12.2.6-canary.7 + '@next/swc': 12.2.6-canary.7 '@segment/ajv-human-errors': 2.1.2 '@swc/helpers': 0.4.3 '@taskr/clear': 1.1.0 diff --git a/test/development/correct-tsconfig-defaults/index.test.ts b/test/development/correct-tsconfig-defaults/index.test.ts index 70b8b51fee62db0..45ce6c521decfd8 100644 --- a/test/development/correct-tsconfig-defaults/index.test.ts +++ b/test/development/correct-tsconfig-defaults/index.test.ts @@ -1,7 +1,5 @@ import { createNext } from 'e2e-utils' -import fs from 'fs' -import { waitFor } from 'next-test-utils' -import path from 'path' +import { check } from 'next-test-utils' import { NextInstance } from 'test/lib/next-modes/base' describe('correct tsconfig.json defaults', () => { @@ -23,38 +21,46 @@ describe('correct tsconfig.json defaults', () => { afterAll(() => next.destroy()) it('should add `moduleResolution` when generating tsconfig.json in dev', async () => { - const tsconfigPath = path.join(next.testDir, 'tsconfig.json') - expect(fs.existsSync(tsconfigPath)).toBeFalse() + try { + expect( + await next.readFile('tsconfig.json').catch(() => false) + ).toBeFalse() - await next.start() - await waitFor(1000) - await next.stop() + await next.start() - expect(fs.existsSync(tsconfigPath)).toBeTrue() + // wait for tsconfig to be written + await check(async () => { + await next.readFile('tsconfig.json') + return 'success' + }, 'success') - const tsconfig = JSON.parse(await next.readFile('tsconfig.json')) - expect(next.cliOutput).not.toContain('moduleResolution') + const tsconfig = JSON.parse(await next.readFile('tsconfig.json')) + expect(next.cliOutput).not.toContain('moduleResolution') - expect(tsconfig.compilerOptions).toEqual( - expect.objectContaining({ moduleResolution: 'node' }) - ) + expect(tsconfig.compilerOptions).toEqual( + expect.objectContaining({ moduleResolution: 'node' }) + ) + } finally { + await next.stop() + } }) it('should not warn for `moduleResolution` when already present and valid', async () => { - const tsconfigPath = path.join(next.testDir, 'tsconfig.json') - expect(fs.existsSync(tsconfigPath)).toBeTrue() + try { + expect( + await next.readFile('tsconfig.json').catch(() => false) + ).toBeTruthy() - await next.start() - await waitFor(1000) - await next.stop() + await next.start() - expect(fs.existsSync(tsconfigPath)).toBeTrue() + const tsconfig = JSON.parse(await next.readFile('tsconfig.json')) - const tsconfig = JSON.parse(await next.readFile('tsconfig.json')) - - expect(tsconfig.compilerOptions).toEqual( - expect.objectContaining({ moduleResolution: 'node' }) - ) - expect(next.cliOutput).not.toContain('moduleResolution') + expect(tsconfig.compilerOptions).toEqual( + expect.objectContaining({ moduleResolution: 'node' }) + ) + expect(next.cliOutput).not.toContain('moduleResolution') + } finally { + await next.stop() + } }) }) diff --git a/test/e2e/edge-render-getserversideprops/app/pages/[id].js b/test/e2e/edge-render-getserversideprops/app/pages/[id].js new file mode 100644 index 000000000000000..c4d5932704aa255 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/[id].js @@ -0,0 +1,22 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/[id]

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params, query }) { + return { + props: { + query, + params, + now: Date.now(), + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/app/pages/index.js b/test/e2e/edge-render-getserversideprops/app/pages/index.js new file mode 100644 index 000000000000000..8264c1fa7c48c30 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/index.js @@ -0,0 +1,22 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/index

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params, query }) { + return { + props: { + query, + now: Date.now(), + params: params || null, + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/index.test.ts b/test/e2e/edge-render-getserversideprops/index.test.ts new file mode 100644 index 000000000000000..7bc8cef2d5abda6 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/index.test.ts @@ -0,0 +1,108 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, normalizeRegEx, renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import { join } from 'path' +import escapeStringRegexp from 'escape-string-regexp' + +describe('edge-render-getserversideprops', () => { + let next: NextInstance + + if (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should have correct query/params on index', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should have correct query/params on /[id]', async () => { + const html = await renderViaHTTP(next.url, '/123', { hello: 'world' }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: '123', hello: 'world' }) + expect(props.params).toEqual({ id: '123' }) + }) + + it('should respond to _next/data for index correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should respond to _next/data for [id] correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/321.json`, + { hello: 'world' }, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({ id: '321', hello: 'world' }) + expect(props.params).toEqual({ id: '321' }) + }) + + if ((global as any).isNextStart) { + it('should have data routes in routes-manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/routes-manifest.json') + ) + + for (const route of manifest.dataRoutes) { + route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex) + } + + expect(manifest.dataRoutes).toEqual([ + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/index.json$` + ), + page: '/', + }, + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/([^/]+?)\\.json$` + ), + namedDataRouteRegex: `^/_next/data/${escapeStringRegexp( + next.buildId + )}/(?[^/]+?)\\.json$`, + page: '/[id]', + routeKeys: { + id: 'id', + }, + }, + ]) + }) + } +}) diff --git a/test/production/custom-error-500/index.test.ts b/test/production/custom-error-500/index.test.ts new file mode 100644 index 000000000000000..2dba5785f3cf144 --- /dev/null +++ b/test/production/custom-error-500/index.test.ts @@ -0,0 +1,77 @@ +import { createNext } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, renderViaHTTP } from 'next-test-utils' + +describe('custom-error-500', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + 'pages/index.js': ` + export function getServerSideProps() { + throw new Error('custom error') + } + + export default function Page() { + return

index page

+ } + `, + 'pages/500.js': ` + export default function Custom500() { + return ( + <> +

pages/500

+ + ) + } + `, + 'pages/_error.js': ` + function Error({ hasError }) { + return ( + <> +

/_error

+ + ) + } + + Error.getInitialProps = ({ err }) => { + console.log(\`called Error.getInitialProps \${!!err}\`) + return { + hasError: !!err + } + } + + export default Error + `, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should correctly use pages/500 and call Error.getInitialProps', async () => { + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('pages/500') + + await check(() => next.cliOutput, /called Error\.getInitialProps true/) + }) + + it('should work correctly with pages/404 present', async () => { + await next.stop() + await next.patchFile( + 'pages/404.js', + ` + export default function Page() { + return

custom 404 page

+ } + ` + ) + await next.start() + + const html = await renderViaHTTP(next.url, '/') + expect(html).toContain('pages/500') + + await check(() => next.cliOutput, /called Error\.getInitialProps true/) + }) +})