diff --git a/errors/install-sharp.md b/errors/install-sharp.md new file mode 100644 index 000000000000000..addd72914f732b8 --- /dev/null +++ b/errors/install-sharp.md @@ -0,0 +1,15 @@ +# Install `sharp` to Use Built-In Image Optimization + +#### Why This Error Occurred + +Using Next.js' built-in Image Optimization requires that you bring your own version of `sharp`. + +#### Possible Ways to Fix It + +Please install the `sharp` package in your project. + +```bash +npm i sharp +# or +yarn add sharp +``` diff --git a/examples/with-expo-typescript/next.config.js b/examples/with-expo-typescript/next.config.js index 58a3b229dc0f863..e6507825ed20a89 100644 --- a/examples/with-expo-typescript/next.config.js +++ b/examples/with-expo-typescript/next.config.js @@ -1,5 +1,5 @@ // @generated: @expo/next-adapter@2.1.9 -// Learn more: https://github.com/expo/expo/blob/master/docs/pages/versions/unversioned/guides/using-nextjs.md#withexpo +// Learn more: https://github.com/expo/expo/blob/master/docs/pages/guides/using-nextjs.md const { withExpo } = require('@expo/next-adapter') diff --git a/examples/with-expo/next.config.js b/examples/with-expo/next.config.js index e88e60de3b33194..206d1ccb9005cbc 100644 --- a/examples/with-expo/next.config.js +++ b/examples/with-expo/next.config.js @@ -1,5 +1,5 @@ // @generated: @expo/next-adapter@2.1.5 -// Learn more: https://github.com/expo/expo/blob/master/docs/pages/versions/unversioned/guides/using-nextjs.md#withexpo +// Learn more: https://github.com/expo/expo/blob/master/docs/pages/guides/using-nextjs.md const { withExpo } = require('@expo/next-adapter') diff --git a/examples/with-mongodb/util/mongodb.js b/examples/with-mongodb/util/mongodb.js index f5a50bcfeebe681..4f0371eddde6a75 100644 --- a/examples/with-mongodb/util/mongodb.js +++ b/examples/with-mongodb/util/mongodb.js @@ -1,37 +1,45 @@ import { MongoClient } from 'mongodb' -let uri = process.env.MONGODB_URI -let dbName = process.env.MONGODB_DB +const { MONGODB_URI, MONGODB_DB } = process.env -let cachedClient = null -let cachedDb = null - -if (!uri) { +if (!MONGODB_URI) { throw new Error( 'Please define the MONGODB_URI environment variable inside .env.local' ) } -if (!dbName) { +if (!MONGODB_DB) { throw new Error( 'Please define the MONGODB_DB environment variable inside .env.local' ) } +/** + * Global is used here to maintain a cached connection across hot reloads + * in development. This prevents connections growing exponentiatlly + * during API Route usage. + */ +let cached = global.mongo +if (!cached) cached = global.mongo = {} + export async function connectToDatabase() { - if (cachedClient && cachedDb) { - return { client: cachedClient, db: cachedDb } + if (cached.conn) return cached.conn + if (!cached.promise) { + const conn = {} + const opts = { + useNewUrlParser: true, + useUnifiedTopology: true, + } + cached.promise = MongoClient.connect(MONGODB_URI, opts) + .then((client) => { + conn.client = client + return client.db(MONGODB_DB) + }) + .then((db) => { + conn.db = db + cached.conn = conn + }) } - - const client = await MongoClient.connect(uri, { - useNewUrlParser: true, - useUnifiedTopology: true, - }) - - const db = await client.db(dbName) - - cachedClient = client - cachedDb = db - - return { client, db } + await cached.promise + return cached.conn } diff --git a/examples/with-prisma/README.md b/examples/with-prisma/README.md index 63e011da182e08e..3d723bbafb54406 100644 --- a/examples/with-prisma/README.md +++ b/examples/with-prisma/README.md @@ -1,5 +1,7 @@ Prisma.io maintains it's own Next.Js examples - [Javascript](https://github.com/prisma/prisma-examples/tree/master/javascript/rest-nextjs) -- [Typescript](https://github.com/prisma/prisma-examples/tree/master/typescript/rest-nextjs) -- [Typescript-GraphQL](https://github.com/prisma/prisma-examples/tree/master/typescript/graphql-nextjs) +- [Typescript: rest-api-routes-auth](https://github.com/prisma/prisma-examples/tree/latest/typescript/rest-nextjs-api-routes-auth) +- [Typescript: rest-api-routes](https://github.com/prisma/prisma-examples/tree/latest/typescript/rest-nextjs-api-routes) +- [Typescript: rest-api-express](https://github.com/prisma/prisma-examples/tree/latest/typescript/rest-nextjs-express) +- [Typescript: graphql](https://github.com/prisma/prisma-examples/tree/latest/typescript/graphql-nextjs) diff --git a/lerna.json b/lerna.json index caec9de1dbaa621..31b40b561ff671e 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "9.5.6-canary.0" + "version": "9.5.6-canary.4" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index b102b0fa5cb1f98..cdc4da3a40d3870 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index d6f342e072b9970..cd567c33134faea 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": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "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 b28c4cb2b92e278..68216067b91bf9e 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 46bcb75e44569ef..d65c70694aa3baf 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 3240ec619009496..07fb25f1c4c9db8 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 68d27c4da863454..ebe360394957b84 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-google-analytics/package.json b/packages/next-plugin-google-analytics/package.json index 08297510b9f513b..e5f8f584d6a4f74 100644 --- a/packages/next-plugin-google-analytics/package.json +++ b/packages/next-plugin-google-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-google-analytics", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-google-analytics" diff --git a/packages/next-plugin-sentry/package.json b/packages/next-plugin-sentry/package.json index 38751edd16a20e8..c0228d2e404261f 100644 --- a/packages/next-plugin-sentry/package.json +++ b/packages/next-plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-sentry", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-sentry" diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index 588add9c5ee64d6..8b859cf6b79d909 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "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 423ce70fd070314..b7d9f3fca664de7 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "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 53b463fb0dcb05a..30d72b64e38a718 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 0633784c85e7103..9462c1e5dabcc39 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -29,6 +29,7 @@ import { CLIENT_STATIC_FILES_PATH, EXPORT_DETAIL, EXPORT_MARKER, + IMAGES_MANIFEST, PAGES_MANIFEST, PHASE_PRODUCTION_BUILD, PRERENDER_MANIFEST, @@ -96,6 +97,7 @@ export type PrerenderManifest = { version: 2 routes: { [route: string]: SsgRoute } dynamicRoutes: { [route: string]: DynamicSsgRoute } + notFoundRoutes: string[] preview: __ApiPreviewProps } @@ -306,6 +308,15 @@ export default async function build( dataRouteRegex: string namedDataRouteRegex?: string }> + i18n?: { + locales: string[] + defaultLocale: string[] + domains: Array<{ + domain: string + defaultLocale: string + locales: string[] + }> + } } = { version: 3, pages404: true, @@ -325,6 +336,7 @@ export default async function build( } }), dataRoutes: [], + i18n: config.experimental.i18n || undefined, } await promises.mkdir(distDir, { recursive: true }) @@ -703,6 +715,7 @@ export default async function build( const finalPrerenderRoutes: { [route: string]: SsgRoute } = {} const tbdPrerenderRoutes: string[] = [] + let ssgNotFoundPaths: string[] = [] if (postCompileSpinner) postCompileSpinner.stopAndPersist() @@ -720,6 +733,7 @@ export default async function build( const exportConfig: any = { ...config, initialPageRevalidationMap: {}, + ssgNotFoundPaths: [] as string[], // Default map will be the collection of automatic statically exported // pages and incremental pages. // n.b. we cannot handle this above in combinedPages because the dynamic @@ -785,7 +799,7 @@ export default async function build( const outputPath = `/${locale}${page === '/' ? '' : page}` defaultMap[outputPath] = { - page: defaultMap[page].page, + page: defaultMap[page]?.page || page, query: { __nextLocale: locale }, } @@ -811,6 +825,7 @@ export default async function build( const postBuildSpinner = createSpinner({ prefixText: `${Log.prefixes.info} Finalizing page optimization`, }) + ssgNotFoundPaths = exportConfig.ssgNotFoundPaths // remove server bundles that were exported for (const page of staticPages) { @@ -864,11 +879,12 @@ export default async function build( } const { i18n } = config.experimental + const isNotFound = ssgNotFoundPaths.includes(page) // for SSG files with i18n the non-prerendered variants are // output with the locale prefixed so don't attempt moving // without the prefix - if (!i18n || additionalSsgFile) { + if ((!i18n || additionalSsgFile) && !isNotFound) { await promises.mkdir(path.dirname(dest), { recursive: true }) await promises.rename(orig, dest) } else if (i18n && !isSsg) { @@ -881,9 +897,14 @@ export default async function build( if (additionalSsgFile) return for (const locale of i18n.locales) { + const curPath = `/${locale}${page === '/' ? '' : page}` const localeExt = page === '/' ? path.extname(file) : '' const relativeDestNoPages = relativeDest.substr('pages/'.length) + if (isSsg && ssgNotFoundPaths.includes(curPath)) { + continue + } + const updatedRelativeDest = path.join( 'pages', locale + localeExt, @@ -903,9 +924,7 @@ export default async function build( ) if (!isSsg) { - pagesManifest[ - `/${locale}${page === '/' ? '' : page}` - ] = updatedRelativeDest + pagesManifest[curPath] = updatedRelativeDest } await promises.mkdir(path.dirname(updatedDest), { recursive: true }) await promises.rename(updatedOrig, updatedDest) @@ -941,13 +960,19 @@ export default async function build( } if (isSsg) { + const { i18n } = config.experimental + // For a non-dynamic SSG page, we must copy its data file from export. if (!isDynamic) { await moveExportedPage(page, page, file, true, 'json') + const revalidationMapPath = i18n + ? `/${i18n.defaultLocale}${page}` + : page + finalPrerenderRoutes[page] = { initialRevalidateSeconds: - exportConfig.initialPageRevalidationMap[page], + exportConfig.initialPageRevalidationMap[revalidationMapPath], srcRoute: null, dataRoute: path.posix.join('/_next/data', buildId, `${file}.json`), } @@ -955,7 +980,7 @@ export default async function build( const pageInfo = pageInfos.get(page) if (pageInfo) { pageInfo.initialRevalidateSeconds = - exportConfig.initialPageRevalidationMap[page] + exportConfig.initialPageRevalidationMap[revalidationMapPath] pageInfos.set(page, pageInfo) } } else { @@ -1056,6 +1081,7 @@ export default async function build( version: 2, routes: finalPrerenderRoutes, dynamicRoutes: finalDynamicRoutes, + notFoundRoutes: ssgNotFoundPaths, preview: previewProps, } @@ -1075,6 +1101,7 @@ export default async function build( routes: {}, dynamicRoutes: {}, preview: previewProps, + notFoundRoutes: [], } await promises.writeFile( path.join(distDir, PRERENDER_MANIFEST), @@ -1083,6 +1110,15 @@ export default async function build( ) } + await promises.writeFile( + path.join(distDir, IMAGES_MANIFEST), + JSON.stringify({ + version: 1, + images: config.images, + }), + 'utf8' + ) + await promises.writeFile( path.join(distDir, EXPORT_MARKER), JSON.stringify({ @@ -1123,6 +1159,15 @@ export default async function build( printCustomRoutes({ redirects, rewrites, headers }) } + if (config.experimental.analyticsId) { + console.log( + chalk.bold.green('Next.js Analytics') + + ' is enabled for this production build. ' + + "You'll receive a Real Experience Score computed by all of your visitors." + ) + console.log('') + } + if (tracer) { const parsedResults = await tracer.profiler.stopProfiling() await new Promise((resolve) => { diff --git a/packages/next/build/plugins/collect-plugins.ts b/packages/next/build/plugins/collect-plugins.ts index f81ba230432af2c..ad9b4ebfc4c38b7 100644 --- a/packages/next/build/plugins/collect-plugins.ts +++ b/packages/next/build/plugins/collect-plugins.ts @@ -182,11 +182,9 @@ async function _collectPlugins( } // find packages with the naming convention - // @scope/next-plugin-[name] // @next/plugin-[name] - // next-plugin-[name] const filteredDeps = dependencies.filter((name) => { - return name.match(/(^@next\/plugin|next-plugin-)/) + return name.match(/^@next\/plugin/) }) if (nextPluginConfigNames) { diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fdfd07ffe49c297..0df798ee8034364 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1015,6 +1015,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_i18n_SUPPORT': JSON.stringify( !!config.experimental.i18n ), + 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify( + config.experimental.analyticsId + ), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index a637c2e37d5f758..53a18a0409ab4af 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -221,12 +221,17 @@ const nextServerlessLoader: loader.Loader = function () { // get pathname from URL with basePath stripped for locale detection const i18n = ${i18n} const accept = require('@hapi/accept') + const cookie = require('next/dist/compiled/cookie') const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie') const { detectDomainLocale } = require('next/dist/next-server/lib/i18n/detect-domain-locale') const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') let locales = i18n.locales let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) + let acceptPreferredLocale = accept.language( + req.headers['accept-language'], + i18n.locales + ) const detectedDomain = detectDomainLocale( i18n.domains, @@ -237,16 +242,14 @@ const nextServerlessLoader: loader.Loader = function () { detectedLocale = defaultLocale } - if (!detectedLocale) { - detectedLocale = accept.language( - req.headers['accept-language'], - i18n.locales - ) - } + // if not domain specific locale use accept-language preferred + detectedLocale = detectedLocale || acceptPreferredLocale let localeDomainRedirect const localePathResult = normalizeLocalePath(parsedUrl.pathname, i18n.locales) + routeNoAssetPath = normalizeLocalePath(routeNoAssetPath, i18n.locales).pathname + if (localePathResult.detectedLocale) { detectedLocale = localePathResult.detectedLocale req.url = formatUrl({ @@ -279,6 +282,7 @@ const nextServerlessLoader: loader.Loader = function () { const shouldStripDefaultLocale = detectedDefaultLocale && denormalizedPagePath.toLowerCase() === \`/\${i18n.defaultLocale.toLowerCase()}\` + const shouldAddLocalePrefix = !detectedDefaultLocale && denormalizedPagePath === '/' @@ -294,6 +298,30 @@ const nextServerlessLoader: loader.Loader = function () { shouldStripDefaultLocale ) ) { + // set the NEXT_LOCALE cookie when a user visits the default locale + // with the locale prefix so that they aren't redirected back to + // their accept-language preferred locale + if ( + shouldStripDefaultLocale && + acceptPreferredLocale !== defaultLocale + ) { + const previous = res.getHeader('set-cookie') + + res.setHeader( + 'set-cookie', + [ + ...(typeof previous === 'string' + ? [previous] + : Array.isArray(previous) + ? previous + : []), + cookie.serialize('NEXT_LOCALE', defaultLocale, { + httpOnly: true, + path: '/', + }) + ]) + } + res.setHeader( 'Location', formatUrl({ @@ -311,6 +339,7 @@ const nextServerlessLoader: loader.Loader = function () { res.end() return } + detectedLocale = detectedLocale || defaultLocale ` : ` @@ -507,12 +536,24 @@ const nextServerlessLoader: loader.Loader = function () { ${handleBasePath} + // remove ?amp=1 from request URL if rendering for export + if (fromExport && parsedUrl.query.amp) { + const queryNoAmp = Object.assign({}, origQuery) + delete queryNoAmp.amp + + req.url = formatUrl({ + ...parsedUrl, + search: undefined, + query: queryNoAmp + }) + } + if (parsedUrl.pathname.match(/_next\\/data/)) { const { - default: getrouteNoAssetPath, + default: getRouteNoAssetPath, } = require('next/dist/next-server/lib/router/utils/get-route-from-asset-path'); _nextData = true; - parsedUrl.pathname = getrouteNoAssetPath( + parsedUrl.pathname = getRouteNoAssetPath( parsedUrl.pathname.replace( new RegExp('/_next/data/${escapedBuildId}/'), '/' @@ -532,7 +573,7 @@ const nextServerlessLoader: loader.Loader = function () { isDataReq: _nextData, locale: detectedLocale, locales, - defaultLocale, + defaultLocale: i18n.defaultLocale, }, options, ) @@ -571,12 +612,24 @@ const nextServerlessLoader: loader.Loader = function () { ? `const nowParams = req.headers && req.headers["x-now-route-matches"] ? getRouteMatcher( (function() { - const { re, groups } = getRouteRegex("${page}"); + const { re, groups, routeKeys } = getRouteRegex("${page}"); return { re: { // Simulate a RegExp match from the \`req.url\` input exec: str => { const obj = parseQs(str); + + // favor named matches if available + const routeKeyNames = Object.keys(routeKeys) + + if (routeKeyNames.every(name => obj[name])) { + return routeKeyNames.reduce((prev, keyName) => { + const paramName = routeKeys[keyName] + prev[groups[paramName].pos] = obj[keyName] + return prev + }, {}) + } + return Object.keys(obj).reduce( (prev, key) => Object.assign(prev, { diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 129e37208e1285d..e54cbea4d42eac2 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -16,20 +16,18 @@ type ImageData = { breakpoints?: number[] } -type ImageProps = { +type ImageProps = Omit & { src: string - host: string - sizes: string - breakpoints: number[] - priority: boolean + host?: string + sizes?: string + priority?: boolean lazy: boolean className: string - unoptimized: boolean - rest: any[] + unoptimized?: boolean } let imageData: any = process.env.__NEXT_IMAGE_OPTS -const breakpoints = imageData.breakpoints || [640, 1024, 1600] +const breakpoints = imageData.sizes || [640, 1024, 1600] let cachedObserver: IntersectionObserver const IntersectionObserver = @@ -89,7 +87,7 @@ function computeSrc(src: string, host: string, unoptimized: boolean): string { function callLoader(src: string, host: string, width?: number): string { let loader = loaders[imageData.hosts[host].loader || 'default'] - return loader({ root: imageData.hosts[host].path, filename: src, width }) + return loader({ root: imageData.hosts[host].path, src, width }) } type SrcSetData = { @@ -110,15 +108,15 @@ type PreloadData = { src: string host: string widths: number[] - sizes: string - unoptimized: boolean + sizes?: string + unoptimized?: boolean } function generatePreload({ src, host, widths, - unoptimized, + unoptimized = false, sizes, }: PreloadData): ReactElement { // This function generates an image preload that makes use of the "imagesrcset" and "imagesizes" @@ -143,8 +141,8 @@ export default function Image({ src, host, sizes, - unoptimized, - priority, + unoptimized = false, + priority = false, lazy, className, ...rest @@ -274,18 +272,21 @@ export default function Image({ type LoaderProps = { root: string - filename: string + src: string width?: number } -function imgixLoader({ root, filename, width }: LoaderProps): string { - return `${root}${filename}${width ? '?w=' + width : ''}` +function imgixLoader({ root, src, width }: LoaderProps): string { + return `${root}${src}${width ? '?w=' + width : ''}` } -function cloudinaryLoader({ root, filename, width }: LoaderProps): string { - return `${root}${width ? 'w_' + width + '/' : ''}${filename}` +function cloudinaryLoader({ root, src, width }: LoaderProps): string { + return `${root}${width ? 'w_' + width + '/' : ''}${src}` } -function defaultLoader({ root, filename }: LoaderProps): string { - return `${root}${filename}` +function defaultLoader({ root, src, width }: LoaderProps): string { + // TODO: change quality parameter to be configurable + return `${root}?url=${encodeURIComponent(src)}&${ + width ? `w=${width}&` : '' + }q=100` } diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index a7a4b069f7b2cf9..43fae5b351d4bfc 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -462,10 +462,6 @@ function renderReactElement(reactEl: JSX.Element, domEl: HTMLElement) { if (isInitialRender) { ReactDOM.hydrate(reactEl, domEl, markHydrateComplete) isInitialRender = false - - if (onPerfEntry && ST) { - measureWebVitals(onPerfEntry) - } } else { ReactDOM.render(reactEl, domEl, markRenderComplete) } @@ -744,5 +740,10 @@ function Root({ } }, []) } + // We should ask to measure the Web Vitals after rendering completes so we + // don't cause any hydration delay: + React.useEffect(() => { + measureWebVitals(onPerfEntry) + }, []) return children as React.ReactElement } diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 56da5f68c4c3c38..41bdf3ef542842b 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -26,6 +26,7 @@ export type LinkProps = { shallow?: boolean passHref?: boolean prefetch?: boolean + locale?: string } type LinkPropsRequired = RequiredKeys type LinkPropsOptional = OptionalKeys @@ -125,7 +126,8 @@ function linkClicked( as: string, replace?: boolean, shallow?: boolean, - scroll?: boolean + scroll?: boolean, + locale?: string ): void { const { nodeName } = e.currentTarget @@ -142,7 +144,7 @@ function linkClicked( } // replace state instead of push if prop is present - router[replace ? 'replace' : 'push'](href, as, { shallow }).then( + router[replace ? 'replace' : 'push'](href, as, { shallow, locale }).then( (success: boolean) => { if (!success) return if (scroll) { @@ -202,21 +204,28 @@ function Link(props: React.PropsWithChildren) { shallow: true, passHref: true, prefetch: true, + locale: true, } as const const optionalProps: LinkPropsOptional[] = Object.keys( optionalPropsGuard ) as LinkPropsOptional[] optionalProps.forEach((key: LinkPropsOptional) => { + const valType = typeof props[key] + if (key === 'as') { - if ( - props[key] && - typeof props[key] !== 'string' && - typeof props[key] !== 'object' - ) { + if (props[key] && valType !== 'string' && valType !== 'object') { throw createPropError({ key, expected: '`string` or `object`', - actual: typeof props[key], + actual: valType, + }) + } + } else if (key === 'locale') { + if (props[key] && valType !== 'string') { + throw createPropError({ + key, + expected: '`string`', + actual: valType, }) } } else if ( @@ -226,11 +235,11 @@ function Link(props: React.PropsWithChildren) { key === 'passHref' || key === 'prefetch' ) { - if (props[key] != null && typeof props[key] !== 'boolean') { + if (props[key] != null && valType !== 'boolean') { throw createPropError({ key, expected: '`boolean`', - actual: typeof props[key], + actual: valType, }) } } else { @@ -285,7 +294,7 @@ function Link(props: React.PropsWithChildren) { } }, [p, childElm, href, as, router]) - let { children, replace, shallow, scroll } = props + let { children, replace, shallow, scroll, locale } = props // Deprecated. Warning shown by propType check. If the children provided is a string (example) we wrap it in an tag if (typeof children === 'string') { children = {children} @@ -314,7 +323,7 @@ function Link(props: React.PropsWithChildren) { child.props.onClick(e) } if (!e.defaultPrevented) { - linkClicked(e, router, href, as, replace, shallow, scroll) + linkClicked(e, router, href, as, replace, shallow, scroll, locale) } }, } @@ -333,7 +342,11 @@ function Link(props: React.PropsWithChildren) { // defined, we specify the current 'href', so that repetition is not needed by the user if (props.passHref || (child.type === 'a' && !('href' in child.props))) { childProps.href = addBasePath( - addLocale(as, router && router.locale, router && router.defaultLocale) + addLocale( + as, + locale || (router && router.locale), + router && router.defaultLocale + ) ) } diff --git a/packages/next/client/performance-relayer.ts b/packages/next/client/performance-relayer.ts index f1b43fa7e10e7c8..434e4b8dc77d79d 100644 --- a/packages/next/client/performance-relayer.ts +++ b/packages/next/client/performance-relayer.ts @@ -4,13 +4,72 @@ import { getFID, getLCP, getTTFB, + Metric, ReportHandler, } from 'web-vitals' -export default (onPerfEntry: ReportHandler) => { - getCLS(onPerfEntry) - getFID(onPerfEntry) - getFCP(onPerfEntry) - getLCP(onPerfEntry) - getTTFB(onPerfEntry) +const initialHref = location.href +let isRegistered = false +let userReportHandler: ReportHandler | undefined + +function onReport(metric: Metric) { + if (userReportHandler) { + userReportHandler(metric) + } + + // This code is not shipped, executed, or present in the client-side + // JavaScript bundle unless explicitly enabled in your application. + // + // When this feature is enabled, we'll make it very clear by printing a + // message during the build (`next build`). + if ( + process.env.NODE_ENV === 'production' && + // This field is empty unless you explicitly configure it: + process.env.__NEXT_ANALYTICS_ID + ) { + const body: Record = { + dsn: process.env.__NEXT_ANALYTICS_ID, + id: metric.id, + page: window.__NEXT_DATA__.page, + href: initialHref, + event_name: metric.name, + value: metric.value.toString(), + speed: + 'connection' in navigator && + navigator['connection'] && + 'effectiveType' in navigator['connection'] + ? (navigator['connection']['effectiveType'] as string) + : '', + } + + const blob = new Blob([new URLSearchParams(body).toString()], { + // This content type is necessary for `sendBeacon`: + type: 'application/x-www-form-urlencoded', + }) + const vitalsUrl = 'https://vitals.vercel-analytics.com/v1/vitals' + ;(navigator.sendBeacon && navigator.sendBeacon(vitalsUrl, blob)) || + fetch(vitalsUrl, { + body: blob, + method: 'POST', + credentials: 'omit', + keepalive: true, + }) + } +} + +export default (onPerfEntry?: ReportHandler) => { + // Update function if it changes: + userReportHandler = onPerfEntry + + // Only register listeners once: + if (isRegistered) { + return + } + isRegistered = true + + getCLS(onReport) + getFID(onReport) + getFCP(onReport) + getLCP(onReport) + getTTFB(onReport) } diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index cc5f14a7a5ffc00..5c146431c303893 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -285,6 +285,12 @@ export default async function exportApp( const { i18n } = nextConfig.experimental + if (i18n && !options.buildExport) { + throw new Error( + `i18n support is not compatible with next export. See here for more info on deploying: https://nextjs.org/docs/deployment` + ) + } + // Start the rendering process const renderOpts = { dir, @@ -467,13 +473,17 @@ export default async function exportApp( renderError = renderError || !!result.error if (!!result.error) errorPaths.push(path) - if ( - options.buildExport && - typeof result.fromBuildExportRevalidate !== 'undefined' - ) { - configuration.initialPageRevalidationMap[path] = - result.fromBuildExportRevalidate + if (options.buildExport) { + if (typeof result.fromBuildExportRevalidate !== 'undefined') { + configuration.initialPageRevalidationMap[path] = + result.fromBuildExportRevalidate + } + + if (result.ssgNotFound === true) { + configuration.ssgNotFoundPaths.push(path) + } } + if (progress) progress() }) ) diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index adc7a93d10c613c..f13b2c817187f94 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -55,6 +55,7 @@ interface ExportPageResults { ampValidations: AmpValidation[] fromBuildExportRevalidate?: number error?: boolean + ssgNotFound?: boolean } interface RenderOpts { @@ -70,6 +71,7 @@ interface RenderOpts { fontManifest?: FontManifest locales?: string[] locale?: string + defaultLocale?: string } type ComponentModule = ComponentType<{}> & { @@ -99,8 +101,9 @@ export default async function exportPage({ const { query: originalQuery = {} } = pathMap const { page } = pathMap const filePath = normalizePagePath(path) - const ampPath = `${filePath}.amp` const isDynamic = isDynamicRoute(page) + const ampPath = `${filePath}.amp` + let renderAmpPath = ampPath let query = { ...originalQuery } let params: { [key: string]: string | string[] } | undefined @@ -114,6 +117,10 @@ export default async function exportPage({ if (localePathResult.detectedLocale) { updatedPath = localePathResult.pathname locale = localePathResult.detectedLocale + + if (locale === renderOpts.defaultLocale) { + renderAmpPath = `${normalizePagePath(updatedPath)}.amp` + } } } @@ -238,7 +245,7 @@ export default async function exportPage({ res, 'export', { - ampPath, + ampPath: renderAmpPath, /// @ts-ignore optimizeFonts, /// @ts-ignore @@ -252,11 +259,11 @@ export default async function exportPage({ // @ts-ignore params ) - curRenderOpts = result.renderOpts || {} - html = result.html + curRenderOpts = (result as any).renderOpts || {} + html = (result as any).html } - if (!html) { + if (!html && !(curRenderOpts as any).ssgNotFound) { throw new Error(`Failed to render serverless page`) } } else { @@ -298,7 +305,7 @@ export default async function exportPage({ curRenderOpts = { ...components, ...renderOpts, - ampPath, + ampPath: renderAmpPath, params, optimizeFonts, optimizeImages, @@ -311,6 +318,7 @@ export default async function exportPage({ html = await renderMethod(req, res, page, query, curRenderOpts) } } + results.ssgNotFound = (curRenderOpts as any).ssgNotFound const validateAmp = async ( rawAmpHtml: string, @@ -334,7 +342,9 @@ export default async function exportPage({ } if (curRenderOpts.inAmpMode && !curRenderOpts.ampSkipValidation) { - await validateAmp(html, path, curRenderOpts.ampValidatorPath) + if (!results.ssgNotFound) { + await validateAmp(html, path, curRenderOpts.ampValidatorPath) + } } else if (curRenderOpts.hybridAmp) { // we need to render the AMP version let ampHtmlFilename = `${ampPath}${sep}index.html` @@ -352,15 +362,23 @@ export default async function exportPage({ if (serverless) { req.url += (req.url!.includes('?') ? '&' : '?') + 'amp=1' // @ts-ignore - ampHtml = (await renderMethod(req, res, 'export')).html + ampHtml = ( + await (renderMethod as any)( + req, + res, + 'export', + curRenderOpts, + params + ) + ).html } else { ampHtml = await renderMethod( req, res, page, // @ts-ignore - { ...query, amp: 1 }, - curRenderOpts + { ...query, amp: '1' }, + curRenderOpts as any ) } @@ -395,6 +413,10 @@ export default async function exportPage({ } results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate + if (results.ssgNotFound) { + // don't attempt writing to disk if getStaticProps returned not found + return results + } await promises.writeFile(htmlFilepath, html, 'utf8') return results } catch (error) { diff --git a/packages/next/next-server/lib/constants.ts b/packages/next/next-server/lib/constants.ts index 8257e98eac59680..09e9e20aeb16c5e 100644 --- a/packages/next/next-server/lib/constants.ts +++ b/packages/next/next-server/lib/constants.ts @@ -8,6 +8,7 @@ export const EXPORT_MARKER = 'export-marker.json' export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json' export const ROUTES_MANIFEST = 'routes-manifest.json' +export const IMAGES_MANIFEST = 'images-manifest.json' export const DEV_CLIENT_PAGES_MANIFEST = '_devPagesManifest.json' export const REACT_LOADABLE_MANIFEST = 'react-loadable-manifest.json' export const FONT_MANIFEST = 'font-manifest.json' diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 4a0bf3fd6a413ae..85921f4d6ca8b1c 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -29,6 +29,7 @@ import escapePathDelimiters from './utils/escape-path-delimiters' interface TransitionOptions { shallow?: boolean + locale?: string } interface NextHistoryState { @@ -298,6 +299,8 @@ const manualScrollRestoration = typeof window !== 'undefined' && 'scrollRestoration' in window.history +const SSG_DATA_NOT_FOUND_ERROR = 'SSG Data NOT_FOUND' + function fetchRetry(url: string, attempts: number): Promise { return fetch(url, { // Cookies are required to be present for Next.js' SSG "Preview Mode". @@ -317,9 +320,13 @@ function fetchRetry(url: string, attempts: number): Promise { if (attempts > 1 && res.status >= 500) { return fetchRetry(url, attempts - 1) } + if (res.status === 404) { + // TODO: handle reloading in development from fallback returning 200 + // to on-demand-entry-handler causing it to reload periodically + throw new Error(SSG_DATA_NOT_FOUND_ERROR) + } throw new Error(`Failed to load static props`) } - return res.json() }) } @@ -329,7 +336,8 @@ function fetchNextData(dataHref: string, isServerRender: boolean) { // 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) { + + if (!isServerRender || err.message === 'SSG Data NOT_FOUND') { markLoadingError(err) } throw err @@ -592,6 +600,7 @@ export default class Router implements BaseRouter { window.location.href = url return false } + this.locale = options.locale || this.locale if (!(options as any)._h) { this.isSsr = false @@ -898,6 +907,13 @@ export default class Router implements BaseRouter { // 3. Internal error while loading the page // So, doing a hard reload is the proper way to deal with this. + if (process.env.NODE_ENV === 'development') { + // append __next404 query to prevent fallback from being re-served + // on reload in development + if (err.message === SSG_DATA_NOT_FOUND_ERROR && this.isSsr) { + as += `${as.indexOf('?') > -1 ? '&' : '?'}__next404=1` + } + } window.location.href = as // Changing the URL doesn't block executing the current code path. diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index a49a886f2fcd117..753b0ea0242c8be 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -23,7 +23,11 @@ const defaultConfig: { [key: string]: any } = { target: 'server', poweredByHeader: true, compress: true, - images: { hosts: { default: { path: 'defaultconfig' } } }, + images: { + sizes: [320, 420, 768, 1024, 1200], + domains: [], + hosts: { default: { path: '/_next/image' } }, + }, devIndicators: { buildActivity: true, autoPrerender: true, @@ -56,6 +60,7 @@ const defaultConfig: { [key: string]: any } = { optimizeImages: false, scrollRestoration: false, i18n: false, + analyticsId: process.env.VERCEL_ANALYTICS_ID || '', }, future: { excludeDefaultMomentLocales: false, @@ -208,6 +213,47 @@ function assignDefaults(userConfig: { [key: string]: any }) { } } + if (result?.images) { + const { images } = result + if (typeof images !== 'object') { + throw new Error( + `Specified images should be an object received ${typeof images}` + ) + } + if (images.domains) { + if (!Array.isArray(images.domains)) { + throw new Error( + `Specified images.domains should be an Array received ${typeof images.domains}` + ) + } + const invalid = images.domains.filter( + (d: unknown) => typeof d !== 'string' + ) + if (invalid.length > 0) { + throw new Error( + `Specified images.domains should be an Array of strings received invalid values (${invalid.join( + ', ' + )})` + ) + } + } + if (images.sizes) { + if (!Array.isArray(images.sizes)) { + throw new Error( + `Specified images.sizes should be an Array received ${typeof images.sizes}` + ) + } + const invalid = images.sizes.filter((d: unknown) => typeof d !== 'number') + if (invalid.length > 0) { + throw new Error( + `Specified images.sizes should be an Array of numbers received invalid values (${invalid.join( + ', ' + )})` + ) + } + } + } + if (result.experimental?.i18n) { const { i18n } = result.experimental const i18nType = typeof i18n @@ -218,7 +264,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (!Array.isArray(i18n.locales)) { throw new Error( - `Specified i18n.locales should be an Array received ${typeof i18n.lcoales}` + `Specified i18n.locales should be an Array received ${typeof i18n.locales}` ) } diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts new file mode 100644 index 000000000000000..7a5c64093b55ab6 --- /dev/null +++ b/packages/next/next-server/server/image-optimizer.ts @@ -0,0 +1,244 @@ +import { UrlWithParsedQuery } from 'url' +import { IncomingMessage, ServerResponse } from 'http' +import { join } from 'path' +import { mediaType } from '@hapi/accept' +import { createReadStream, promises } from 'fs' +import { createHash } from 'crypto' +import Server from './next-server' +import { getContentType, getExtension } from './serve-static' +import { fileExists } from '../../lib/file-exists' + +let sharp: typeof import('sharp') +//const AVIF = 'image/avif' +const WEBP = 'image/webp' +const PNG = 'image/png' +const JPEG = 'image/jpeg' +const MIME_TYPES = [/* AVIF, */ WEBP, PNG, JPEG] +const CACHE_VERSION = 1 + +export async function imageOptimizer( + server: Server, + req: IncomingMessage, + res: ServerResponse, + parsedUrl: UrlWithParsedQuery +) { + const { nextConfig, distDir } = server + const { sizes = [], domains = [] } = nextConfig?.images || {} + const { headers } = req + const { url, w, q } = parsedUrl.query + const proto = headers['x-forwarded-proto'] || 'http' + const host = headers['x-forwarded-host'] || headers.host + const mimeType = mediaType(headers.accept, MIME_TYPES) || '' + + if (!url) { + res.statusCode = 400 + res.end('"url" parameter is required') + return { finished: true } + } else if (Array.isArray(url)) { + res.statusCode = 400 + res.end('"url" parameter cannot be an array') + return { finished: true } + } + + let absoluteUrl: URL + try { + absoluteUrl = new URL(url) + } catch (_error) { + // url was not absolute so assuming relative url + try { + absoluteUrl = new URL(url, `${proto}://${host}`) + } catch (__error) { + res.statusCode = 400 + res.end('"url" parameter is invalid') + return { finished: true } + } + } + + if (!['http:', 'https:'].includes(absoluteUrl.protocol)) { + res.statusCode = 400 + res.end('"url" parameter is invalid') + return { finished: true } + } + + if (!server.renderOpts.dev && !domains.includes(absoluteUrl.hostname)) { + res.statusCode = 400 + res.end('"url" parameter is not allowed') + return { finished: true } + } + + if (!w) { + res.statusCode = 400 + res.end('"w" parameter (width) is required') + return { finished: true } + } else if (Array.isArray(w)) { + res.statusCode = 400 + res.end('"w" parameter (width) cannot be an array') + return { finished: true } + } + + if (!q) { + res.statusCode = 400 + res.end('"q" parameter (quality) is required') + return { finished: true } + } else if (Array.isArray(q)) { + res.statusCode = 400 + res.end('"q" parameter (quality) cannot be an array') + return { finished: true } + } + + const width = parseInt(w, 10) + + if (!width || isNaN(width)) { + res.statusCode = 400 + res.end('"w" parameter (width) must be a number greater than 0') + return { finished: true } + } + + if (!sizes.includes(width)) { + res.statusCode = 400 + res.end(`"w" parameter (width) of ${width} is not allowed`) + return { finished: true } + } + + const quality = parseInt(q) + + if (isNaN(quality) || quality < 1 || quality > 100) { + res.statusCode = 400 + res.end('"q" parameter (quality) must be a number between 1 and 100') + return { finished: true } + } + + const { href } = absoluteUrl + const hash = getHash([CACHE_VERSION, href, width, quality, mimeType]) + const imagesDir = join(distDir, 'cache', 'images') + const hashDir = join(imagesDir, hash) + const now = Date.now() + + if (await fileExists(hashDir, 'directory')) { + const files = await promises.readdir(hashDir) + for (let file of files) { + const [filename, extension] = file.split('.') + const expireAt = Number(filename) + const contentType = getContentType(extension) + if (now < expireAt) { + if (contentType) { + res.setHeader('Content-Type', contentType) + } + createReadStream(join(hashDir, file)).pipe(res) + return { finished: true } + } else { + await promises.unlink(join(hashDir, file)) + } + } + } + + const upstreamRes = await fetch(href) + + if (!upstreamRes.ok) { + res.statusCode = upstreamRes.status + res.end('"url" parameter is valid but upstream response is invalid') + return { finished: true } + } + + const upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer()) + const upstreamType = upstreamRes.headers.get('Content-Type') + const maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control')) + const expireAt = maxAge * 1000 + now + let contentType: string + + if (mimeType) { + contentType = mimeType + } else if (upstreamType?.startsWith('image/') && getExtension(upstreamType)) { + contentType = upstreamType + } else { + contentType = JPEG + } + + if (!sharp) { + try { + // eslint-disable-next-line import/no-extraneous-dependencies + sharp = require('sharp') + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + error.message += + "\nTo use Next.js' built-in Image Optimization, you first need to install `sharp`." + error.message += + '\nRun `npm i sharp` or `yarn add sharp` inside your workspace.' + error.message += '\n\nLearn more: https://err.sh/next.js/install-sharp' + } + throw error + } + } + + const transformer = sharp(upstreamBuffer).resize(width) + + //if (contentType === AVIF) { + // Soon https://github.com/lovell/sharp/issues/2289 + //} + if (contentType === WEBP) { + transformer.webp({ quality }) + } else if (contentType === PNG) { + transformer.png({ quality }) + } else if (contentType === JPEG) { + transformer.jpeg({ quality }) + } + + try { + const optimizedBuffer = await transformer.toBuffer() + await promises.mkdir(hashDir, { recursive: true }) + const extension = getExtension(contentType) + const filename = join(hashDir, `${expireAt}.${extension}`) + await promises.writeFile(filename, optimizedBuffer) + res.setHeader('Content-Type', contentType) + res.end(optimizedBuffer) + } catch (error) { + server.logError(error) + if (upstreamType) { + res.setHeader('Content-Type', upstreamType) + } + res.end(upstreamBuffer) + } + + return { finished: true } +} + +function getHash(items: (string | number | undefined)[]) { + const hash = createHash('sha256') + for (let item of items) { + hash.update(String(item)) + } + // See https://en.wikipedia.org/wiki/Base64#Filenames + return hash.digest('base64').replace(/\//g, '-') +} + +function parseCacheControl(str: string | null): Map { + const map = new Map() + if (!str) { + return map + } + for (let directive of str.split(',')) { + let [key, value] = directive.trim().split('=') + key = key.toLowerCase() + if (value) { + value = value.toLowerCase() + } + map.set(key, value) + } + return map +} + +export function getMaxAge(str: string | null): number { + const minimum = 60 + const map = parseCacheControl(str) + if (map) { + let age = map.get('s-maxage') || map.get('max-age') || '' + if (age.startsWith('"') && age.endsWith('"')) { + age = age.slice(1, -1) + } + const n = parseInt(age, 10) + if (!isNaN(n)) { + return Math.max(n, minimum) + } + } + return minimum +} diff --git a/packages/next/next-server/server/incremental-cache.ts b/packages/next/next-server/server/incremental-cache.ts index 3f1383935f4aedb..0a4bf595d7c40e9 100644 --- a/packages/next/next-server/server/incremental-cache.ts +++ b/packages/next/next-server/server/incremental-cache.ts @@ -10,9 +10,10 @@ function toRoute(pathname: string): string { } type IncrementalCacheValue = { - html: string - pageData: any + html?: string + pageData?: any isStale?: boolean + isNotFound?: boolean curRevalidate?: number | false // milliseconds to revalidate after revalidateAfter: number | false @@ -55,6 +56,7 @@ export class IncrementalCache { version: -1 as any, // letting us know this doesn't conform to spec routes: {}, dynamicRoutes: {}, + notFoundRoutes: [], preview: null as any, // `preview` is special case read in next-dev-server } } else { @@ -67,8 +69,9 @@ export class IncrementalCache { // default to 50MB limit max: max || 50 * 1024 * 1024, length(val) { + if (val.isNotFound) return 25 // rough estimate of size of cache value - return val.html.length + JSON.stringify(val.pageData).length + return val.html!.length + JSON.stringify(val.pageData).length }, }) } @@ -112,6 +115,10 @@ export class IncrementalCache { // let's check the disk for seed data if (!data) { + if (this.prerenderManifest.notFoundRoutes.includes(pathname)) { + return { isNotFound: true, revalidateAfter: false } + } + try { const html = await promises.readFile( this.getSeedPath(pathname, 'html'), @@ -151,8 +158,9 @@ export class IncrementalCache { async set( pathname: string, data: { - html: string - pageData: any + html?: string + pageData?: any + isNotFound?: boolean }, revalidateSeconds?: number | false ) { @@ -178,7 +186,7 @@ export class IncrementalCache { // TODO: This option needs to cease to exist unless it stops mutating the // `next build` output's manifest. - if (this.incrementalOptions.flushToDisk) { + if (this.incrementalOptions.flushToDisk && !data.isNotFound) { try { const seedPath = this.getSeedPath(pathname, 'html') await promises.mkdir(path.dirname(seedPath), { recursive: true }) diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 92b47661022dedd..fe1e5cfcb58f26a 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -79,7 +79,9 @@ import accept from '@hapi/accept' import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path' import { detectLocaleCookie } from '../lib/i18n/detect-locale-cookie' import * as Log from '../../build/output/log' +import { imageOptimizer } from './image-optimizer' import { detectDomainLocale } from '../lib/i18n/detect-domain-locale' +import cookie from 'next/dist/compiled/cookie' const getCustomRouteMatcher = pathMatch(true) @@ -196,6 +198,7 @@ export default class Server { ? requireFontManifest(this.distDir, this._isLikeServerless) : null, optimizeImages: this.nextConfig.experimental.optimizeImages, + defaultLocale: this.nextConfig.experimental.i18n?.defaultLocale, } // Only the `publicRuntimeConfig` key is exposed to the client side @@ -269,7 +272,7 @@ export default class Server { return PHASE_PRODUCTION_SERVER } - private logError(err: Error): void { + public logError(err: Error): void { if (this.onErrorMiddleware) { this.onErrorMiddleware({ err }) } @@ -310,6 +313,10 @@ export default class Server { const { pathname, ...parsed } = parseUrl(req.url || '/') let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) + let acceptPreferredLocale = accept.language( + req.headers['accept-language'], + i18n.locales + ) const detectedDomain = detectDomainLocale(i18n.domains, req) if (detectedDomain) { @@ -317,12 +324,8 @@ export default class Server { detectedLocale = defaultLocale } - if (!detectedLocale) { - detectedLocale = accept.language( - req.headers['accept-language'], - i18n.locales - ) - } + // if not domain specific locale use accept-language preferred + detectedLocale = detectedLocale || acceptPreferredLocale let localeDomainRedirect: string | undefined const localePathResult = normalizeLocalePath(pathname!, i18n.locales) @@ -333,6 +336,7 @@ export default class Server { ...parsed, pathname: localePathResult.pathname, }) + ;(req as any).__nextStrippedLocale = true parsedUrl.pathname = localePathResult.pathname // check if the locale prefix matches a domain's defaultLocale @@ -360,6 +364,7 @@ export default class Server { detectedDefaultLocale && denormalizedPagePath.toLowerCase() === `/${i18n.defaultLocale.toLowerCase()}` + const shouldAddLocalePrefix = !detectedDefaultLocale && denormalizedPagePath === '/' @@ -371,6 +376,28 @@ export default class Server { shouldAddLocalePrefix || shouldStripDefaultLocale) ) { + // set the NEXT_LOCALE cookie when a user visits the default locale + // with the locale prefix so that they aren't redirected back to + // their accept-language preferred locale + if ( + shouldStripDefaultLocale && + acceptPreferredLocale !== defaultLocale + ) { + const previous = res.getHeader('set-cookie') + + res.setHeader('set-cookie', [ + ...(typeof previous === 'string' + ? [previous] + : Array.isArray(previous) + ? previous + : []), + cookie.serialize('NEXT_LOCALE', defaultLocale, { + httpOnly: true, + path: '/', + }), + ]) + } + res.setHeader( 'Location', formatUrl({ @@ -446,6 +473,7 @@ export default class Server { useFileSystemPublicRoutes: boolean dynamicRoutes: DynamicRoutes | undefined } { + const server: Server = this const publicRoutes = fs.existsSync(this.publicDir) ? this.generatePublicRoutes() : [] @@ -563,6 +591,13 @@ export default class Server { } }, }, + { + match: route('/_next/image'), + type: 'route', + name: '_next/image catchall', + fn: (req, res, _params, parsedUrl) => + imageOptimizer(server, req, res, parsedUrl), + }, { match: route('/_next/:path*'), type: 'route', @@ -674,7 +709,7 @@ export default class Server { ) const { query } = parsedDestination - delete parsedDestination.query + delete (parsedDestination as any).query parsedDestination.search = stringifyQs(query, undefined, undefined, { encodeURIComponent: (str: string) => str, @@ -719,7 +754,7 @@ export default class Server { // external rewrite, proxy it if (parsedDestination.protocol) { const { query } = parsedDestination - delete parsedDestination.query + delete (parsedDestination as any).query parsedDestination.search = stringifyQs( query, undefined, @@ -1090,6 +1125,7 @@ export default class Server { ...(components.getStaticProps ? { amp: query.amp, + __next404: query.__next404, _nextDataReq: query._nextDataReq, __nextLocale: query.__nextLocale, } @@ -1215,12 +1251,27 @@ export default class Server { query.amp ? '.amp' : '' }` + // In development we use a __next404 query to allow signaling we should + // render the 404 page after attempting to fetch the _next/data for a + // fallback page since the fallback page will always be available after + // reload and we don't want to re-serve it and instead want to 404. + if (this.renderOpts.dev && isSSG && query.__next404) { + delete query.__next404 + throw new NoFallbackError() + } + // Complete the response with cached data if its present const cachedData = ssgCacheKey ? await this.incrementalCache.get(ssgCacheKey) : undefined if (cachedData) { + if (cachedData.isNotFound) { + // we don't currently revalidate when notFound is returned + // so trigger rendering 404 here + throw new NoFallbackError() + } + const data = isDataReq ? JSON.stringify(cachedData.pageData) : cachedData.html @@ -1265,10 +1316,12 @@ export default class Server { html: string | null pageData: any sprRevalidate: number | false + isNotFound?: boolean }> => { let pageData: any let html: string | null let sprRevalidate: number | false + let isNotFound: boolean | undefined let renderResult // handle serverless @@ -1288,6 +1341,7 @@ export default class Server { html = renderResult.html pageData = renderResult.renderOpts.pageData sprRevalidate = renderResult.renderOpts.revalidate + isNotFound = renderResult.renderOpts.ssgNotFound } else { const origQuery = parseUrl(req.url || '', true).query const resolvedUrl = formatUrl({ @@ -1329,9 +1383,10 @@ export default class Server { // TODO: change this to a different passing mechanism pageData = (renderOpts as any).pageData sprRevalidate = (renderOpts as any).revalidate + isNotFound = (renderOpts as any).ssgNotFound } - return { html, pageData, sprRevalidate } + return { html, pageData, sprRevalidate, isNotFound } } ) @@ -1413,10 +1468,15 @@ export default class Server { const { isOrigin, - value: { html, pageData, sprRevalidate }, + value: { html, pageData, sprRevalidate, isNotFound }, } = await doRender() let resHtml = html - if (!isResSent(res) && (isSSG || isDataReq || isServerProps)) { + + if ( + !isResSent(res) && + !isNotFound && + (isSSG || isDataReq || isServerProps) + ) { sendPayload( req, res, @@ -1441,11 +1501,14 @@ export default class Server { if (isOrigin && ssgCacheKey) { await this.incrementalCache.set( ssgCacheKey, - { html: html!, pageData }, + { html: html!, pageData, isNotFound }, sprRevalidate ) } + if (isNotFound) { + throw new NoFallbackError() + } return resHtml } diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 332823e52612af0..6a5de13378f8298 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -414,6 +414,7 @@ export async function renderToHTML( const isFallback = !!query.__nextFallback delete query.__nextFallback delete query.__nextLocale + delete query.__next404 const isSSG = !!getStaticProps const isBuildTimeSSG = isSSG && renderOpts.nextExport @@ -622,7 +623,10 @@ export async function renderToHTML( const invalidKeys = Object.keys(data).filter( (key) => - key !== 'revalidate' && key !== 'props' && key !== 'unstable_redirect' + key !== 'revalidate' && + key !== 'props' && + key !== 'unstable_redirect' && + key !== 'unstable_notFound' ) if (invalidKeys.includes('unstable_revalidate')) { @@ -633,6 +637,12 @@ export async function renderToHTML( throw new Error(invalidKeysMsg('getStaticProps', invalidKeys)) } + if (data.unstable_notFound) { + ;(renderOpts as any).ssgNotFound = true + ;(renderOpts as any).revalidate = false + return null + } + if ( data.unstable_redirect && typeof data.unstable_redirect === 'object' @@ -899,6 +909,10 @@ export async function renderToHTML( let html = renderDocument(Document, { ...renderOpts, + canonicalBase: + !renderOpts.ampPath && (req as any).__nextStrippedLocale + ? `${renderOpts.canonicalBase || ''}/${renderOpts.locale}` + : renderOpts.canonicalBase, docComponentsRendered, buildManifest: filteredBuildManifest, // Only enabled in production as development mode has features relying on HMR (style injection for example) diff --git a/packages/next/next-server/server/serve-static.ts b/packages/next/next-server/server/serve-static.ts index fccbe65d3254610..52826b92e5f7eb2 100644 --- a/packages/next/next-server/server/serve-static.ts +++ b/packages/next/next-server/server/serve-static.ts @@ -19,3 +19,23 @@ export function serveStatic( .on('finish', resolve) }) } + +export function getContentType(extWithoutDot: string): string | null { + const { mime } = send + if ('getType' in mime) { + // 2.0 + return mime.getType(extWithoutDot) + } + // 1.0 + return (mime as any).lookup(extWithoutDot) +} + +export function getExtension(contentType: string): string | null { + const { mime } = send + if ('getExtension' in mime) { + // 2.0 + return mime.getExtension(contentType) + } + // 1.0 + return (mime as any).extension(contentType) +} diff --git a/packages/next/package.json b/packages/next/package.json index ba0d2dea314cfe8..b86d18b4ae1c5b6 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -30,6 +30,8 @@ "error.d.ts", "head.js", "head.d.ts", + "image.js", + "image.d.ts", "link.js", "link.d.ts", "router.js", @@ -77,10 +79,10 @@ "@babel/runtime": "7.11.2", "@babel/types": "7.11.5", "@hapi/accept": "5.0.1", - "@next/env": "9.5.6-canary.0", - "@next/polyfill-module": "9.5.6-canary.0", - "@next/react-dev-overlay": "9.5.6-canary.0", - "@next/react-refresh-utils": "9.5.6-canary.0", + "@next/env": "9.5.6-canary.4", + "@next/polyfill-module": "9.5.6-canary.4", + "@next/react-dev-overlay": "9.5.6-canary.4", + "@next/react-refresh-utils": "9.5.6-canary.4", "ast-types": "0.13.2", "babel-plugin-transform-define": "2.0.0", "babel-plugin-transform-react-remove-prop-types": "0.4.24", @@ -124,7 +126,7 @@ "react-dom": "^16.6.0" }, "devDependencies": { - "@next/polyfill-nomodule": "9.5.6-canary.0", + "@next/polyfill-nomodule": "9.5.6-canary.4", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", "@taskr/watch": "1.1.0", @@ -156,6 +158,7 @@ "@types/resolve": "0.0.8", "@types/semver": "7.3.1", "@types/send": "0.14.4", + "@types/sharp": "0.26.0", "@types/styled-jsx": "2.2.8", "@types/text-table": "0.2.1", "@types/webpack-sources": "0.1.5", @@ -202,6 +205,7 @@ "resolve": "1.11.0", "semver": "7.3.2", "send": "0.17.1", + "sharp": "0.26.2", "source-map": "0.6.1", "string-hash": "1.1.3", "strip-ansi": "6.0.0", diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index 3b26f7f10b386ea..e89990f38a8598f 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -89,6 +89,7 @@ export type GetStaticPropsResult

= { props?: P revalidate?: number | boolean unstable_redirect?: Redirect + unstable_notFound?: true } export type GetStaticProps< diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index d6dcedf652901f8..7a8137645cab0ae 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": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "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 34a9a0e23ac61dd..6d001559aec28be 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": "9.5.6-canary.0", + "version": "9.5.6-canary.4", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/test/integration/amphtml-ssg/pages/blog/[slug].js b/test/integration/amphtml-ssg/pages/blog/[slug].js index 38f28cd7f387b1b..d93d82ef049d5b1 100644 --- a/test/integration/amphtml-ssg/pages/blog/[slug].js +++ b/test/integration/amphtml-ssg/pages/blog/[slug].js @@ -4,9 +4,10 @@ export const config = { amp: 'hybrid', } -export const getStaticProps = () => { +export const getStaticProps = (ctx) => { return { props: { + slug: ctx.params?.slug || null, hello: 'hello', random: Math.random(), }, @@ -18,10 +19,11 @@ export const getStaticPaths = () => ({ fallback: false, }) -export default ({ hello, random }) => ( +export default ({ hello, random, slug }) => ( <>

useAmp: {useAmp() ? 'yes' : 'no'}

{hello}

{random}

+

{slug}

) diff --git a/test/integration/amphtml-ssg/test/index.test.js b/test/integration/amphtml-ssg/test/index.test.js index e7d8f3a4f201dff..51d7ad463764830 100644 --- a/test/integration/amphtml-ssg/test/index.test.js +++ b/test/integration/amphtml-ssg/test/index.test.js @@ -51,6 +51,7 @@ const runTests = (isDev = false) => { const $ = cheerio.load(html) expect($('#use-amp').text()).toContain('no') expect($('#hello').text()).toContain('hello') + expect($('#slug').text()).toContain('post-1') }) it('should load dynamic hybrid SSG/AMP page with trailing slash', async () => { @@ -58,6 +59,7 @@ const runTests = (isDev = false) => { const $ = cheerio.load(html) expect($('#use-amp').text()).toContain('no') expect($('#hello').text()).toContain('hello') + expect($('#slug').text()).toContain('post-1') }) it('should load dynamic hybrid SSG/AMP page with query', async () => { @@ -65,6 +67,7 @@ const runTests = (isDev = false) => { const $ = cheerio.load(html) expect($('#use-amp').text()).toContain('yes') expect($('#hello').text()).toContain('hello') + expect($('#slug').text()).toContain('post-1') }) it('should load a hybrid amp page with query correctly', async () => { diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index 92bf58533a36762..a1c77da767cb072 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -104,7 +104,7 @@ describe('Build Output', () => { expect(parseFloat(err404FirstLoad) - 64.2).toBeLessThanOrEqual(0) expect(err404FirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(sharedByAll) - 60.7).toBeLessThanOrEqual(0) + expect(parseFloat(sharedByAll) - 60.8).toBeLessThanOrEqual(0) expect(sharedByAll.endsWith('kB')).toBe(true) if (_appSize.endsWith('kB')) { diff --git a/test/integration/i18n-support/pages/amp/amp-first.js b/test/integration/i18n-support/pages/amp/amp-first.js new file mode 100644 index 000000000000000..eeb68575a23d8dc --- /dev/null +++ b/test/integration/i18n-support/pages/amp/amp-first.js @@ -0,0 +1,32 @@ +import { useAmp } from 'next/amp' +import { useRouter } from 'next/router' + +export const config = { + amp: true, +} + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

another page

+

{useAmp() ? 'yes' : 'no'}

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + ) +} + +export const getServerSideProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/pages/amp/amp-hybrid.js b/test/integration/i18n-support/pages/amp/amp-hybrid.js new file mode 100644 index 000000000000000..c693898eb538912 --- /dev/null +++ b/test/integration/i18n-support/pages/amp/amp-hybrid.js @@ -0,0 +1,23 @@ +import { useAmp } from 'next/amp' +import { useRouter } from 'next/router' + +export const config = { + amp: 'hybrid', +} + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

another page

+

{useAmp() ? 'yes' : 'no'}

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + ) +} diff --git a/test/integration/i18n-support/pages/gsp/index.js b/test/integration/i18n-support/pages/gsp/index.js index 8c573d748dcc06a..7bb55bb36ccd547 100644 --- a/test/integration/i18n-support/pages/gsp/index.js +++ b/test/integration/i18n-support/pages/gsp/index.js @@ -21,7 +21,6 @@ export default function Page(props) { ) } -// TODO: should non-dynamic GSP pages pre-render for each locale? export const getStaticProps = ({ locale, locales }) => { return { props: { diff --git a/test/integration/i18n-support/pages/links.js b/test/integration/i18n-support/pages/links.js new file mode 100644 index 000000000000000..9e358b790020634 --- /dev/null +++ b/test/integration/i18n-support/pages/links.js @@ -0,0 +1,54 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + const { nextLocale } = router.query + + return ( + <> + +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to /another + +
+ + to /gsp + +
+ + to /gsp/fallback/first + +
+ + to /gsp/fallback/hello + +
+ + to /gsp/no-fallback/first + +
+ + to /gssp + +
+ + to /gssp/first + +
+ + ) +} + +// make SSR page so we have query values immediately +export const getServerSideProps = () => { + return { + props: {}, + } +} diff --git a/test/integration/i18n-support/pages/not-found/fallback/[slug].js b/test/integration/i18n-support/pages/not-found/fallback/[slug].js new file mode 100644 index 000000000000000..e4e809bc4f32137 --- /dev/null +++ b/test/integration/i18n-support/pages/not-found/fallback/[slug].js @@ -0,0 +1,50 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) return 'Loading...' + + return ( + <> +

gsp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +export const getStaticProps = ({ params, locale, locales }) => { + if (locale === 'en' || locale === 'nl') { + return { + unstable_notFound: true, + } + } + + return { + props: { + params, + locale, + locales, + }, + } +} + +export const getStaticPaths = () => { + return { + // the default locale will be used since one isn't defined here + paths: ['first', 'second'].map((slug) => ({ + params: { slug }, + })), + fallback: true, + } +} diff --git a/test/integration/i18n-support/pages/not-found/index.js b/test/integration/i18n-support/pages/not-found/index.js new file mode 100644 index 000000000000000..18a9bd7996f8329 --- /dev/null +++ b/test/integration/i18n-support/pages/not-found/index.js @@ -0,0 +1,37 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gsp page

+

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +export const getStaticProps = ({ locale, locales }) => { + if (locale === 'en' || locale === 'nl') { + return { + unstable_notFound: true, + } + } + + return { + props: { + locale, + locales, + }, + } +} diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index 00f1bf163ce457a..7ba2761afdf6106 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -5,6 +5,7 @@ import fs from 'fs-extra' import cheerio from 'cheerio' import { join } from 'path' import webdriver from 'next-webdriver' +import escapeRegex from 'escape-string-regexp' import { fetchViaHTTP, findPort, @@ -14,6 +15,8 @@ import { nextStart, renderViaHTTP, File, + waitFor, + normalizeRegEx, } from 'next-test-utils' jest.setTimeout(1000 * 60 * 2) @@ -22,11 +25,296 @@ const appDir = join(__dirname, '../') const nextConfig = new File(join(appDir, 'next.config.js')) let app let appPort -// let buildId +let buildPagesDir +let buildId const locales = ['en-US', 'nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr', 'en'] +async function addDefaultLocaleCookie(browser) { + // make sure default locale is used in case browser isn't set to + // favor en-US by default, (we use all caps to ensure it's case-insensitive) + await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'EN-US' }) + await browser.get(browser.initUrl) +} + function runTests(isDev) { + if (!isDev) { + it('should add i18n config to routes-manifest', async () => { + const routesManifest = await fs.readJSON( + join(appDir, '.next/routes-manifest.json') + ) + + expect(routesManifest.i18n).toEqual({ + locales: ['en-US', 'nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr', 'en'], + defaultLocale: 'en-US', + domains: [ + { + http: true, + domain: 'example.be', + defaultLocale: 'nl-BE', + }, + { + http: true, + domain: 'example.fr', + defaultLocale: 'fr', + }, + ], + }) + }) + + it('should output correct prerender-manifest', async () => { + const prerenderManifest = await fs.readJSON( + join(appDir, '.next/prerender-manifest.json') + ) + + for (const key of Object.keys(prerenderManifest.dynamicRoutes)) { + const item = prerenderManifest.dynamicRoutes[key] + item.routeRegex = normalizeRegEx(item.routeRegex) + item.dataRouteRegex = normalizeRegEx(item.dataRouteRegex) + } + + expect(prerenderManifest.routes).toEqual({ + '/en-US/gsp/fallback/first': { + dataRoute: `/_next/data/${buildId}/en-US/gsp/fallback/first.json`, + initialRevalidateSeconds: false, + srcRoute: '/gsp/fallback/[slug]', + }, + '/en-US/gsp/fallback/second': { + dataRoute: `/_next/data/${buildId}/en-US/gsp/fallback/second.json`, + initialRevalidateSeconds: false, + srcRoute: '/gsp/fallback/[slug]', + }, + '/en-US/gsp/no-fallback/first': { + dataRoute: `/_next/data/${buildId}/en-US/gsp/no-fallback/first.json`, + initialRevalidateSeconds: false, + srcRoute: '/gsp/no-fallback/[slug]', + }, + '/en-US/gsp/no-fallback/second': { + dataRoute: `/_next/data/${buildId}/en-US/gsp/no-fallback/second.json`, + initialRevalidateSeconds: false, + srcRoute: '/gsp/no-fallback/[slug]', + }, + '/en-US/not-found/fallback/first': { + dataRoute: `/_next/data/${buildId}/en-US/not-found/fallback/first.json`, + initialRevalidateSeconds: false, + srcRoute: '/not-found/fallback/[slug]', + }, + '/en-US/not-found/fallback/second': { + dataRoute: `/_next/data/${buildId}/en-US/not-found/fallback/second.json`, + initialRevalidateSeconds: false, + srcRoute: '/not-found/fallback/[slug]', + }, + '/gsp': { + dataRoute: `/_next/data/${buildId}/gsp.json`, + srcRoute: null, + initialRevalidateSeconds: false, + }, + '/nl-NL/gsp/no-fallback/second': { + dataRoute: `/_next/data/${buildId}/nl-NL/gsp/no-fallback/second.json`, + initialRevalidateSeconds: false, + srcRoute: '/gsp/no-fallback/[slug]', + }, + '/not-found': { + dataRoute: `/_next/data/${buildId}/not-found.json`, + srcRoute: null, + initialRevalidateSeconds: false, + }, + }) + expect(prerenderManifest.dynamicRoutes).toEqual({ + '/gsp/fallback/[slug]': { + routeRegex: normalizeRegEx( + '^\\/gsp\\/fallback\\/([^\\/]+?)(?:\\/)?$' + ), + dataRoute: `/_next/data/${buildId}/gsp/fallback/[slug].json`, + fallback: '/gsp/fallback/[slug].html', + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/gsp\\/fallback\\/([^\\/]+?)\\.json$` + ), + }, + '/gsp/no-fallback/[slug]': { + routeRegex: normalizeRegEx( + '^\\/gsp\\/no\\-fallback\\/([^\\/]+?)(?:\\/)?$' + ), + dataRoute: `/_next/data/${buildId}/gsp/no-fallback/[slug].json`, + fallback: false, + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeRegex( + buildId + )}/gsp/no\\-fallback/([^/]+?)\\.json$` + ), + }, + '/not-found/fallback/[slug]': { + dataRoute: `/_next/data/${buildId}/not-found/fallback/[slug].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/not\\-found\\/fallback\\/([^\\/]+?)\\.json$` + ), + fallback: '/not-found/fallback/[slug].html', + routeRegex: normalizeRegEx('^/not\\-found/fallback/([^/]+?)(?:/)?$'), + }, + }) + }) + } + + it('should navigate with locale prop correctly', async () => { + const browser = await webdriver(appPort, '/links?nextLocale=fr') + await addDefaultLocaleCookie(browser) + + expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/links?nextLocale=fr' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ nextLocale: 'fr' }) + + await browser.elementByCss('#to-another').click() + await browser.waitForElementByCss('#another') + + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('fr') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + + let parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/fr/another') + expect(parsedUrl.query).toEqual({}) + + await browser.eval('window.history.back()') + await browser.waitForElementByCss('#links') + + expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/links?nextLocale=fr' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('fr') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ nextLocale: 'fr' }) + + parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/fr/links') + expect(parsedUrl.query).toEqual({ nextLocale: 'fr' }) + + await browser.eval('window.history.forward()') + await browser.waitForElementByCss('#another') + + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/another' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('fr') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({}) + + parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/fr/another') + expect(parsedUrl.query).toEqual({}) + }) + + it('should navigate with locale prop correctly GSP', async () => { + const browser = await webdriver(appPort, '/links?nextLocale=nl') + await addDefaultLocaleCookie(browser) + + expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/links?nextLocale=nl' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ nextLocale: 'nl' }) + + await browser.elementByCss('#to-fallback-first').click() + await browser.waitForElementByCss('#gsp') + + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/gsp/fallback/[slug]' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/gsp/fallback/first' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('nl') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ slug: 'first' }) + + let parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') + expect(parsedUrl.query).toEqual({}) + + await browser.eval('window.history.back()') + await browser.waitForElementByCss('#links') + + expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/links?nextLocale=nl' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('nl') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ nextLocale: 'nl' }) + + parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/nl/links') + expect(parsedUrl.query).toEqual({ nextLocale: 'nl' }) + + await browser.eval('window.history.forward()') + await browser.waitForElementByCss('#gsp') + + expect(await browser.elementByCss('#router-pathname').text()).toBe( + '/gsp/fallback/[slug]' + ) + expect(await browser.elementByCss('#router-as-path').text()).toBe( + '/gsp/fallback/first' + ) + expect(await browser.elementByCss('#router-locale').text()).toBe('nl') + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect( + JSON.parse(await browser.elementByCss('#router-query').text()) + ).toEqual({ slug: 'first' }) + + parsedUrl = url.parse(await browser.eval('window.location.href'), true) + expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') + expect(parsedUrl.query).toEqual({}) + }) + it('should update asPath on the client correctly', async () => { for (const check of ['en', 'En']) { const browser = await webdriver(appPort, `/${check}`) @@ -132,6 +420,43 @@ function runTests(isDev) { expect(result2.query).toEqual({}) }) + it('should set locale cookie when removing default locale and accept-lang doesnt match', async () => { + const res = await fetchViaHTTP(appPort, '/en-US', undefined, { + headers: { + 'accept-language': 'nl', + }, + redirect: 'manual', + }) + + expect(res.status).toBe(307) + + const parsedUrl = url.parse(res.headers.get('location'), true) + expect(parsedUrl.pathname).toBe('/') + expect(parsedUrl.query).toEqual({}) + expect(res.headers.get('set-cookie')).toContain('NEXT_LOCALE=en-US') + }) + + it('should not redirect to accept-lang preferred locale with locale cookie', async () => { + const res = await fetchViaHTTP(appPort, '/', undefined, { + headers: { + 'accept-language': 'nl', + cookie: 'NEXT_LOCALE=en-US', + }, + redirect: 'manual', + }) + + expect(res.status).toBe(200) + + const html = await res.text() + const $ = cheerio.load(html) + + expect($('#router-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('html').attr('lang')).toBe('en-US') + expect($('#router-pathname').text()).toBe('/') + expect($('#router-as-path').text()).toBe('/') + }) + it('should redirect to correct locale domain', async () => { const checks = [ // test domain, locale prefix, redirect result @@ -191,6 +516,53 @@ function runTests(isDev) { await checkDomainLocales('fr', 'example.fr') }) + it('should generate AMP pages with all locales', async () => { + for (const locale of locales) { + const localePath = locale !== 'en-US' ? `/${locale}` : '' + const html = await renderViaHTTP(appPort, `${localePath}/amp/amp-hybrid`) + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + expect($('#is-amp').text()).toBe('no') + expect($('#router-locale').text()).toBe(locale) + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('#router-pathname').text()).toBe('/amp/amp-hybrid') + expect($('#router-as-path').text()).toBe('/amp/amp-hybrid') + expect(JSON.parse($('#router-query').text())).toEqual({}) + + const amphtmlPath = `${localePath}/amp/amp-hybrid${ + isDev ? '?amp=1' : '.amp' + }` + expect($('link[rel=amphtml]').attr('href')).toBe(amphtmlPath) + + const html2 = await renderViaHTTP(appPort, amphtmlPath) + const $2 = cheerio.load(html2) + expect($2('html').attr('lang')).toBe(locale) + expect($2('#is-amp').text()).toBe('yes') + expect($2('#router-locale').text()).toBe(locale) + expect(JSON.parse($2('#router-locales').text())).toEqual(locales) + expect($2('#router-pathname').text()).toBe('/amp/amp-hybrid') + expect($2('#router-as-path').text()).toBe('/amp/amp-hybrid') + expect(JSON.parse($2('#router-query').text())).toEqual({ amp: '1' }) + expect($2('link[rel=amphtml]').attr('href')).toBeFalsy() + } + }) + + it('should work with AMP first page with all locales', async () => { + for (const locale of locales) { + const localePath = locale !== 'en-US' ? `/${locale}` : '' + const html = await renderViaHTTP(appPort, `${localePath}/amp/amp-first`) + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(locale) + expect($('#is-amp').text()).toBe('yes') + expect($('#router-locale').text()).toBe(locale) + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('#router-pathname').text()).toBe('/amp/amp-first') + expect($('#router-as-path').text()).toBe('/amp/amp-first') + expect(JSON.parse($('#router-query').text())).toEqual({}) + expect($('link[rel=amphtml]').attr('href')).toBeFalsy() + } + }) + it('should generate fallbacks with all locales', async () => { for (const locale of locales) { const html = await renderViaHTTP( @@ -222,7 +594,7 @@ function runTests(isDev) { } }) - it('should generate non-dynamic SSG page with all locales', async () => { + it('should generate non-dynamic GSP page with all locales', async () => { for (const locale of locales) { const html = await renderViaHTTP(appPort, `/${locale}/gsp`) const $ = cheerio.load(html) @@ -243,8 +615,90 @@ function runTests(isDev) { } }) - // TODO: SSG 404 behavior to opt-out of generating specific locale - // for non-dynamic SSG pages + if (!isDev) { + it('should not output GSP pages that returned notFound', async () => { + const skippedLocales = ['en', 'nl'] + + for (const locale of locales) { + const pagePath = join(buildPagesDir, locale, 'not-found.html') + const dataPath = join(buildPagesDir, locale, 'not-found.json') + console.log(pagePath) + expect(await fs.exists(pagePath)).toBe(!skippedLocales.includes(locale)) + expect(await fs.exists(dataPath)).toBe(!skippedLocales.includes(locale)) + } + }) + } + + it('should 404 for GSP pages that returned notFound', async () => { + const skippedLocales = ['en', 'nl'] + + for (const locale of locales) { + const res = await fetchViaHTTP(appPort, `/${locale}/not-found`) + expect(res.status).toBe(skippedLocales.includes(locale) ? 404 : 200) + + if (skippedLocales.includes(locale)) { + const browser = await webdriver(appPort, `/${locale}/not-found`) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + locale + ) + expect( + await browser.eval('document.documentElement.innerHTML') + ).toContain('This page could not be found') + + const parsedUrl = url.parse( + await browser.eval('window.location.href'), + true + ) + expect(parsedUrl.pathname).toBe(`/${locale}/not-found`) + expect(parsedUrl.query).toEqual({}) + } + } + }) + + it('should 404 for GSP that returned notFound on client-transition', async () => { + const browser = await webdriver(appPort, '/en') + await browser.eval(`(function() { + window.beforeNav = 1 + window.next.router.push('/not-found') + })()`) + + await browser.waitForElementByCss('h1') + + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + expect(await browser.elementByCss('html').text()).toContain( + 'This page could not be found' + ) + expect(await browser.eval('window.beforeNav')).toBe(null) + }) + + it('should render 404 for fallback page that returned 404', async () => { + const browser = await webdriver( + appPort, + '/en/not-found/fallback/first', + true, + true + ) + await browser.waitForElementByCss('h1') + await browser.eval('window.beforeNav = 1') + + expect(await browser.elementByCss('html').text()).toContain( + 'This page could not be found' + ) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + + const parsedUrl = url.parse( + await browser.eval('window.location.href'), + true + ) + expect(parsedUrl.pathname).toBe('/en/not-found/fallback/first') + expect(parsedUrl.query).toEqual(isDev ? { __next404: '1' } : {}) + + if (isDev) { + // make sure page doesn't reload un-necessarily in development + await waitFor(10 * 1000) + } + expect(await browser.eval('window.beforeNav')).toBe(1) + }) it('should remove un-necessary locale prefix for default locale', async () => { const res = await fetchViaHTTP(appPort, '/en-US', undefined, { @@ -472,10 +926,7 @@ function runTests(isDev) { it('should navigate client side for default locale with no prefix', async () => { const browser = await webdriver(appPort, '/') - // make sure default locale is used in case browser isn't set to - // favor en-US by default, (we use all caps to ensure it's case-insensitive) - await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'EN-US' }) - await browser.get(browser.initUrl) + await addDefaultLocaleCookie(browser) const checkIndexValues = async () => { expect(await browser.elementByCss('#router-locale').text()).toBe('en-US') @@ -796,7 +1247,8 @@ describe('i18n Support', () => { await nextBuild(appDir) appPort = await findPort() app = await nextStart(appDir, appPort) - // buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') + buildPagesDir = join(appDir, '.next/server/pages') + buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') }) afterAll(() => killApp(app)) @@ -811,7 +1263,8 @@ describe('i18n Support', () => { await nextBuild(appDir) appPort = await findPort() app = await nextStart(appDir, appPort) - // buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') + buildPagesDir = join(appDir, '.next/serverless/pages') + buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') }) afterAll(async () => { nextConfig.restore() diff --git a/test/integration/image-component/bad-next-config/test/index.test.js b/test/integration/image-component/bad-next-config/test/index.test.js index 9914cc0ca2c1b3f..da8c8963c6fbd47 100644 --- a/test/integration/image-component/bad-next-config/test/index.test.js +++ b/test/integration/image-component/bad-next-config/test/index.test.js @@ -16,13 +16,13 @@ describe('Next.config.js images prop without default host', () => { nextConfig, `module.exports = { images: { + sizes: [480, 1024, 1600], hosts: { secondary: { path: 'https://examplesecondary.com/images/', loader: 'cloudinary', }, }, - breakpoints: [480, 1024, 1600], }, }`, 'utf8' @@ -46,6 +46,7 @@ describe('Next.config.js images prop without path', () => { nextConfig, `module.exports = { images: { + sizes: [480, 1024, 1600], hosts: { default: { path: 'https://examplesecondary.com/images/', @@ -55,7 +56,6 @@ describe('Next.config.js images prop without path', () => { loader: 'cloudinary', }, }, - breakpoints: [480, 1024, 1600], }, }`, 'utf8' diff --git a/test/integration/image-component/basic/next.config.js b/test/integration/image-component/basic/next.config.js index 28c2e06113c5b01..8ada091227bd4a1 100644 --- a/test/integration/image-component/basic/next.config.js +++ b/test/integration/image-component/basic/next.config.js @@ -1,5 +1,6 @@ module.exports = { images: { + sizes: [480, 1024, 1600], hosts: { default: { path: 'https://example.com/myaccount/', @@ -10,6 +11,5 @@ module.exports = { loader: 'cloudinary', }, }, - breakpoints: [480, 1024, 1600], }, } diff --git a/test/integration/image-optimizer/next.config.js b/test/integration/image-optimizer/next.config.js new file mode 100644 index 000000000000000..6b05babba937329 --- /dev/null +++ b/test/integration/image-optimizer/next.config.js @@ -0,0 +1,2 @@ +// prettier-ignore +module.exports = { /* replaceme */ } diff --git a/test/integration/image-optimizer/pages/index.js b/test/integration/image-optimizer/pages/index.js new file mode 100644 index 000000000000000..ff208d477174466 --- /dev/null +++ b/test/integration/image-optimizer/pages/index.js @@ -0,0 +1,5 @@ +function Home() { + return

Image Optimizer Home

+} + +export default Home diff --git a/test/integration/image-optimizer/public/test.bmp b/test/integration/image-optimizer/public/test.bmp new file mode 100644 index 000000000000000..f33feda8616b735 Binary files /dev/null and b/test/integration/image-optimizer/public/test.bmp differ diff --git a/test/integration/image-optimizer/public/test.gif b/test/integration/image-optimizer/public/test.gif new file mode 100644 index 000000000000000..6bbbd315e9fe876 Binary files /dev/null and b/test/integration/image-optimizer/public/test.gif differ diff --git a/test/integration/image-optimizer/public/test.jpg b/test/integration/image-optimizer/public/test.jpg new file mode 100644 index 000000000000000..d536c882412ed3d Binary files /dev/null and b/test/integration/image-optimizer/public/test.jpg differ diff --git a/test/integration/image-optimizer/public/test.png b/test/integration/image-optimizer/public/test.png new file mode 100644 index 000000000000000..e14fafc5cf3bc63 Binary files /dev/null and b/test/integration/image-optimizer/public/test.png differ diff --git a/test/integration/image-optimizer/public/test.svg b/test/integration/image-optimizer/public/test.svg new file mode 100644 index 000000000000000..025d874f92e6a3c --- /dev/null +++ b/test/integration/image-optimizer/public/test.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/test/integration/image-optimizer/public/test.tiff b/test/integration/image-optimizer/public/test.tiff new file mode 100644 index 000000000000000..c2cc3e203bb3fdb Binary files /dev/null and b/test/integration/image-optimizer/public/test.tiff differ diff --git a/test/integration/image-optimizer/test/get-max-age.test.js b/test/integration/image-optimizer/test/get-max-age.test.js new file mode 100644 index 000000000000000..b790a456228bd0a --- /dev/null +++ b/test/integration/image-optimizer/test/get-max-age.test.js @@ -0,0 +1,44 @@ +/* eslint-env jest */ +import { getMaxAge } from '../../../../packages/next/dist/next-server/server/image-optimizer.js' + +describe('getMaxAge', () => { + it('should return default when no cache-control provided', () => { + expect(getMaxAge()).toBe(60) + }) + it('should return default when cache-control is null', () => { + expect(getMaxAge(null)).toBe(60) + }) + it('should return default when cache-control is empty string', () => { + expect(getMaxAge('')).toBe(60) + }) + it('should return default when cache-control max-age is less than default', () => { + expect(getMaxAge('max-age=30')).toBe(60) + }) + it('should return default when cache-control max-age is not a number', () => { + expect(getMaxAge('max-age=foo')).toBe(60) + }) + it('should return default when cache-control is no-cache', () => { + expect(getMaxAge('no-cache')).toBe(60) + }) + it('should return cache-control max-age lowercase', () => { + expect(getMaxAge('max-age=9999')).toBe(9999) + }) + it('should return cache-control MAX-AGE uppercase', () => { + expect(getMaxAge('MAX-AGE=9999')).toBe(9999) + }) + it('should return cache-control s-maxage lowercase', () => { + expect(getMaxAge('s-maxage=9999')).toBe(9999) + }) + it('should return cache-control S-MAXAGE', () => { + expect(getMaxAge('S-MAXAGE=9999')).toBe(9999) + }) + it('should return cache-control s-maxage with spaces', () => { + expect(getMaxAge('public, max-age=5555, s-maxage=9999')).toBe(9999) + }) + it('should return cache-control s-maxage without spaces', () => { + expect(getMaxAge('public,s-maxage=9999,max-age=5555')).toBe(9999) + }) + it('should return cache-control for a quoted value', () => { + expect(getMaxAge('public, s-maxage="9999", max-age="5555"')).toBe(9999) + }) +}) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js new file mode 100644 index 000000000000000..73233695b4e1cc2 --- /dev/null +++ b/test/integration/image-optimizer/test/index.test.js @@ -0,0 +1,348 @@ +/* eslint-env jest */ +import fs from 'fs-extra' +import { join } from 'path' +import { + killApp, + findPort, + launchApp, + fetchViaHTTP, + nextBuild, + nextStart, + File, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '../') +const imagesDir = join(appDir, '.next', 'cache', 'images') +const nextConfig = new File(join(appDir, 'next.config.js')) +let appPort +let app + +async function fsToJson(dir, output = {}) { + const files = await fs.readdir(dir) + for (let file of files) { + const fsPath = join(dir, file) + const stat = await fs.stat(fsPath) + if (stat.isDirectory()) { + output[file] = {} + await fsToJson(fsPath, output[file]) + } else { + output[file] = 'file' + } + } + return output +} + +function runTests({ w, isDev }) { + it('should return home page', async () => { + const res = await fetchViaHTTP(appPort, '/', null, {}) + expect(await res.text()).toMatch(/Image Optimizer Home/m) + }) + + it('should fail when url is missing', async () => { + const query = { w, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is required`) + }) + + it('should fail when w is missing', async () => { + const query = { url: '/test.png', q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"w" parameter (width) is required`) + }) + + it('should fail when q is missing', async () => { + const query = { url: '/test.png', w } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"q" parameter (quality) is required`) + }) + + it('should fail when q is greater than 100', async () => { + const query = { url: '/test.png', w, q: 101 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + it('should fail when q is less than 1', async () => { + const query = { url: '/test.png', w, q: 0 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + it('should fail when w is 0 or less', async () => { + const query = { url: '/test.png', w: 0, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) must be a number greater than 0` + ) + }) + + it('should fail when w is not a number', async () => { + const query = { url: '/test.png', w: 'foo', q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) must be a number greater than 0` + ) + }) + + it('should fail when q is not a number', async () => { + const query = { url: '/test.png', w, q: 'foo' } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + if (!isDev) { + it('should fail when domain is not defined in next.config.js', async () => { + const url = `http://vercel.com/button` + const query = { url, w, q: 100 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is not allowed`) + }) + } + + it('should fail when width is not in next.config.js', async () => { + const query = { url: '/test.png', w: 1000, q: 100 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) of 1000 is not allowed` + ) + }) + + it('should resize relative url and webp accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should resize relative url and jpeg accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/jpeg' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + }) + + it('should resize relative url and png accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/png' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/png') + }) + + it('should resize relative url with invalid accept header as png', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/png') + }) + + it('should resize relative url with invalid accept header as gif', async () => { + const query = { url: '/test.gif', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/gif') + }) + + it('should resize relative url with invalid accept header as svg', async () => { + const query = { url: '/test.svg', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/svg+xml') + }) + + it('should resize relative url with invalid accept header as tiff', async () => { + const query = { url: '/test.tiff', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/tiff') + }) + + it('should resize relative url and wildcard accept header as webp', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/*' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should resize absolute url from localhost', async () => { + const url = `http://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should fail when url has file protocol', async () => { + const url = `file://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is invalid`) + }) + + it('should fail when url has ftp protocol', async () => { + const url = `ftp://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is invalid`) + }) + + it('should fail when url fails to load an image', async () => { + const url = `http://localhost:${appPort}/not-an-image` + const query = { w, url, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(404) + expect(await res.text()).toBe( + `"url" parameter is valid but upstream response is invalid` + ) + }) + + it('should use cached image file when parameters are the same', async () => { + await fs.remove(imagesDir) + + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + + const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res1.status).toBe(200) + expect(res1.headers.get('Content-Type')).toBe('image/webp') + const json1 = await fsToJson(imagesDir) + expect(Object.keys(json1).length).toBe(1) + + const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res2.status).toBe(200) + expect(res2.headers.get('Content-Type')).toBe('image/webp') + const json2 = await fsToJson(imagesDir) + expect(json2).toStrictEqual(json1) + }) + + it('should proxy-pass unsupported image types and should not cache file', async () => { + const json1 = await fsToJson(imagesDir) + expect(json1).toBeTruthy() + + const query = { url: '/test.bmp', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/bmp') + + const json2 = await fsToJson(imagesDir) + expect(json2).toStrictEqual(json1) + }) +} + +describe('Image Optimizer', () => { + describe('dev support w/o next.config.js', () => { + const size = 768 // defaults defined in server/config.ts + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true }) + }) + + describe('dev support with next.config.js', () => { + const size = 64 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + sizes: [size], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true }) + }) + + describe('Server support with next.config.js', () => { + const size = 128 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + sizes: [128], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false }) + }) + + describe('Serverless support with next.config.js', () => { + const size = 256 + beforeAll(async () => { + const json = JSON.stringify({ + target: 'experimental-serverless-trace', + images: { + sizes: [size], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false }) + }) +}) diff --git a/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/package.json b/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/package.json deleted file mode 100644 index 4525eb1b1592cdb..000000000000000 --- a/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@zeit/next-plugin-scope", - "version": "0.0.1", - "nextjs": { - "name": "Scoped Package Test", - "required-env": [] - }, - "peerDependencies": { - "next": "*" - } -} diff --git a/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/src/on-init-client.js b/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/src/on-init-client.js deleted file mode 100644 index 8b056831d809f6e..000000000000000 --- a/test/integration/next-plugins/app/node_modules/@zeit/next-plugin-scope/src/on-init-client.js +++ /dev/null @@ -1,3 +0,0 @@ -export default async function initClient() { - window.didScopedInit = true -} \ No newline at end of file diff --git a/test/integration/next-plugins/app/node_modules/next-plugin-normal/package.json b/test/integration/next-plugins/app/node_modules/next-plugin-normal/package.json deleted file mode 100644 index 590a3cda9afc57e..000000000000000 --- a/test/integration/next-plugins/app/node_modules/next-plugin-normal/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "next-plugin-normal", - "version": "0.0.1", - "nextjs": { - "name": "Normal Package Test", - "required-env": [] - }, - "peerDependencies": { - "next": "*" - } -} diff --git a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-with-ext.js/index.js b/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-with-ext.js/index.js deleted file mode 100644 index 4cfbbe36f2f9870..000000000000000 --- a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-with-ext.js/index.js +++ /dev/null @@ -1 +0,0 @@ -throw new Error('This file should never be loaded') diff --git a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-without-ext/index.js b/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-without-ext/index.js deleted file mode 100644 index 4cfbbe36f2f9870..000000000000000 --- a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/dir-without-ext/index.js +++ /dev/null @@ -1 +0,0 @@ -throw new Error('This file should never be loaded') diff --git a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/on-init-client.js b/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/on-init-client.js deleted file mode 100644 index 7b6f96c57001c1f..000000000000000 --- a/test/integration/next-plugins/app/node_modules/next-plugin-normal/src/on-init-client.js +++ /dev/null @@ -1,3 +0,0 @@ -export default async function initClient() { - window.didNormalInit = true -} \ No newline at end of file diff --git a/test/integration/next-plugins/app/package.json b/test/integration/next-plugins/app/package.json index 3a9403050e037b9..ae559b125d822d4 100644 --- a/test/integration/next-plugins/app/package.json +++ b/test/integration/next-plugins/app/package.json @@ -1,7 +1,5 @@ { "dependencies": { - "@next/plugin-google-analytics": "latest", - "@zeit/next-plugin-scope": "latest", - "next-plugin-normal": "latest" + "@next/plugin-google-analytics": "latest" } } diff --git a/test/integration/next-plugins/test/index.test.js b/test/integration/next-plugins/test/index.test.js index 433bee3d6b02c9a..51189da43f6b1be 100644 --- a/test/integration/next-plugins/test/index.test.js +++ b/test/integration/next-plugins/test/index.test.js @@ -44,8 +44,6 @@ function runTests() { it('should list loaded plugins', async () => { expect(stdout).toMatch(/loaded plugin: @next\/plugin-google-analytics/i) - expect(stdout).toMatch(/loaded plugin: @zeit\/next-plugin-scope/i) - expect(stdout).toMatch(/loaded plugin: next-plugin-normal/i) }) it('should ignore directories in plugins', async () => { @@ -111,8 +109,6 @@ describe('Next.js plugins', () => { it('should disable auto detecting plugins when plugin config is used', async () => { expect(stdout).toMatch(/loaded plugin: @next\/plugin-google-analytics/i) - expect(stdout).not.toMatch(/loaded plugin: @zeit\/next-plugin-scope/i) - expect(stdout).not.toMatch(/loaded plugin: next-plugin-normal/i) }) it('should expose a plugins config', async () => { diff --git a/test/integration/relay-analytics-disabled/pages/_app.js b/test/integration/relay-analytics-disabled/pages/_app.js new file mode 100644 index 000000000000000..5cc9a4d48488850 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/_app.js @@ -0,0 +1,15 @@ +/* global localStorage */ +/* eslint-disable camelcase */ +import App from 'next/app' + +export default class MyApp extends App {} + +/* + Method is experimental and will eventually be handled in a Next.js plugin +*/ +export function reportWebVitals(data) { + localStorage.setItem( + data.name || data.entryType, + data.value !== undefined ? data.value : data.startTime + ) +} diff --git a/test/integration/relay-analytics-disabled/pages/index.js b/test/integration/relay-analytics-disabled/pages/index.js new file mode 100644 index 000000000000000..46c828a80fd0d44 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/index.js @@ -0,0 +1,25 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + +export default () => { + return ( +
+

Foo!

+

bar!

+
+ ) +} diff --git a/test/integration/relay-analytics-disabled/test/index.test.js b/test/integration/relay-analytics-disabled/test/index.test.js new file mode 100644 index 000000000000000..9a5edbaa9080472 --- /dev/null +++ b/test/integration/relay-analytics-disabled/test/index.test.js @@ -0,0 +1,72 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { findPort, killApp, nextBuild, nextStart } from 'next-test-utils' +import webdriver from 'next-webdriver' +import path, { join } from 'path' + +const appDir = join(__dirname, '../') +let appPort +let server +jest.setTimeout(1000 * 60 * 2) + +let buildManifest + +describe('Analytics relayer (disabled)', () => { + let stdout + beforeAll(async () => { + appPort = await findPort() + ;({ stdout } = await nextBuild(appDir, [], { + stdout: true, + })) + buildManifest = require(path.join( + appDir, + '.next/build-manifest.json' + ), 'utf8') + server = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(server)) + + it('Does not relay any data', async () => { + const browser = await webdriver(appPort, '/') + await browser.waitForElementByCss('h1') + const h1Text = await browser.elementByCss('h1').text() + const firstContentfulPaint = parseFloat( + await browser.eval('localStorage.getItem("FCP")') + ) + + expect(h1Text).toMatch(/Foo!/) + + expect(firstContentfulPaint).not.toBeNaN() + expect(firstContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + expect(beacons.length).toBe(0) + + expect(stdout).not.toMatch('Next.js Analytics') + + await browser.close() + }) + + it('Does not include the code', async () => { + const pageFiles = [ + ...new Set([ + ...buildManifest.pages['/'].filter((file) => file.endsWith('.js')), + ...buildManifest.pages['/_app'].filter((file) => file.endsWith('.js')), + ]), + ] + + expect(pageFiles.length).toBeGreaterThan(1) + + for (const pageFile of pageFiles) { + const content = await fs.readFile( + path.join(appDir, '.next', pageFile), + 'utf8' + ) + expect(content).not.toMatch('vercel-analytics') + } + }) +}) diff --git a/test/integration/relay-analytics/pages/index.js b/test/integration/relay-analytics/pages/index.js index 5730772dc4b9bb4..46c828a80fd0d44 100644 --- a/test/integration/relay-analytics/pages/index.js +++ b/test/integration/relay-analytics/pages/index.js @@ -1,3 +1,20 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + export default () => { return (
diff --git a/test/integration/relay-analytics/test/index.test.js b/test/integration/relay-analytics/test/index.test.js index 18adc1501ca9c6f..38c87d816ea5a51 100644 --- a/test/integration/relay-analytics/test/index.test.js +++ b/test/integration/relay-analytics/test/index.test.js @@ -10,9 +10,13 @@ let server jest.setTimeout(1000 * 60 * 2) describe('Analytics relayer', () => { + let stdout beforeAll(async () => { appPort = await findPort() - await nextBuild(appDir) + ;({ stdout } = await nextBuild(appDir, [], { + env: { VERCEL_ANALYTICS_ID: 'test' }, + stdout: true, + })) server = await nextStart(appDir, appPort) }) afterAll(() => killApp(server)) @@ -54,6 +58,35 @@ describe('Analytics relayer', () => { expect(cls).not.toBeNull() expect(largestContentfulPaint).not.toBeNaN() expect(largestContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + beacons.sort((a, b) => a.event_name.localeCompare(b.event_name)) + + expect(beacons.length).toBe(2) + expect(beacons[0]).toMatchObject({ + dsn: 'test', + event_name: 'FCP', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + expect(beacons[1]).toMatchObject({ + dsn: 'test', + event_name: 'TTFB', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + + expect(stdout).toMatch('Next.js Analytics') + await browser.close() }) }) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 02b215d6ad01b1b..dc5a102f9ed7b40 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -80,7 +80,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 280 * 1024 + const delta = responseSizesBytes - 281 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) diff --git a/test/lib/next-webdriver.d.ts b/test/lib/next-webdriver.d.ts index e366c58c9c65781..fe87e0363ab763b 100644 --- a/test/lib/next-webdriver.d.ts +++ b/test/lib/next-webdriver.d.ts @@ -24,5 +24,6 @@ interface Chain { export default function ( appPort: number, path: string, - waitHydration?: boolean + waitHydration?: boolean, + allowHydrationRetry?: boolean ): Promise diff --git a/test/lib/next-webdriver.js b/test/lib/next-webdriver.js index 9ad87b8b2237532..2d663205ccf896c 100644 --- a/test/lib/next-webdriver.js +++ b/test/lib/next-webdriver.js @@ -152,7 +152,12 @@ const freshWindow = async () => { await browser.switchTo().window(newWindow) } -export default async (appPort, path, waitHydration = true) => { +export default async ( + appPort, + path, + waitHydration = true, + allowHydrationRetry = false +) => { if (!initialWindow) { initialWindow = await browser.getWindowHandle() } @@ -176,24 +181,41 @@ export default async (appPort, path, waitHydration = true) => { // Wait for application to hydrate if (waitHydration) { console.log(`\n> Waiting hydration for ${url}\n`) - await browser.executeAsyncScript(function () { - var callback = arguments[arguments.length - 1] - // if it's not a Next.js app return - if (document.documentElement.innerHTML.indexOf('__NEXT_DATA__') === -1) { - callback() - } + const checkHydrated = async () => { + await browser.executeAsyncScript(function () { + var callback = arguments[arguments.length - 1] - if (window.__NEXT_HYDRATED) { - callback() - } else { - var timeout = setTimeout(callback, 10 * 1000) - window.__NEXT_HYDRATED_CB = function () { - clearTimeout(timeout) + // if it's not a Next.js app return + if ( + document.documentElement.innerHTML.indexOf('__NEXT_DATA__') === -1 + ) { + callback() + } + + if (window.__NEXT_HYDRATED) { callback() + } else { + var timeout = setTimeout(callback, 10 * 1000) + window.__NEXT_HYDRATED_CB = function () { + clearTimeout(timeout) + callback() + } } + }) + } + + try { + await checkHydrated() + } catch (err) { + if (allowHydrationRetry) { + // re-try in case the page reloaded during check + await checkHydrated() + } else { + throw err } - }) + } + console.log(`\n> Hydration complete for ${url}\n`) } diff --git a/yarn.lock b/yarn.lock index 86363b87b344fde..e9616c3cb7a1adb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3392,6 +3392,13 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/sharp@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.26.0.tgz#2fa8419dbdaca8dd38f73888b27b207f188a8669" + integrity sha512-oJrR8eiwpL7qykn2IeFRduXM4za7z+7yOUEbKVtuDQ/F6htDLHYO6IbzhaJQHV5n6O3adIh4tJvtgPyLyyydqg== + dependencies: + "@types/node" "*" + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -5358,6 +5365,14 @@ color-string@^1.5.2: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" + integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color@^0.11.0: version "0.11.4" resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" @@ -5373,6 +5388,14 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" +color@^3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" + integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.4" + colormin@^1.0.5: version "1.1.2" resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" @@ -6360,6 +6383,13 @@ decompress-response@^5.0.0: dependencies: mimic-response "^2.0.0" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -11175,6 +11205,11 @@ mimic-response@^2.0.0, mimic-response@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -11213,7 +11248,7 @@ minimist-options@^4.0.2: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" @@ -11299,6 +11334,11 @@ mk-dirs@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mk-dirs/-/mk-dirs-1.0.0.tgz#44ee67f82341c6762718e88e85e577882e1f67fd" +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" @@ -11456,6 +11496,11 @@ node-addon-api@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492" +node-addon-api@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" + integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== + node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -13544,6 +13589,27 @@ prebuild-install@^5.3.2: tunnel-agent "^0.6.0" which-pm-runs "^1.0.0" +prebuild-install@^5.3.5: + version "5.3.5" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.5.tgz#e7e71e425298785ea9d22d4f958dbaccf8bb0e1b" + integrity sha512-YmMO7dph9CYKi5IR/BzjOJlRzpxGGVo1EsLSUZ0mt/Mq0HWZIHOKHHcHdT69yG54C9m6i45GpItwRHpk0Py7Uw== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp "^0.5.1" + napi-build-utils "^1.0.1" + node-abi "^2.7.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + which-pm-runs "^1.0.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -15111,6 +15177,21 @@ shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" +sharp@0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.26.2.tgz#3d5777d246ae32890afe82a783c1cbb98456a88c" + integrity sha512-bGBPCxRAvdK9bX5HokqEYma4j/Q5+w8Nrmb2/sfgQCLEUx/HblcpmOfp59obL3+knIKnOhyKmDb4tEOhvFlp6Q== + dependencies: + color "^3.1.2" + detect-libc "^1.0.3" + node-addon-api "^3.0.2" + npmlog "^4.1.2" + prebuild-install "^5.3.5" + semver "^7.3.2" + simple-get "^4.0.0" + tar-fs "^2.1.0" + tunnel-agent "^0.6.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -15155,6 +15236,15 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" +simple-get@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675" + integrity sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -15884,6 +15974,16 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.0.0" +tar-fs@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5" + integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + tar-stream@2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.3.tgz#1e2022559221b7866161660f118255e20fa79e41"