diff --git a/docs/advanced-features/module-path-aliases.md b/docs/advanced-features/module-path-aliases.md index 2c2c0e20811a..1641c798ad14 100644 --- a/docs/advanced-features/module-path-aliases.md +++ b/docs/advanced-features/module-path-aliases.md @@ -4,6 +4,13 @@ description: Configure module path aliases that allow you to remap certain impor # Absolute Imports and Module path aliases +
+ Examples + +
+ Next.js automatically supports the `tsconfig.json` and `jsconfig.json` `"paths"` and `"baseUrl"` options since [Next.js 9.4](https://nextjs.org/blog/next-9-4). > Note: `jsconfig.json` can be used when you don't use TypeScript diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 8fa4a86e7538..10662adc8525 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -1,5 +1,5 @@ --- -description: Enable image optimization with the built-in Image component. +description: Enable Image Optimization with the built-in Image component. --- # next/image @@ -29,7 +29,12 @@ function Home() { return ( <>

My Homepage

- me + Picture of the author

Welcome to my homepage!

) @@ -48,6 +53,6 @@ export default Home - `loading` - The loading behavior. When `lazy`, defer loading the image until it reaches a calculated distance from the viewport. When `eager`, load the image immediately. Default `lazy`. [More info](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading) - `priority` - When true, the image will be considered high priority and [preload](https://web.dev/preload-responsive-images/). - `unoptimized` - When true, the source image will be served as-is instead of resizing and changing quality. -- `unsized` - When true, the `width` and `height` requirement can by bypassed. Should _not_ be used with `priority` or above-the-fold images. +- `unsized` - When true, the `width` and `height` requirement can by bypassed. Should _not_ be used with above-the-fold images. Should _not_ be used with `priority`. -Another other properties on the `` component be passed to the underlying `` element. +All other properties on the `` component will be passed to the underlying `` element. diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 77b6f85c4a61..fbe1ab86c60c 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -2,7 +2,7 @@ description: Next.js supports built-in image optimization, as well as third party loaders for Imgix, Cloudinary, and more! Learn more here. --- -# Image Optimization +# Image Component and Image Optimization
Examples @@ -19,7 +19,7 @@ The Automatic Image Optimization allows for resizing, optimizing, and serving im ## Image Component -To add an image to your application, import the `next/image` component: +To add an image to your application, import the [`next/image`](/docs/api-reference/next/image.md) component: ```jsx import Image from 'next/image' @@ -31,8 +31,8 @@ function Home() { Picture of the author

Welcome to my homepage!

@@ -44,19 +44,32 @@ export default Home - `width` and `height` are required to prevent [Cumulative Layout Shift](https://web.dev/cls/), a [Core Web Vital](https://web.dev/vitals/) that Google is going to [use in their search ranking](https://webmasters.googleblog.com/2020/05/evaluating-page-experience.html) - `width` and `height` are automatically responsive, unlike the HTML `` element +- See [`next/image`](/docs/api-reference/next/image.md) for list of available props. ## Configuration You can configure Image Optimization by using the `images` property in `next.config.js`. -### Sizes +### Device Sizes -You can specify a list of image widths to allow using the `sizes` property. Since images maintain their aspect ratio using the `width` and `height` attributes of the source image, there is no need to specify height in `next.config.js` – only the width. You can think of these as breakpoints. +You can specify a list of device width breakpoints using the `deviceSizes` property. Since images maintain their aspect ratio using the `width` and `height` attributes of the source image, there is no need to specify height in `next.config.js` – only the width. These values will be used by the browser to determine which size image should load. ```js module.exports = { images: { - sizes: [320, 420, 768, 1024, 1200], + deviceSizes: [320, 420, 768, 1024, 1200], + }, +} +``` + +### Icon Sizes + +You can specify a list of icon image widths using the `iconSizes` property. These widths should be smaller than the smallest value in `deviceSizes`. The purpose is for images that don't scale with the browser window, such as icons or badges. If `iconSizes` is not defined, then `deviceSizes` will be used. + +```js +module.exports = { + images: { + iconSizes: [16, 32, 64], }, } ``` @@ -89,25 +102,19 @@ module.exports = { The following Image Optimization cloud providers are supported: -- Imgix: `loader: 'imgix'` -- Cloudinary: `loader: 'cloudinary'` -- Akamai: `loader: 'akamai'` -- Vercel: No configuration necessary +- When using `next start` or a custom server image optimization works automatically. +- [Vercel](https://vercel.com): Works automatically when you deploy on Vercel +- [Imgix](https://www.imgix.com): `loader: 'imgix'` +- [Cloudinary](https://cloudinary.com): `loader: 'cloudinary'` +- [Akamai](https://www.akamai.com): `loader: 'akamai'` ## Related For more information on what to do next, we recommend the following sections:
- - CSS Support: - Use the built-in CSS support to add custom styles to your app. + + next/image + See all available properties for the Image component
- -
-- When using `next start` or a custom server image optimization works automatically. -- [Vercel](https://vercel.com): Works automatically when you deploy on Vercel -- [Imgix](https://www.imgix.com): `loader: 'imgix'` -- [Cloudinary](https://cloudinary.com): `loader: 'cloudinary'` -- [Akamai](https://www.akamai.com): `loader: 'akamai'` diff --git a/docs/manifest.json b/docs/manifest.json index ae23677e5b35..696f09bafb5d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -21,6 +21,10 @@ "title": "Built-in CSS Support", "path": "/docs/basic-features/built-in-css-support.md" }, + { + "title": "Image Optimization", + "path": "/docs/basic-features/image-optimization.md" + }, { "title": "Fast Refresh", "path": "/docs/basic-features/fast-refresh.md" @@ -180,6 +184,10 @@ { "title": "Codemods", "path": "/docs/advanced-features/codemods.md" + }, + { + "title": "Internationalized Routing", + "path": "/docs/advanced-features/i18n-routing.md" } ] }, @@ -216,6 +224,10 @@ "title": "next/link", "path": "/docs/api-reference/next/link.md" }, + { + "title": "next/image", + "path": "/docs/api-reference/next/image.md" + }, { "title": "next/head", "path": "/docs/api-reference/next/head.md" diff --git a/examples/i18n-routing/.gitignore b/examples/i18n-routing/.gitignore new file mode 100644 index 000000000000..1437c53f70bc --- /dev/null +++ b/examples/i18n-routing/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/i18n-routing/README.md b/examples/i18n-routing/README.md new file mode 100644 index 000000000000..fa6c4a9394a6 --- /dev/null +++ b/examples/i18n-routing/README.md @@ -0,0 +1,23 @@ +# Internationalized Routing + +This example shows how to create internationalized pages using Next.js and the i18n routing feature. It shows a normal page, a non-dynamic `getStaticProps` page, a dynamic `getStaticProps` page, and a `getServerSideProps` page. + +For further documentation on this feature see the documentation [here](https://nextjs.org/docs/advanced-features/i18n-routing) + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/amp) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example i18n-routing i18n-app +# or +yarn create next-app --example i18n-routing i18n-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/i18n-routing/next.config.js b/examples/i18n-routing/next.config.js new file mode 100644 index 000000000000..f548199a3b10 --- /dev/null +++ b/examples/i18n-routing/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, +} diff --git a/examples/i18n-routing/package.json b/examples/i18n-routing/package.json new file mode 100644 index 000000000000..656226b12945 --- /dev/null +++ b/examples/i18n-routing/package.json @@ -0,0 +1,15 @@ +{ + "name": "i18n-routing", + "version": "1.0.0", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "latest", + "react": "^16.7.0", + "react-dom": "^16.7.0" + }, + "license": "MIT" +} diff --git a/examples/i18n-routing/pages/gsp/[slug].js b/examples/i18n-routing/pages/gsp/[slug].js new file mode 100644 index 000000000000..896537fe56cf --- /dev/null +++ b/examples/i18n-routing/pages/gsp/[slug].js @@ -0,0 +1,59 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function GspPage(props) { + const router = useRouter() + const { defaultLocale, isFallback, query } = router + + if (isFallback) { + return 'Loading...' + } + + return ( +
+

getServerSideProps page

+

Current slug: {query.slug}

+

Current locale: {props.locale}

+

Default locale: {defaultLocale}

+

Configured locales: {JSON.stringify(props.locales)}

+ + + To getStaticProps page + +
+ + + To getServerSideProps page + +
+ + + To index page + +
+
+ ) +} + +export const getStaticProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} + +export const getStaticPaths = ({ locales }) => { + const paths = [] + + for (const locale of locales) { + paths.push({ params: { slug: 'first' }, locale }) + paths.push({ params: { slug: 'second' }, locale }) + } + + return { + paths, + fallback: true, + } +} diff --git a/examples/i18n-routing/pages/gsp/index.js b/examples/i18n-routing/pages/gsp/index.js new file mode 100644 index 000000000000..4243a6034c46 --- /dev/null +++ b/examples/i18n-routing/pages/gsp/index.js @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function GspPage(props) { + const router = useRouter() + const { defaultLocale } = router + + return ( +
+

getServerSideProps page

+

Current locale: {props.locale}

+

Default locale: {defaultLocale}

+

Configured locales: {JSON.stringify(props.locales)}

+ + + To dynamic getStaticProps page + +
+ + + To getServerSideProps page + +
+ + + To index page + +
+
+ ) +} + +export const getStaticProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/examples/i18n-routing/pages/gssp.js b/examples/i18n-routing/pages/gssp.js new file mode 100644 index 000000000000..94f7158b1202 --- /dev/null +++ b/examples/i18n-routing/pages/gssp.js @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function GsspPage(props) { + const router = useRouter() + const { defaultLocale } = router + + return ( +
+

getServerSideProps page

+

Current locale: {props.locale}

+

Default locale: {defaultLocale}

+

Configured locales: {JSON.stringify(props.locales)}

+ + + To getStaticProps page + +
+ + + To dynamic getStaticProps page + +
+ + + To index page + +
+
+ ) +} + +export const getServerSideProps = ({ locale, locales }) => { + return { + props: { + locale, + locales, + }, + } +} diff --git a/examples/i18n-routing/pages/index.js b/examples/i18n-routing/pages/index.js new file mode 100644 index 000000000000..d3bd67204f26 --- /dev/null +++ b/examples/i18n-routing/pages/index.js @@ -0,0 +1,31 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function IndexPage(props) { + const router = useRouter() + const { locale, locales, defaultLocale } = router + + return ( +
+

Index page

+

Current locale: {locale}

+

Default locale: {defaultLocale}

+

Configured locales: {JSON.stringify(locales)}

+ + + To getStaticProps page + +
+ + + To dynamic getStaticProps page + +
+ + + To getServerSideProps page + +
+
+ ) +} diff --git a/lerna.json b/lerna.json index 239d9523b350..a0c6944976a2 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "9.5.6-canary.14" + "version": "9.5.6-canary.16" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index ce97bcb440be..7757d36353d6 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.14", + "version": "9.5.6-canary.16", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 9e951d613ccf..eb85b1a79c23 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.14", + "version": "9.5.6-canary.16", "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 930fb377da43..684268d1b806 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.14", + "version": "9.5.6-canary.16", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 748a864e4e1b..95f3075888b7 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.14", + "version": "9.5.6-canary.16", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index ef59ce9697b5..63cc5d7b874e 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.14", + "version": "9.5.6-canary.16", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index cd96f126bb93..cae2904e0955 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.14", + "version": "9.5.6-canary.16", "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 1abe107a3f37..288f382b1d34 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.14", + "version": "9.5.6-canary.16", "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 63a920806b0e..77fd33118703 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.14", + "version": "9.5.6-canary.16", "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 13a043a38c69..3ec335749bc7 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.14", + "version": "9.5.6-canary.16", "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 05701a624588..ebe89716be94 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.14", + "version": "9.5.6-canary.16", "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 4e96606d16ea..4ab65f6ceec0 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.14", + "version": "9.5.6-canary.16", "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 81256b987527..eb9179d6e92f 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -594,7 +594,7 @@ export default async function build( ssgPageRoutes = workerResult.prerenderRoutes } - if (workerResult.prerenderFallback === 'unstable_blocking') { + if (workerResult.prerenderFallback === 'blocking') { ssgBlockingFallbackPages.add(page) } else if (workerResult.prerenderFallback === true) { ssgStaticFallbackPages.add(page) @@ -1110,11 +1110,15 @@ export default async function build( ) } + const images = { ...config.images } + const { deviceSizes, iconSizes } = images + images.sizes = [...deviceSizes, ...iconSizes] + await promises.writeFile( path.join(distDir, IMAGES_MANIFEST), JSON.stringify({ version: 1, - images: config.images, + images, }), 'utf8' ) @@ -1159,7 +1163,7 @@ export default async function build( printCustomRoutes({ redirects, rewrites, headers }) } - if (config.experimental.analyticsId) { + if (config.analyticsId) { console.log( chalk.bold.green('Next.js Analytics') + ' is enabled for this production build. ' + diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index e6ad2c1e7103..6a171be27f31 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -575,7 +575,7 @@ export async function buildStaticPaths( if ( !( typeof staticPathsResult.fallback === 'boolean' || - staticPathsResult.fallback === 'unstable_blocking' + staticPathsResult.fallback === 'blocking' ) ) { throw new Error( @@ -700,7 +700,7 @@ export async function isPageStatic( hasServerProps?: boolean hasStaticProps?: boolean prerenderRoutes?: string[] | undefined - prerenderFallback?: boolean | 'unstable_blocking' | undefined + prerenderFallback?: boolean | 'blocking' | undefined }> { try { require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig) @@ -775,7 +775,7 @@ export async function isPageStatic( } let prerenderRoutes: Array | undefined - let prerenderFallback: boolean | 'unstable_blocking' | undefined + let prerenderFallback: boolean | 'blocking' | undefined if (hasStaticProps && hasStaticPaths) { ;({ paths: prerenderRoutes, diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 272ff411f253..f0d7eed6d91e 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -991,9 +991,16 @@ export default async function getBaseWebpackConfig( config.experimental.scrollRestoration ), 'process.env.__NEXT_IMAGE_OPTS': JSON.stringify({ - sizes: config.images.sizes, + deviceSizes: config.images.deviceSizes, + iconSizes: config.images.iconSizes, path: config.images.path, loader: config.images.loader, + ...(dev + ? { + // pass domains in development to allow validating on the client + domains: config.images.domains, + } + : {}), }), 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), @@ -1003,9 +1010,7 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_I18N_DOMAINS': JSON.stringify( config.experimental.i18n.domains ), - 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify( - config.experimental.analyticsId - ), + 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify(config.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 669594fdb2b3..013f63562d0a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -262,33 +262,29 @@ const nextServerlessLoader: loader.Loader = function () { ...parsedUrl, pathname: localePathResult.pathname, }) + req.__nextStrippedLocale = true parsedUrl.pathname = localePathResult.pathname + } + + // If a detected locale is a domain specific locale and we aren't already + // on that domain and path prefix redirect to it to prevent duplicate + // content from multiple domains + if (detectedDomain) { + const localeToCheck = localePathResult.detectedLocale + ? detectedLocale + : acceptPreferredLocale - // check if the locale prefix matches a domain's defaultLocale - // and we're on a locale specific domain if so redirect to that domain - // if (detectedDomain) { - // const matchedDomain = detectDomainLocale( - // i18n.domains, - // undefined, - // detectedLocale - // ) - - // if (matchedDomain) { - // localeDomainRedirect = \`http\${ - // matchedDomain.http ? '' : 's' - // }://\${matchedDomain.domain}\` - // } - // } - } else if (detectedDomain) { const matchedDomain = detectDomainLocale( i18n.domains, undefined, - acceptPreferredLocale + localeToCheck ) if (matchedDomain && matchedDomain.domain !== detectedDomain.domain) { localeDomainRedirect = \`http\${matchedDomain.http ? '' : 's'}://\${ matchedDomain.domain + }/\${ + localeToCheck === matchedDomain.defaultLocale ? '' : localeToCheck }\` } } @@ -772,7 +768,7 @@ const nextServerlessLoader: loader.Loader = function () { if (!renderMode) { if (_nextData || getStaticProps || getServerSideProps) { - if (renderOpts.ssgNotFound) { + if (renderOpts.isNotFound) { res.statusCode = 404 const NotFoundComponent = ${ diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 80fb05cd9f34..d938348a338a 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -14,9 +14,11 @@ const loaders = new Map string>([ type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default' type ImageData = { - sizes: number[] + deviceSizes: number[] + iconSizes: number[] loader: LoaderKey path: string + domains?: string[] } type ImageProps = Omit< @@ -34,9 +36,16 @@ type ImageProps = Omit< ) const imageData: ImageData = process.env.__NEXT_IMAGE_OPTS as any -const { sizes: configSizes, loader: configLoader, path: configPath } = imageData -configSizes.sort((a, b) => a - b) // smallest to largest -const largestSize = configSizes[configSizes.length - 1] +const { + deviceSizes: configDeviceSizes, + iconSizes: configIconSizes, + loader: configLoader, + path: configPath, + domains: configDomains, +} = imageData +// sort smallest to largest +configDeviceSizes.sort((a, b) => a - b) +configIconSizes.sort((a, b) => a - b) let cachedObserver: IntersectionObserver const IntersectionObserver = @@ -74,15 +83,36 @@ function getObserver(): IntersectionObserver | undefined { )) } +function getDeviceSizes(width: number | undefined): number[] { + if (typeof width !== 'number') { + return configDeviceSizes + } + const smallest = configDeviceSizes[0] + if (width < smallest && configIconSizes.includes(width)) { + return [width] + } + const widths: number[] = [] + for (let size of configDeviceSizes) { + widths.push(size) + if (size >= width) { + break + } + } + return widths +} + function computeSrc( src: string, unoptimized: boolean, + width: number | undefined, quality?: string ): string { if (unoptimized) { return src } - return callLoader({ src, width: largestSize, quality }) + const widths = getDeviceSizes(width) + const largest = widths[widths.length - 1] + return callLoader({ src, width: largest, quality }) } type CallLoaderProps = { @@ -98,29 +128,39 @@ function callLoader(loaderProps: CallLoaderProps) { type SrcSetData = { src: string - widths: number[] - quality?: string + unoptimized: boolean + width: number | undefined + quality: string | undefined } -function generateSrcSet({ src, widths, quality }: SrcSetData): string { +function generateSrcSet({ + src, + unoptimized, + width, + quality, +}: SrcSetData): string | undefined { // At each breakpoint, generate an image url using the loader, such as: // ' www.example.com/foo.jpg?w=480 480w, ' - return widths - .map((width: number) => `${callLoader({ src, width, quality })} ${width}w`) + if (unoptimized) { + return undefined + } + + return getDeviceSizes(width) + .map((w) => `${callLoader({ src, width: w, quality })} ${w}w`) .join(', ') } type PreloadData = { src: string - widths: number[] + unoptimized: boolean + width: number | undefined sizes?: string - unoptimized?: boolean quality?: string } function generatePreload({ src, - widths, + width, unoptimized = false, sizes, quality, @@ -134,15 +174,25 @@ function generatePreload({ ) } +function getInt(x: unknown): number | undefined { + if (typeof x === 'number') { + return x + } + if (typeof x === 'string') { + return parseInt(x, 10) + } + return undefined +} + export default function Image({ src, sizes, @@ -159,6 +209,13 @@ export default function Image({ const thisEl = useRef(null) if (process.env.NODE_ENV !== 'production') { + if (!src) { + throw new Error( + `Image is missing required "src" property. Make sure you pass "src" in props to the \`next/image\` component. Received: ${JSON.stringify( + { width, height, quality, unsized } + )}` + ) + } if (!VALID_LOADING_VALUES.includes(loading)) { throw new Error( `Image with src "${src}" has invalid "loading" property. Provided "${loading}" should be one of ${VALID_LOADING_VALUES.map( @@ -194,58 +251,23 @@ export default function Image({ } }, [thisEl, lazy]) - // Generate attribute values - const imgSrc = computeSrc(src, unoptimized, quality) - const imgSrcSet = !unoptimized - ? generateSrcSet({ - src, - widths: configSizes, - quality, - }) - : undefined - - let imgAttributes: - | { - src: string - srcSet?: string - } - | { - 'data-src': string - 'data-srcset'?: string - } - if (!lazy) { - imgAttributes = { - src: imgSrc, - } - if (imgSrcSet) { - imgAttributes.srcSet = imgSrcSet - } - } else { - imgAttributes = { - 'data-src': imgSrc, - } - if (imgSrcSet) { - imgAttributes['data-srcset'] = imgSrcSet - } - className = className ? className + ' __lazy' : '__lazy' - } - + let widthInt = getInt(width) + let heightInt = getInt(height) let divStyle: React.CSSProperties | undefined let imgStyle: React.CSSProperties | undefined let wrapperStyle: React.CSSProperties | undefined if ( - typeof height !== 'undefined' && - typeof width !== 'undefined' && + typeof widthInt !== 'undefined' && + typeof heightInt !== 'undefined' && !unsized ) { // // - const quotient = - parseInt(height as string, 10) / parseInt(width as string, 10) + const quotient = heightInt / widthInt const ratio = isNaN(quotient) ? 1 : quotient * 100 wrapperStyle = { maxWidth: '100%', - width, + width: widthInt, } divStyle = { position: 'relative', @@ -260,8 +282,8 @@ export default function Image({ width: '100%', } } else if ( - typeof height === 'undefined' && - typeof width === 'undefined' && + typeof widthInt === 'undefined' && + typeof heightInt === 'undefined' && unsized ) { // @@ -282,6 +304,41 @@ export default function Image({ } } + // Generate attribute values + const imgSrc = computeSrc(src, unoptimized, widthInt, quality) + const imgSrcSet = generateSrcSet({ + src, + width: widthInt, + unoptimized, + quality, + }) + + let imgAttributes: + | { + src: string + srcSet?: string + } + | { + 'data-src': string + 'data-srcset'?: string + } + if (!lazy) { + imgAttributes = { + src: imgSrc, + } + if (imgSrcSet) { + imgAttributes.srcSet = imgSrcSet + } + } else { + imgAttributes = { + 'data-src': imgSrc, + } + if (imgSrcSet) { + imgAttributes['data-srcset'] = imgSrcSet + } + className = className ? className + ' __lazy' : '__lazy' + } + // No need to add preloads on the client side--by the time the application is hydrated, // it's too late for preloads const shouldPreload = priority && typeof window === 'undefined' @@ -292,7 +349,7 @@ export default function Image({ {shouldPreload ? generatePreload({ src, - widths: configSizes, + width: widthInt, unoptimized, sizes, quality, @@ -349,6 +406,42 @@ function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string { } function defaultLoader({ root, src, width, quality }: LoaderProps): string { + if (process.env.NODE_ENV !== 'production') { + const missingValues = [] + + // these should always be provided but make sure they are + if (!src) missingValues.push('src') + if (!width) missingValues.push('width') + + if (missingValues.length > 0) { + throw new Error( + `Next Image Optimization requires ${missingValues.join( + ', ' + )} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify( + { src, width, quality } + )}` + ) + } + + if (src && !src.startsWith('/') && configDomains) { + let parsedSrc: URL + try { + parsedSrc = new URL(src) + } catch (err) { + console.error(err) + throw new Error( + `Failed to parse "${src}" if using relative image it must start with a leading slash "/" or be an absolute URL` + ) + } + + if (!configDomains.includes(parsedSrc.hostname)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname is not configured under images in your \`next.config.js\`` + ) + } + } + } + return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${ quality || '100' }` diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index f13b2c817187..0645fc552972 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -263,7 +263,7 @@ export default async function exportPage({ html = (result as any).html } - if (!html && !(curRenderOpts as any).ssgNotFound) { + if (!html && !(curRenderOpts as any).isNotFound) { throw new Error(`Failed to render serverless page`) } } else { @@ -318,7 +318,7 @@ export default async function exportPage({ html = await renderMethod(req, res, page, query, curRenderOpts) } } - results.ssgNotFound = (curRenderOpts as any).ssgNotFound + results.ssgNotFound = (curRenderOpts as any).isNotFound const validateAmp = async ( rawAmpHtml: string, diff --git a/packages/next/next-server/lib/i18n/detect-domain-locale.ts b/packages/next/next-server/lib/i18n/detect-domain-locale.ts index b77e9adca32f..7ad9d9890e57 100644 --- a/packages/next/next-server/lib/i18n/detect-domain-locale.ts +++ b/packages/next/next-server/lib/i18n/detect-domain-locale.ts @@ -3,6 +3,7 @@ export function detectDomainLocale( | Array<{ http?: boolean domain: string + locales?: string[] defaultLocale: string }> | undefined, @@ -13,17 +14,23 @@ export function detectDomainLocale( | { http?: boolean domain: string + locales?: string[] defaultLocale: string } | undefined if (domainItems) { + if (detectedLocale) { + detectedLocale = detectedLocale.toLowerCase() + } + for (const item of domainItems) { // remove port if present const domainHostname = item.domain?.split(':')[0].toLowerCase() if ( hostname === domainHostname || - detectedLocale?.toLowerCase() === item.defaultLocale.toLowerCase() + detectedLocale === item.defaultLocale.toLowerCase() || + item.locales?.some((locale) => locale.toLowerCase() === detectedLocale) ) { domainItem = item break diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 5eeb52da7e5f..d367a16db2eb 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -813,12 +813,7 @@ export default class Router implements BaseRouter { this._resolveHref(parsedHref, pages) if (pages.includes(parsedHref.pathname)) { - return this.change( - 'replaceState', - destination, - destination, - options - ) + return this.change(method, destination, destination, options) } } diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 2890b4a6b818..b3117452e030 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -23,8 +23,10 @@ const defaultConfig: { [key: string]: any } = { target: 'server', poweredByHeader: true, compress: true, + analyticsId: process.env.VERCEL_ANALYTICS_ID || '', images: { - sizes: [320, 420, 768, 1024, 1200], + deviceSizes: [320, 420, 768, 1024, 1200], + iconSizes: [], domains: [], path: '/_next/image', loader: 'default', @@ -61,7 +63,6 @@ const defaultConfig: { [key: string]: any } = { optimizeImages: false, scrollRestoration: false, i18n: false, - analyticsId: process.env.VERCEL_ANALYTICS_ID || '', }, future: { excludeDefaultMomentLocales: false, @@ -235,6 +236,13 @@ function assignDefaults(userConfig: { [key: string]: any }) { `Specified images.domains should be an Array received ${typeof images.domains}` ) } + + if (images.domains.length > 50) { + throw new Error( + `Specified images.domains exceeds length of 50, received length (${images.domains.length}), please reduce the length of the array to continue` + ) + } + const invalid = images.domains.filter( (d: unknown) => typeof d !== 'string' ) @@ -246,16 +254,53 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } } - if (images.sizes) { - if (!Array.isArray(images.sizes)) { + if (images.deviceSizes) { + const { deviceSizes } = images + if (!Array.isArray(deviceSizes)) { + throw new Error( + `Specified images.deviceSizes should be an Array received ${typeof deviceSizes}` + ) + } + + if (deviceSizes.length > 25) { throw new Error( - `Specified images.sizes should be an Array received ${typeof images.sizes}` + `Specified images.deviceSizes exceeds length of 25, received length (${deviceSizes.length}), please reduce the length of the array to continue` ) } - const invalid = images.sizes.filter((d: unknown) => typeof d !== 'number') + + const invalid = deviceSizes.filter((d: unknown) => { + return typeof d !== 'number' || d < 1 || d > 10000 + }) + if (invalid.length > 0) { throw new Error( - `Specified images.sizes should be an Array of numbers received invalid values (${invalid.join( + `Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( + ', ' + )})` + ) + } + } + if (images.iconSizes) { + const { iconSizes } = images + if (!Array.isArray(iconSizes)) { + throw new Error( + `Specified images.iconSizes should be an Array received ${typeof iconSizes}` + ) + } + + if (iconSizes.length > 25) { + throw new Error( + `Specified images.iconSizes exceeds length of 25, received length (${iconSizes.length}), please reduce the length of the array to continue` + ) + } + + const invalid = iconSizes.filter((d: unknown) => { + return typeof d !== 'number' || d < 1 || d > 10000 + }) + + if (invalid.length > 0) { + throw new Error( + `Specified images.iconSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( ', ' )})` ) @@ -295,7 +340,26 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (!item.defaultLocale) return true if (!item.domain || typeof item.domain !== 'string') return true - return false + let hasInvalidLocale = false + + if (Array.isArray(item.locales)) { + for (const locale of item.locales) { + if (typeof locale !== 'string') hasInvalidLocale = true + + for (const domainItem of i18n.domains) { + if (domainItem === item) continue + if (domainItem.locales && domainItem.locales.includes(locale)) { + console.warn( + `Both ${item.domain} and ${domainItem.domain} configured the locale (${locale}) but only one can. Remove it from one i18n.domains config to continue` + ) + hasInvalidLocale = true + break + } + } + } + } + + return hasInvalidLocale }) if (invalidDomainItems.length > 0) { diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts index c0c2e023d7b0..d1825f7b2f5e 100644 --- a/packages/next/next-server/server/image-optimizer.ts +++ b/packages/next/next-server/server/image-optimizer.ts @@ -23,6 +23,14 @@ const CACHE_VERSION = 1 const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] +type ImageData = { + deviceSizes: number[] + iconSizes: number[] + loader: string + path: string + domains?: string[] +} + export async function imageOptimizer( server: Server, req: IncomingMessage, @@ -30,7 +38,9 @@ export async function imageOptimizer( parsedUrl: UrlWithParsedQuery ) { const { nextConfig, distDir } = server - const { sizes = [], domains = [], loader } = nextConfig?.images || {} + const imageData: ImageData = nextConfig.images + const { deviceSizes = [], iconSizes = [], domains = [], loader } = imageData + const sizes = [...deviceSizes, ...iconSizes] if (loader !== 'default') { await server.render404(req, res, parsedUrl) diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index e478f1da66bc..17ab6495fda1 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -342,32 +342,32 @@ export default class Server { }) ;(req as any).__nextStrippedLocale = true parsedUrl.pathname = localePathResult.pathname + } + + // If a detected locale is a domain specific locale and we aren't already + // on that domain and path prefix redirect to it to prevent duplicate + // content from multiple domains + if (detectedDomain && parsedUrl.pathname === '/') { + const localeToCheck = acceptPreferredLocale + // const localeToCheck = localePathResult.detectedLocale + // ? detectedLocale + // : acceptPreferredLocale - // check if the locale prefix matches a domain's defaultLocale - // and we're on a locale specific domain if so redirect to that domain - // if (detectedDomain) { - // const matchedDomain = detectDomainLocale( - // i18n.domains, - // undefined, - // detectedLocale - // ) - - // if (matchedDomain) { - // localeDomainRedirect = `http${matchedDomain.http ? '' : 's'}://${ - // matchedDomain?.domain - // }` - // } - // } - } else if (detectedDomain) { const matchedDomain = detectDomainLocale( i18n.domains, undefined, - acceptPreferredLocale + localeToCheck ) - if (matchedDomain && matchedDomain.domain !== detectedDomain.domain) { + if ( + matchedDomain && + (matchedDomain.domain !== detectedDomain.domain || + localeToCheck !== matchedDomain.defaultLocale) + ) { localeDomainRedirect = `http${matchedDomain.http ? '' : 's'}://${ matchedDomain.domain + }/${ + localeToCheck === matchedDomain.defaultLocale ? '' : localeToCheck }` } } @@ -1351,7 +1351,7 @@ export default class Server { html = renderResult.html pageData = renderResult.renderOpts.pageData sprRevalidate = renderResult.renderOpts.revalidate - isNotFound = renderResult.renderOpts.ssgNotFound + isNotFound = renderResult.renderOpts.isNotFound } else { const origQuery = parseUrl(req.url || '', true).query const resolvedUrl = formatUrl({ @@ -1393,7 +1393,7 @@ 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 + isNotFound = (renderOpts as any).isNotFound } return { html, pageData, sprRevalidate, isNotFound } diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 71a53c66d111..d497c70036ac 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -625,8 +625,8 @@ export async function renderToHTML( (key) => key !== 'revalidate' && key !== 'props' && - key !== 'unstable_redirect' && - key !== 'unstable_notFound' + key !== 'redirect' && + key !== 'notFound' ) if (invalidKeys.includes('unstable_revalidate')) { @@ -637,23 +637,24 @@ export async function renderToHTML( throw new Error(invalidKeysMsg('getStaticProps', invalidKeys)) } - if (data.unstable_notFound) { + if ('notFound' in data && data.notFound) { if (pathname === '/404') { throw new Error( - `The /404 page can not return unstable_notFound in "getStaticProps", please remove it to continue!` + `The /404 page can not return notFound in "getStaticProps", please remove it to continue!` ) } - ;(renderOpts as any).ssgNotFound = true + ;(renderOpts as any).isNotFound = true ;(renderOpts as any).revalidate = false return null } if ( - data.unstable_redirect && - typeof data.unstable_redirect === 'object' + 'redirect' in data && + data.redirect && + typeof data.redirect === 'object' ) { - checkRedirectValues(data.unstable_redirect, req) + checkRedirectValues(data.redirect, req) if (isBuildTimeSSG) { throw new Error( @@ -663,18 +664,18 @@ export async function renderToHTML( } if (isDataReq) { - data.props = { - __N_REDIRECT: data.unstable_redirect.destination, + ;(data as any).props = { + __N_REDIRECT: data.redirect.destination, } } else { - handleRedirect(res, data.unstable_redirect) + handleRedirect(res, data.redirect) return null } } if ( (dev || isBuildTimeSSG) && - !isSerializableProps(pathname, 'getStaticProps', data.props) + !isSerializableProps(pathname, 'getStaticProps', (data as any).props) ) { // this fn should throw an error instead of ever returning `false` throw new Error( @@ -682,7 +683,7 @@ export async function renderToHTML( ) } - if (typeof data.revalidate === 'number') { + if ('revalidate' in data && typeof data.revalidate === 'number') { if (!Number.isInteger(data.revalidate)) { throw new Error( `A page's revalidate option must be seconds expressed as a natural number. Mixed numbers, such as '${data.revalidate}', cannot be used.` + @@ -703,20 +704,25 @@ export async function renderToHTML( `\nTo only run getStaticProps at build-time and not revalidate at runtime, you can set \`revalidate\` to \`false\`!` ) } - } else if (data.revalidate === true) { + } else if ('revalidate' in data && data.revalidate === true) { // When enabled, revalidate after 1 second. This value is optimal for // the most up-to-date page possible, but without a 1-to-1 // request-refresh ratio. data.revalidate = 1 } else { // By default, we never revalidate. - data.revalidate = false + ;(data as any).revalidate = false } - props.pageProps = Object.assign({}, props.pageProps, data.props) + props.pageProps = Object.assign( + {}, + props.pageProps, + 'props' in data ? data.props : undefined + ) // pass up revalidate and props for export // TODO: change this to a different passing mechanism - ;(renderOpts as any).revalidate = data.revalidate + ;(renderOpts as any).revalidate = + 'revalidate' in data ? data.revalidate : undefined ;(renderOpts as any).pageData = props } @@ -755,32 +761,55 @@ export async function renderToHTML( } const invalidKeys = Object.keys(data).filter( - (key) => key !== 'props' && key !== 'unstable_redirect' + (key) => key !== 'props' && key !== 'redirect' && key !== 'notFound' ) + if ((data as any).unstable_notFound) { + throw new Error( + `unstable_notFound has been renamed to notFound, please update the field to continue. Page: ${pathname}` + ) + } + if ((data as any).unstable_redirect) { + throw new Error( + `unstable_redirect has been renamed to redirect, please update the field to continue. Page: ${pathname}` + ) + } + if (invalidKeys.length) { throw new Error(invalidKeysMsg('getServerSideProps', invalidKeys)) } - if ( - data.unstable_redirect && - typeof data.unstable_redirect === 'object' - ) { - checkRedirectValues(data.unstable_redirect, req) + if ('notFound' in data) { + if (pathname === '/404') { + throw new Error( + `The /404 page can not return notFound in "getStaticProps", please remove it to continue!` + ) + } + + ;(renderOpts as any).isNotFound = true + return null + } + + if ('redirect' in data && typeof data.redirect === 'object') { + checkRedirectValues(data.redirect, req) if (isDataReq) { - data.props = { - __N_REDIRECT: data.unstable_redirect.destination, + ;(data as any).props = { + __N_REDIRECT: data.redirect.destination, } } else { - handleRedirect(res, data.unstable_redirect) + handleRedirect(res, data.redirect) return null } } if ( (dev || isBuildTimeSSG) && - !isSerializableProps(pathname, 'getServerSideProps', data.props) + !isSerializableProps( + pathname, + 'getServerSideProps', + (data as any).props + ) ) { // this fn should throw an error instead of ever returning `false` throw new Error( @@ -788,7 +817,7 @@ export async function renderToHTML( ) } - props.pageProps = Object.assign({}, props.pageProps, data.props) + props.pageProps = Object.assign({}, props.pageProps, (data as any).props) ;(renderOpts as any).pageData = props } } catch (dataFetchError) { diff --git a/packages/next/package.json b/packages/next/package.json index 95734c554c7d..88f4ab1a906b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "9.5.6-canary.14", + "version": "9.5.6-canary.16", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -79,10 +79,10 @@ "@babel/runtime": "7.11.2", "@babel/types": "7.11.5", "@hapi/accept": "5.0.1", - "@next/env": "9.5.6-canary.14", - "@next/polyfill-module": "9.5.6-canary.14", - "@next/react-dev-overlay": "9.5.6-canary.14", - "@next/react-refresh-utils": "9.5.6-canary.14", + "@next/env": "9.5.6-canary.16", + "@next/polyfill-module": "9.5.6-canary.16", + "@next/react-dev-overlay": "9.5.6-canary.16", + "@next/react-refresh-utils": "9.5.6-canary.16", "ast-types": "0.13.2", "babel-plugin-transform-define": "2.0.0", "babel-plugin-transform-react-remove-prop-types": "0.4.24", @@ -129,7 +129,7 @@ "sharp": "0.26.2" }, "devDependencies": { - "@next/polyfill-nomodule": "9.5.6-canary.14", + "@next/polyfill-nomodule": "9.5.6-canary.16", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", "@taskr/watch": "1.1.0", diff --git a/packages/next/server/next-dev-server.ts b/packages/next/server/next-dev-server.ts index d2b70721c3c0..17944484b967 100644 --- a/packages/next/server/next-dev-server.ts +++ b/packages/next/server/next-dev-server.ts @@ -556,7 +556,7 @@ export default class DevServer extends Server { return { staticPaths, fallbackMode: - fallback === 'unstable_blocking' + fallback === 'blocking' ? 'blocking' : fallback === true ? 'static' diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index d55380171625..29d8e361d488 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -86,12 +86,10 @@ export type GetStaticPropsContext = { defaultLocale?: string } -export type GetStaticPropsResult

= { - props?: P - revalidate?: number | boolean - unstable_redirect?: Redirect - unstable_notFound?: true -} +export type GetStaticPropsResult

= + | { props: P; revalidate?: number | boolean } + | { redirect: Redirect; revalidate?: number | boolean } + | { notFound: true } export type GetStaticProps< P extends { [key: string]: any } = { [key: string]: any }, @@ -113,7 +111,7 @@ export type GetStaticPathsContext = { export type GetStaticPathsResult

= { paths: Array - fallback: boolean | 'unstable_blocking' + fallback: boolean | 'blocking' } export type GetStaticPaths

= ( @@ -135,10 +133,10 @@ export type GetServerSidePropsContext< defaultLocale?: string } -export type GetServerSidePropsResult

= { - props?: P - unstable_redirect?: Redirect -} +export type GetServerSidePropsResult

= + | { props: P } + | { redirect: Redirect } + | { notFound: true } export type GetServerSideProps< P extends { [key: string]: any } = { [key: string]: any }, diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index a4cbec7fb1b8..ffe0c500fdd5 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.14", + "version": "9.5.6-canary.16", "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 937856d2c70c..be6e5b038347 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.14", + "version": "9.5.6-canary.16", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/test/integration/getserversideprops/pages/not-found/[slug].js b/test/integration/getserversideprops/pages/not-found/[slug].js new file mode 100644 index 000000000000..a2b94c1582bc --- /dev/null +++ b/test/integration/getserversideprops/pages/not-found/[slug].js @@ -0,0 +1,34 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gssp page

+

{JSON.stringify(props)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +export const getServerSideProps = ({ query }) => { + if (query.hiding) { + return { + notFound: true, + } + } + + return { + props: { + hello: 'world', + }, + } +} diff --git a/test/integration/getserversideprops/pages/not-found/index.js b/test/integration/getserversideprops/pages/not-found/index.js new file mode 100644 index 000000000000..a2b94c1582bc --- /dev/null +++ b/test/integration/getserversideprops/pages/not-found/index.js @@ -0,0 +1,34 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

gssp page

+

{JSON.stringify(props)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to / + +
+ + ) +} + +export const getServerSideProps = ({ query }) => { + if (query.hiding) { + return { + notFound: true, + } + } + + return { + props: { + hello: 'world', + }, + } +} diff --git a/test/integration/getserversideprops/test/index.test.js b/test/integration/getserversideprops/test/index.test.js index 015ec3d7b10b..b3e910d30a9f 100644 --- a/test/integration/getserversideprops/test/index.test.js +++ b/test/integration/getserversideprops/test/index.test.js @@ -118,6 +118,24 @@ const expectedManifestRoutes = () => [ ), page: '/non-json', }, + { + dataRouteRegex: `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/not-found.json$`, + page: '/not-found', + }, + { + dataRouteRegex: `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/not\\-found\\/([^\\/]+?)\\.json$`, + namedDataRouteRegex: `^/_next/data/${escapeRegex( + buildId + )}/not\\-found/(?[^/]+?)\\.json$`, + page: '/not-found/[slug]', + routeKeys: { + slug: 'slug', + }, + }, { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/refresh.json$` @@ -235,6 +253,50 @@ const navigateTest = (dev = false) => { const runTests = (dev = false) => { navigateTest(dev) + it('should render 404 correctly when notFound is returned (non-dynamic)', async () => { + const res = await fetchViaHTTP(appPort, '/not-found', { hiding: true }) + + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + + it('should render 404 correctly when notFound is returned client-transition (non-dynamic)', async () => { + const browser = await webdriver(appPort, '/') + await browser.eval(`(function() { + window.beforeNav = 1 + window.next.router.push('/not-found?hiding=true') + })()`) + + await browser.waitForElementByCss('h1') + 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 correctly when notFound is returned (dynamic)', async () => { + const res = await fetchViaHTTP(appPort, '/not-found/first', { + hiding: true, + }) + + expect(res.status).toBe(404) + expect(await res.text()).toContain('This page could not be found') + }) + + it('should render 404 correctly when notFound is returned client-transition (dynamic)', async () => { + const browser = await webdriver(appPort, '/') + await browser.eval(`(function() { + window.beforeNav = 1 + window.next.router.push('/not-found/first?hiding=true') + })()`) + + await browser.waitForElementByCss('h1') + expect(await browser.elementByCss('html').text()).toContain( + 'This page could not be found' + ) + expect(await browser.eval('window.beforeNav')).toBe(null) + }) + it('should SSR normal page correctly', async () => { const html = await renderViaHTTP(appPort, '/') expect(html).toMatch(/hello.*?world/) diff --git a/test/integration/gssp-redirect/pages/gsp-blog/[post].js b/test/integration/gssp-redirect/pages/gsp-blog/[post].js index 18166abd0b48..cd3fae7419a7 100644 --- a/test/integration/gssp-redirect/pages/gsp-blog/[post].js +++ b/test/integration/gssp-redirect/pages/gsp-blog/[post].js @@ -26,7 +26,7 @@ export const getStaticProps = ({ params }) => { } return { - unstable_redirect: { + redirect: { destination, permanent: params.post.includes('permanent'), }, diff --git a/test/integration/gssp-redirect/pages/gssp-blog/[post].js b/test/integration/gssp-redirect/pages/gssp-blog/[post].js index 4c6af348a556..d01d19f31ae4 100644 --- a/test/integration/gssp-redirect/pages/gssp-blog/[post].js +++ b/test/integration/gssp-redirect/pages/gssp-blog/[post].js @@ -16,7 +16,7 @@ export const getServerSideProps = ({ params }) => { } return { - unstable_redirect: { + redirect: { destination, permanent: params.post.includes('permanent'), }, diff --git a/test/integration/gssp-redirect/test/index.test.js b/test/integration/gssp-redirect/test/index.test.js index bdbb842bd51c..4852aac6ad91 100644 --- a/test/integration/gssp-redirect/test/index.test.js +++ b/test/integration/gssp-redirect/test/index.test.js @@ -123,6 +123,10 @@ const runTests = () => { window.next.router.push('/gssp-blog/redirect-dest-_another') })()`) await browser.waitForElementByCss('#another') + + const text = await browser.elementByCss('#another').text() + + expect(text).toEqual('another Page') }) it('should apply redirect when GSSP page is navigated to client-side (external)', async () => { @@ -149,6 +153,10 @@ const runTests = () => { window.next.router.push('/gsp-blog/redirect-dest-_another') })()`) await browser.waitForElementByCss('#another') + + const text = await browser.elementByCss('#another').text() + + expect(text).toEqual('another Page') }) it('should apply redirect when GSP page is navigated to client-side (external)', async () => { @@ -167,6 +175,94 @@ const runTests = () => { }, }) }) + + it('should not replace history of the origin page when GSSP page is navigated to client-side (internal normal)', async () => { + const browser = await webdriver(appPort, '/another?mark_as=root') + + await browser.eval(`(function () { + window.location.href = '/' + })()`) + await browser.waitForElementByCss('#index') + + await browser.eval(`(function () { + window.next.router.push('/gssp-blog/redirect-dest-_another') + })()`) + await browser.waitForElementByCss('#another') + + await browser.eval(`(function () { + window.history.back() + })()`) + + const curUrl = await browser.url() + const { path } = url.parse(curUrl) + expect(path).toEqual('/') + }) + + it('should not replace history of the origin page when GSSP page is navigated to client-side (external)', async () => { + const browser = await webdriver(appPort, '/another?mark_as=root') + + await browser.eval(`(function () { + window.location.href = '/' + })()`) + await browser.waitForElementByCss('#index') + + await browser.eval(`(function () { + window.next.router.push('/gssp-blog/redirect-dest-_gssp-blog_first') + })()`) + await browser.waitForElementByCss('#gssp') + + await browser.eval(`(function () { + window.history.back() + })()`) + + const curUrl = await browser.url() + const { path } = url.parse(curUrl) + expect(path).toEqual('/') + }) + + it('should not replace history of the origin page when GSP page is navigated to client-side (internal)', async () => { + const browser = await webdriver(appPort, '/another?mark_as=root') + + await browser.eval(`(function () { + window.location.href = '/' + })()`) + await browser.waitForElementByCss('#index') + + await browser.eval(`(function () { + window.next.router.push('/gsp-blog/redirect-dest-_another') + })()`) + await browser.waitForElementByCss('#another') + + await browser.eval(`(function () { + window.history.back() + })()`) + + const curUrl = await browser.url() + const { path } = url.parse(curUrl) + expect(path).toEqual('/') + }) + + it('should not replace history of the origin page when GSP page is navigated to client-side (external)', async () => { + const browser = await webdriver(appPort, '/another?mark_as=root') + + await browser.eval(`(function () { + window.location.href = '/' + })()`) + await browser.waitForElementByCss('#index') + + await browser.eval(`(function () { + window.next.router.push('/gsp-blog/redirect-dest-_gsp-blog_first') + })()`) + await browser.waitForElementByCss('#gsp') + + await browser.eval(`(function () { + window.history.back() + })()`) + + const curUrl = await browser.url() + const { path } = url.parse(curUrl) + expect(path).toEqual('/') + }) } describe('GS(S)P Redirect Support', () => { @@ -224,7 +320,7 @@ describe('GS(S)P Redirect Support', () => { export const getStaticProps = ({ params }) => { return { - unstable_redirect: { + redirect: { permanent: true, destination: '/another' } diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js index b49138d81668..3e1f4d2507de 100644 --- a/test/integration/i18n-support/next.config.js +++ b/test/integration/i18n-support/next.config.js @@ -11,11 +11,13 @@ module.exports = { http: true, domain: 'example.be', defaultLocale: 'nl-BE', + locales: ['nl', 'nl-NL', 'nl-BE'], }, { http: true, domain: 'example.fr', defaultLocale: 'fr', + locales: ['fr-BE'], }, ], }, diff --git a/test/integration/i18n-support/pages/not-found/fallback/[slug].js b/test/integration/i18n-support/pages/not-found/fallback/[slug].js index e4e809bc4f32..0fde7256d1aa 100644 --- a/test/integration/i18n-support/pages/not-found/fallback/[slug].js +++ b/test/integration/i18n-support/pages/not-found/fallback/[slug].js @@ -26,7 +26,7 @@ export default function Page(props) { export const getStaticProps = ({ params, locale, locales }) => { if (locale === 'en' || locale === 'nl') { return { - unstable_notFound: true, + notFound: true, } } diff --git a/test/integration/i18n-support/pages/not-found/index.js b/test/integration/i18n-support/pages/not-found/index.js index 18a9bd7996f8..b6652953a9af 100644 --- a/test/integration/i18n-support/pages/not-found/index.js +++ b/test/integration/i18n-support/pages/not-found/index.js @@ -24,7 +24,7 @@ export default function Page(props) { export const getStaticProps = ({ locale, locales }) => { if (locale === 'en' || locale === 'nl') { return { - unstable_notFound: true, + notFound: true, } } diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index b756e275b180..4fe4f7b1633b 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -52,11 +52,13 @@ function runTests(isDev) { http: true, domain: 'example.be', defaultLocale: 'nl-BE', + locales: ['nl', 'nl-NL', 'nl-BE'], }, { http: true, domain: 'example.fr', defaultLocale: 'fr', + locales: ['fr-BE'], }, ], }) @@ -661,27 +663,56 @@ function runTests(isDev) { }) it('should handle locales with domain', async () => { - const checkDomainLocales = async (domainDefault = '', domain = '') => { - for (const locale of locales) { - // skip other domains' default locale since we redirect these - if (['fr', 'nl-BE'].includes(locale) && locale !== domainDefault) { - continue - } - - const res = await fetchViaHTTP( - appPort, - `/${locale === domainDefault ? '' : locale}`, - undefined, - { - headers: { - host: domain, - }, - redirect: 'manual', - } - ) + const domainItems = [ + { + // used for testing, this should not be needed in most cases + // as production domains should always use https + http: true, + domain: 'example.be', + defaultLocale: 'nl-BE', + locales: ['nl', 'nl-NL', 'nl-BE'], + }, + { + http: true, + domain: 'example.fr', + defaultLocale: 'fr', + locales: ['fr-BE'], + }, + ] + const domainLocales = domainItems.reduce((prev, cur) => { + return [...prev, ...cur.locales] + }, []) + + const checkDomainLocales = async ( + domainDefault = '', + domain = '', + locale = '' + ) => { + const res = await fetchViaHTTP(appPort, `/`, undefined, { + headers: { + host: domain, + 'accept-language': locale, + }, + redirect: 'manual', + }) + const expectedDomainItem = domainItems.find( + (item) => item.defaultLocale === locale || item.locales.includes(locale) + ) + const shouldRedirect = + expectedDomainItem.domain !== domain || + locale !== expectedDomainItem.defaultLocale - expect(res.status).toBe(200) + expect(res.status).toBe(shouldRedirect ? 307 : 200) + if (shouldRedirect) { + const parsedUrl = url.parse(res.headers.get('location'), true) + + expect(parsedUrl.pathname).toBe( + `/${expectedDomainItem.defaultLocale === locale ? '' : locale}` + ) + expect(parsedUrl.query).toEqual({}) + expect(parsedUrl.hostname).toBe(expectedDomainItem.domain) + } else { const html = await res.text() const $ = cheerio.load(html) @@ -691,8 +722,11 @@ function runTests(isDev) { } } - await checkDomainLocales('nl-BE', 'example.be') - await checkDomainLocales('fr', 'example.fr') + for (const item of domainItems) { + for (const locale of domainLocales) { + await checkDomainLocales(item.defaultLocale, item.domain, locale) + } + } }) it('should generate AMP pages with all locales', async () => { diff --git a/test/integration/image-component/basic/next.config.js b/test/integration/image-component/basic/next.config.js index 8f260dea043f..617de0a68d69 100644 --- a/test/integration/image-component/basic/next.config.js +++ b/test/integration/image-component/basic/next.config.js @@ -1,6 +1,7 @@ module.exports = { images: { - sizes: [480, 1024, 1600], + deviceSizes: [480, 1024, 1600, 2000], + iconSizes: [16, 64], path: 'https://example.com/myaccount/', loader: 'imgix', }, diff --git a/test/integration/image-component/basic/pages/client-side.js b/test/integration/image-component/basic/pages/client-side.js index 99f97951f2bd..ddd00fd2fe3e 100644 --- a/test/integration/image-component/basic/pages/client-side.js +++ b/test/integration/image-component/basic/pages/client-side.js @@ -53,6 +53,20 @@ const ClientSide = () => { width={300} height={400} /> + + Errors diff --git a/test/integration/image-component/basic/pages/index.js b/test/integration/image-component/basic/pages/index.js index 0ad8d8e1ac44..c08992abfb47 100644 --- a/test/integration/image-component/basic/pages/index.js +++ b/test/integration/image-component/basic/pages/index.js @@ -19,7 +19,7 @@ const Page = () => { data-demo="demo-value" src="bar.jpg" loading="eager" - width={300} + width={1024} height={400} /> { width={300} height={400} /> - { width={300} height={400} /> + + Client Side diff --git a/test/integration/image-component/basic/pages/lazy.js b/test/integration/image-component/basic/pages/lazy.js index 8a5fe4a01c8f..161b530bc3c3 100644 --- a/test/integration/image-component/basic/pages/lazy.js +++ b/test/integration/image-component/basic/pages/lazy.js @@ -9,7 +9,7 @@ const Lazy = () => { id="lazy-top" src="foo1.jpg" height={400} - width={300} + width={1024} loading="lazy" >
@@ -35,7 +35,7 @@ const Lazy = () => { id="lazy-without-attribute" src="foo4.jpg" height={400} - width={300} + width={800} >
{ src="foo5.jpg" loading="eager" height={400} - width={300} + width={1900} >
) diff --git a/test/integration/image-component/basic/test/index.test.js b/test/integration/image-component/basic/test/index.test.js index bda3f67eea79..ff369bda69b8 100644 --- a/test/integration/image-component/basic/test/index.test.js +++ b/test/integration/image-component/basic/test/index.test.js @@ -33,27 +33,37 @@ function runTests() { }) it('should modify src with the loader', async () => { expect(await browser.elementById('basic-image').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo.jpg?auto=format&w=1600&q=60' + 'https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60' ) }) it('should correctly generate src even if preceding slash is included in prop', async () => { expect( await browser.elementById('preceding-slash-image').getAttribute('src') - ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=1600') + ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=480') }) it('should add a srcset based on the loader', async () => { expect( await browser.elementById('basic-image').getAttribute('srcset') - ).toBe( - 'https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60 480w, https://example.com/myaccount/foo.jpg?auto=format&w=1024&q=60 1024w, https://example.com/myaccount/foo.jpg?auto=format&w=1600&q=60 1600w' - ) + ).toBe('https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60 480w') }) it('should add a srcset even with preceding slash in prop', async () => { expect( await browser.elementById('preceding-slash-image').getAttribute('srcset') - ).toBe( - 'https://example.com/myaccount/fooslash.jpg?auto=format&w=480 480w, https://example.com/myaccount/fooslash.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/fooslash.jpg?auto=format&w=1600 1600w' + ).toBe('https://example.com/myaccount/fooslash.jpg?auto=format&w=480 480w') + }) + it('should use iconSizes when width matches, not deviceSizes from next.config.js', async () => { + expect(await browser.elementById('icon-image-16').getAttribute('src')).toBe( + 'https://example.com/myaccount/icon.png?auto=format&w=16' + ) + expect( + await browser.elementById('icon-image-16').getAttribute('srcset') + ).toBe('https://example.com/myaccount/icon.png?auto=format&w=16 16w') + expect(await browser.elementById('icon-image-64').getAttribute('src')).toBe( + 'https://example.com/myaccount/icon.png?auto=format&w=64' ) + expect( + await browser.elementById('icon-image-64').getAttribute('srcset') + ).toBe('https://example.com/myaccount/icon.png?auto=format&w=64 64w') }) it('should support the unoptimized attribute', async () => { expect( @@ -70,10 +80,10 @@ function runTests() { function lazyLoadingTests() { it('should have loaded the first image immediately', async () => { expect(await browser.elementById('lazy-top').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo1.jpg?auto=format&w=1600' + 'https://example.com/myaccount/foo1.jpg?auto=format&w=1024' ) expect(await browser.elementById('lazy-top').getAttribute('srcset')).toBe( - 'https://example.com/myaccount/foo1.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo1.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/foo1.jpg?auto=format&w=1600 1600w' + 'https://example.com/myaccount/foo1.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo1.jpg?auto=format&w=1024 1024w' ) }) it('should not have loaded the second image immediately', async () => { @@ -101,11 +111,11 @@ function lazyLoadingTests() { await check(() => { return browser.elementById('lazy-mid').getAttribute('src') - }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=1600') + }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480') await check(() => { return browser.elementById('lazy-mid').getAttribute('srcset') - }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo2.jpg?auto=format&w=1024 1024w, https://example.com/myaccount/foo2.jpg?auto=format&w=1600 1600w') + }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480 480w') }) it('should not have loaded the third image after scrolling down', async () => { expect( @@ -150,15 +160,17 @@ function lazyLoadingTests() { await waitFor(200) expect( await browser.elementById('lazy-without-attribute').getAttribute('src') - ).toBe('https://example.com/myaccount/foo4.jpg?auto=format&w=1600') + ).toBe('https://example.com/myaccount/foo4.jpg?auto=format&w=1024') expect( await browser.elementById('lazy-without-attribute').getAttribute('srcset') - ).toBeTruthy() + ).toBe( + 'https://example.com/myaccount/foo4.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo4.jpg?auto=format&w=1024 1024w' + ) }) it('should load the fifth image eagerly, without scrolling', async () => { expect(await browser.elementById('eager-loading').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo5.jpg?auto=format&w=1600' + 'https://example.com/myaccount/foo5.jpg?auto=format&w=2000' ) expect( await browser.elementById('eager-loading').getAttribute('srcset') @@ -198,14 +210,14 @@ describe('Image Component Tests', () => { it('should add a preload tag for a priority image', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/withpriority.png?auto=format&w=1600' + 'https://example.com/myaccount/withpriority.png?auto=format&w=480&q=60' ) ).toBe(true) }) it('should add a preload tag for a priority image with preceding slash', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/fooslash.jpg?auto=format&w=1600' + 'https://example.com/myaccount/fooslash.jpg?auto=format&w=480' ) ).toBe(true) }) @@ -219,7 +231,7 @@ describe('Image Component Tests', () => { it('should add a preload tag for a priority image, with quality', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/withpriority.png?auto=format&w=1600&q=60' + 'https://example.com/myaccount/withpriority.png?auto=format&w=480&q=60' ) ).toBe(true) }) diff --git a/test/integration/image-component/default/pages/invalid-src.js b/test/integration/image-component/default/pages/invalid-src.js new file mode 100644 index 000000000000..d9920d016b95 --- /dev/null +++ b/test/integration/image-component/default/pages/invalid-src.js @@ -0,0 +1,13 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Hello World

+ +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/missing-src.js b/test/integration/image-component/default/pages/missing-src.js new file mode 100644 index 000000000000..df490c1fc04e --- /dev/null +++ b/test/integration/image-component/default/pages/missing-src.js @@ -0,0 +1,12 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+ +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index f6deaf57b064..c2c62f141278 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -8,6 +8,8 @@ import { nextStart, nextBuild, check, + hasRedbox, + getRedboxHeader, } from 'next-test-utils' import webdriver from 'next-webdriver' import fs from 'fs-extra' @@ -20,7 +22,7 @@ const nextConfig = join(appDir, 'next.config.js') let appPort let app -function runTests() { +function runTests(mode) { it('should load the images', async () => { let browser try { @@ -79,6 +81,26 @@ function runTests() { } } }) + + if (mode === 'dev') { + it('should show missing src error', async () => { + const browser = await webdriver(appPort, '/missing-src') + + await hasRedbox(browser) + expect(await getRedboxHeader(browser)).toContain( + 'Image is missing required "src" property. Make sure you pass "src" in props to the `next/image` component. Received: {"width":200}' + ) + }) + + it('should show invalid src error', async () => { + const browser = await webdriver(appPort, '/invalid-src') + + await hasRedbox(browser) + expect(await getRedboxHeader(browser)).toContain( + 'Invalid src prop (https://google.com/test.png) on `next/image`, hostname is not configured under images in your `next.config.js`' + ) + }) + } } describe('Image Component Tests', () => { diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 589480d4847d..3428ffd359b0 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -10,6 +10,7 @@ import { nextBuild, nextStart, File, + waitFor, } from 'next-test-utils' import sharp from 'sharp' @@ -321,6 +322,108 @@ function runTests({ w, isDev, domains }) { } describe('Image Optimizer', () => { + describe('config checks', () => { + it('should error when domains length exceeds 50', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + domains: new Array(51).fill('google.com'), + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.domains exceeds length of 50, received length (51), please reduce the length of the array to continue' + ) + }) + + it('should error when sizes length exceeds 25', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + deviceSizes: new Array(51).fill(1024), + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.deviceSizes exceeds length of 25, received length (51), please reduce the length of the array to continue' + ) + }) + + it('should error when deviceSizes contains invalid widths', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + deviceSizes: [0, 12000, 64, 128, 256], + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (0, 12000)' + ) + }) + + it('should error when iconSizes contains invalid widths', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + iconSizes: [0, 16, 64, 12000], + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.iconSizes should be an Array of numbers that are between 1 and 10000, received invalid values (0, 12000)' + ) + }) + }) + // domains for testing const domains = ['localhost', 'example.com'] @@ -343,7 +446,8 @@ describe('Image Optimizer', () => { beforeAll(async () => { const json = JSON.stringify({ images: { - sizes: [size, largeSize], + deviceSizes: [largeSize], + iconSizes: [size], domains, }, }) @@ -380,7 +484,7 @@ describe('Image Optimizer', () => { beforeAll(async () => { const json = JSON.stringify({ images: { - sizes: [size, largeSize], + deviceSizes: [size, largeSize], domains, }, }) @@ -404,7 +508,7 @@ describe('Image Optimizer', () => { const json = JSON.stringify({ target: 'experimental-serverless-trace', images: { - sizes: [size, largeSize], + deviceSizes: [size, largeSize], domains, }, }) diff --git a/test/integration/prerender/pages/blocking-fallback-once/[slug].js b/test/integration/prerender/pages/blocking-fallback-once/[slug].js index 0d9b4971ba00..5b047ded890c 100644 --- a/test/integration/prerender/pages/blocking-fallback-once/[slug].js +++ b/test/integration/prerender/pages/blocking-fallback-once/[slug].js @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' export async function getStaticPaths() { return { paths: [], - fallback: 'unstable_blocking', + fallback: 'blocking', } } diff --git a/test/integration/prerender/pages/blocking-fallback-some/[slug].js b/test/integration/prerender/pages/blocking-fallback-some/[slug].js index a9ccb79b9594..dc89af7f5117 100644 --- a/test/integration/prerender/pages/blocking-fallback-some/[slug].js +++ b/test/integration/prerender/pages/blocking-fallback-some/[slug].js @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' export async function getStaticPaths() { return { paths: [{ params: { slug: 'a' } }, { params: { slug: 'b' } }], - fallback: 'unstable_blocking', + fallback: 'blocking', } } diff --git a/test/integration/prerender/pages/blocking-fallback/[slug].js b/test/integration/prerender/pages/blocking-fallback/[slug].js index cb2a93e47f07..0b39f579352c 100644 --- a/test/integration/prerender/pages/blocking-fallback/[slug].js +++ b/test/integration/prerender/pages/blocking-fallback/[slug].js @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' export async function getStaticPaths() { return { paths: [], - fallback: 'unstable_blocking', + fallback: 'blocking', } } diff --git a/test/integration/prerender/pages/non-json-blocking/[p].js b/test/integration/prerender/pages/non-json-blocking/[p].js index 9d872c8d3fec..f7b406bdcb9b 100644 --- a/test/integration/prerender/pages/non-json-blocking/[p].js +++ b/test/integration/prerender/pages/non-json-blocking/[p].js @@ -7,7 +7,7 @@ export async function getStaticProps() { } export async function getStaticPaths() { - return { paths: [], fallback: 'unstable_blocking' } + return { paths: [], fallback: 'blocking' } } const Page = ({ time }) => { diff --git a/test/integration/prerender/test/index.test.js b/test/integration/prerender/test/index.test.js index cab5b95e8f97..c7c8e3b334a0 100644 --- a/test/integration/prerender/test/index.test.js +++ b/test/integration/prerender/test/index.test.js @@ -2101,7 +2101,7 @@ describe('SSG Prerender', () => { await fs.writeFile( pagePath, fallbackBlockingPageContents[page].replace( - "fallback: 'unstable_blocking'", + "fallback: 'blocking'", 'fallback: false' ) )