diff --git a/.github/ISSUE_TEMPLATE/1.Bug_report.md b/.github/ISSUE_TEMPLATE/1.Bug_report.md index 3204d20475f45..a6670d64bb768 100644 --- a/.github/ISSUE_TEMPLATE/1.Bug_report.md +++ b/.github/ISSUE_TEMPLATE/1.Bug_report.md @@ -1,6 +1,7 @@ --- name: Bug report about: Create a bug report for the Next.js core / examples +labels: 'template: bug' --- # Bug report diff --git a/.github/ISSUE_TEMPLATE/2.Feature_request.md b/.github/ISSUE_TEMPLATE/2.Feature_request.md index c3cfe03750198..924236b62e35e 100644 --- a/.github/ISSUE_TEMPLATE/2.Feature_request.md +++ b/.github/ISSUE_TEMPLATE/2.Feature_request.md @@ -1,6 +1,7 @@ --- name: Feature request about: Create a feature request for the Next.js core +labels: 'template: story' --- # Feature request diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 884eaaf91cf81..1c9c3c90eb1bd 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -107,10 +107,12 @@ jobs: steps: - uses: actions/checkout@v2 - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* - - run: cat packages/next/package.json | jq '.resolutions.webpack = "^5.0.0-beta.30"' > package.json.tmp && mv package.json.tmp packages/next/package.json - - run: cat packages/next/package.json | jq '.resolutions.react = "^17.0.0-rc.1"' > package.json.tmp && mv package.json.tmp packages/next/package.json - - run: cat packages/next/package.json | jq '.resolutions."react-dom" = "^17.0.0-rc.1"' > package.json.tmp && mv package.json.tmp packages/next/package.json + - run: cat package.json | jq '.resolutions.webpack = "^5.0.0-beta.30"' > package.json.tmp && mv package.json.tmp package.json + - run: cat package.json | jq '.resolutions.react = "^17.0.1"' > package.json.tmp && mv package.json.tmp package.json + - run: cat package.json | jq '.resolutions."react-dom" = "^17.0.1"' > package.json.tmp && mv package.json.tmp package.json - run: yarn install --check-files + - run: yarn list webpack react react-dom + - run: node run-tests.js test/integration/link-ref/test/index.test.js - run: node run-tests.js test/integration/production/test/index.test.js - run: node run-tests.js test/integration/basic/test/index.test.js - run: node run-tests.js test/integration/async-modules/test/index.test.js diff --git a/docs/advanced-features/automatic-static-optimization.md b/docs/advanced-features/automatic-static-optimization.md index 5685b6962207d..b4b4dadd1ec0c 100644 --- a/docs/advanced-features/automatic-static-optimization.md +++ b/docs/advanced-features/automatic-static-optimization.md @@ -34,8 +34,6 @@ And if you add `getServerSideProps` to the page, it will then be JavaScript, lik .next/server/static/${BUILD_ID}/about.js ``` -In development you'll know if `pages/about.js` is optimized or not thanks to the included [static optimization indicator](/docs/api-reference/next.config.js/static-optimization-indicator.md). - ## Caveats - If you have a [custom `App`](/docs/advanced-features/custom-app.md) with `getInitialProps` then this optimization will be turned off in pages without [Static Generation](/docs/basic-features/data-fetching.md#getstaticprops-static-generation). diff --git a/docs/advanced-features/i18n-routing.md b/docs/advanced-features/i18n-routing.md index 78681a78008cc..58c454dd06b23 100644 --- a/docs/advanced-features/i18n-routing.md +++ b/docs/advanced-features/i18n-routing.md @@ -131,6 +131,21 @@ When using Domain Routing, if a user with the `Accept-Language` header `fr;q=0.9 When using Sub-path Routing, the user would be redirected to `/fr`. +### Disabling Automatic Locale Detection + +The automatic locale detection can be disabled with: + +```js +// next.config.js +module.exports = { + i18n: { + localeDetection: false, + }, +} +``` + +When `localeDetection` is set to `false` Next.js will no longer automatically redirect based on the user's preferred locale and will only provide locale information detected from either the locale based domain or locale path as described above. + ## Accessing the locale information You can access the locale information via the Next.js router. For example, using the [`useRouter()`](https://nextjs.org/docs/api-reference/next/router#userouter) hook the following properties are available: diff --git a/docs/api-reference/next.config.js/static-optimization-indicator.md b/docs/api-reference/next.config.js/static-optimization-indicator.md deleted file mode 100644 index 7ef296f9c36f9..0000000000000 --- a/docs/api-reference/next.config.js/static-optimization-indicator.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -description: Optimized pages include an indicator to let you know if it's being statically optimized. You can opt-out of it here. ---- - -# Static Optimization Indicator - -When a page qualifies for [Automatic Static Optimization](/docs/advanced-features/automatic-static-optimization.md) we show an indicator to let you know. - -This is helpful since automatic static optimization can be very beneficial and knowing immediately in development if the page qualifies can be useful. - -In some cases this indicator might not be useful, like when working on electron applications. To remove it open `next.config.js` and disable the `autoPrerender` config in `devIndicators`: - -```js -module.exports = { - devIndicators: { - autoPrerender: false, - }, -} -``` - -## Related - -
- - Introduction to next.config.js: - Learn more about the configuration file used by Next.js. - -
- -
- - Automatic Static Optimization: - Next.js automatically optimizes your app to be static HTML whenever possible. Learn how it works here. - -
diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 423d10c424827..4f434e9576806 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -46,13 +46,14 @@ export default Home `Image` accepts the following props: - `src` - The path or URL to the source image. This is required. -- `width` - The intrinsic width of the source image in pixels. Must be an integer without a unit. Required unless `unsized` is true. -- `height` - The intrinsic height of the source image, in pixels. Must be an integer without a unit. Required unless `unsized` is true. +- `width` - The width of the image, in pixels. Must be an integer without a unit. Required unless `layout="fill"`. +- `height` - The height of the image, in pixels. Must be an integer without a unit. Required unless `layout="fill"`. +- `layout` - The rendered layout of the image. If `fixed`, the image dimensions will not change as the viewport changes (no responsiveness). If `intrinsic`, the image will scale the dimensions down for smaller viewports but maintain the original dimensions for larger viewports. If `responsive`, the image will scale the dimensions down for smaller viewports and scale up for larger viewports. If `fill`, the image will stretch both width and height to the dimensions of the parent element. Default `intrinsic`. - `sizes` - Defines what proportion of the screen you expect the image to take up. Recommended, as it helps serve the correct sized image to each device. [More info](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes). - `quality` - The quality of the optimized image, an integer between 1 and 100 where 100 is the best quality. Default 75. - `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 above-the-fold images. Should _not_ be used with `priority`. +- `unsized` - **Deprecated** When true, the `width` and `height` requirement can by bypassed. Use the `layout` property instead! All other properties on the `` component will be passed to the underlying `` element. diff --git a/docs/basic-features/data-fetching.md b/docs/basic-features/data-fetching.md index 7f973189425d8..4a20eda25e1bc 100644 --- a/docs/basic-features/data-fetching.md +++ b/docs/basic-features/data-fetching.md @@ -63,6 +63,7 @@ The `context` parameter is an object containing the following keys: - `props` - A **required** object with the props that will be received by the page component. It should be a [serializable object](https://en.wikipedia.org/wiki/Serialization) - `revalidate` - An **optional** amount in seconds after which a page re-generation can occur. More on [Incremental Static Regeneration](#incremental-static-regeneration) - `notFound` - An optional boolean value to allow the page to return a 404 status and page. More on [Incremental Static Regeneration](#incremental-static-regeneration) +- `redirect` - An optional redirect value to allow redirecting to internal and external resources. It should match the shape of `{ destination: string, permanent: boolean }`. In some rare cases, you might need to assign a custom status code for older HTTP Clients to properly redirect. In these cases, you can use the `statusCode` property instead of the `permanent` property, but not both. > **Note**: You can import modules in top-level scope for use in `getStaticProps`. > Imports used in `getStaticProps` will [not be bundled for the client-side](#write-server-side-code-directly). @@ -552,6 +553,12 @@ The `context` parameter is an object containing the following keys: - `locales` contains all supported locales (if enabled). - `defaultLocale` contains the configured default locale (if enabled). +`getServerSideProps` should return an object with: + +- `props` - A **required** object with the props that will be received by the page component. It should be a [serializable object](https://en.wikipedia.org/wiki/Serialization) +- `notFound` - An optional boolean value to allow the page to return a 404 status and page. More on [Incremental Static Regeneration](#incremental-static-regeneration) +- `redirect` - An optional redirect value to allow redirecting to internal and external resources. It should match the shape of `{ destination: string, permanent: boolean }`. In some rare cases, you might need to assign a custom status code for older HTTP Clients to properly redirect. In these cases, you can use the `statusCode` property instead of the `permanent` property, but not both. + > **Note**: You can import modules in top-level scope for use in `getServerSideProps`. > Imports used in `getServerSideProps` will not be bundled for the client-side. > diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 5d3db276f103a..f87d9d296b551 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -62,8 +62,8 @@ If no configuration is provided, the following default configuration will be use ```js module.exports = { images: { - deviceSizes: [320, 420, 768, 1024, 1200], - imageSizes: [], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], domains: [], path: '/_next/image', loader: 'default', @@ -77,24 +77,24 @@ This means you only need to configure the properties you wish to change. ### Device Sizes -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. +You can specify a list of device width breakpoints using the `deviceSizes` property. These widths are used when the [`next/image`](/docs/api-reference/next/image.md) component uses `layout="responsive"` or `layout="fill"` so that the correct image is served for the device visiting your website. ```js module.exports = { images: { - deviceSizes: [320, 420, 768, 1024, 1200], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], }, } ``` ### Image Sizes -You can specify a list of exact image widths using the `imageSizes` property. These widths should be different than the widths defined in `deviceSizes`. The purpose is for images that don't scale with the browser window, such as icons, badges, or profile images. If the `width` property of a [`next/image`](/docs/api-reference/next/image.md) component matches a value in `imageSizes`, the image will be rendered at that exact width. +You can specify a list of image widths using the `imageSizes` property. These widths should be different than the widths defined in `deviceSizes` because the arrays will be concatentated. These widths are used when the [`next/image`](/docs/api-reference/next/image.md) component uses `layout="fixed"` or `layout="intrinsic"`. ```js module.exports = { images: { - imageSizes: [16, 32, 64], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, } ``` @@ -143,7 +143,7 @@ The expiration (or rather Max Age) is defined by the upstream server's `Cache-Co If `s-maxage` is found in `Cache-Control`, it is used. If no `s-maxage` is found, then `max-age` is used. If no `max-age` is found, then 60 seconds is used. -You can configure [`deviceSizes`](#device-sizes) to reduce the total number of possible generated images. +You can configure [`deviceSizes`](#device-sizes) and [`imageSizes`](#device-sizes) to reduce the total number of possible generated images. ## Related diff --git a/docs/manifest.json b/docs/manifest.json index 696f09bafb5d2..c0b6cca77ed5c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -292,10 +292,6 @@ "title": "Compression", "path": "/docs/api-reference/next.config.js/compression.md" }, - { - "title": "Static Optimization Indicator", - "path": "/docs/api-reference/next.config.js/static-optimization-indicator.md" - }, { "title": "Runtime Configuration", "path": "/docs/api-reference/next.config.js/runtime-configuration.md" diff --git a/errors/install-sharp.md b/errors/install-sharp.md index 168d23082c174..5c35f95b7357d 100644 --- a/errors/install-sharp.md +++ b/errors/install-sharp.md @@ -2,13 +2,13 @@ #### Why This Error Occurred -Using Next.js' built-in Image Optimization requires that you install `sharp`. +Using Next.js' built-in [Image Optimization](https://nextjs.org/docs/basic-features/image-optimization) requires [sharp](https://www.npmjs.com/package/sharp) as a dependency. -Since `sharp` is optional, it may have been skipped if you installed `next` with the [`--no-optional`](https://docs.npmjs.com/cli/install) flag or it may have been skipped if your platform does not support `sharp`. +You are seeing this error because your OS was unable to [install sharp](https://sharp.pixelplumbing.com/install) properly, either using pre-built binaries or building from source. #### Possible Ways to Fix It -Option 1: Install the `sharp` package in your project. +Option 1: Use a different version of Node.js and try to install `sharp` again. ```bash npm i sharp @@ -16,13 +16,10 @@ npm i sharp yarn add sharp ``` -Option 2: Configure an external loader in `next.config.js` such as [imgix](https://imgix.com). +Option 2: If using macOS, ensure XCode Build Tools are installed and try to install `sharp` again. -```js -module.exports = { - images: { - path: 'https://example.com/myaccount/', - loader: 'imgix', - }, -} -``` +For example, see [macOS Catalina instructions](https://github.com/nodejs/node-gyp/blob/66c0f0446749caa591ad841cd029b6d5b5c8da42/macOS_Catalina.md). + +Option 3: Use a different OS and try to install `sharp` again. + +For example, if you're using Windows, try using [WSL](https://docs.microsoft.com/en-us/windows/wsl/about) (Windows Subsystem for Linux). diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index 19846e447bb70..c060c62728da3 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -12,9 +12,9 @@ Make sure your `images` field follows the allowed config shape and values: module.exports = { images: { // limit of 25 deviceSizes values - deviceSizes: [320, 420, 768, 1024, 1200], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], // limit of 25 imageSizes values - imageSizes: [], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // limit of 50 domains values domains: [], path: '/_next/image', diff --git a/errors/invalid-redirect-gssp.md b/errors/invalid-redirect-gssp.md index 05db61946d490..5b966ffa0a2df 100644 --- a/errors/invalid-redirect-gssp.md +++ b/errors/invalid-redirect-gssp.md @@ -13,16 +13,16 @@ export const getStaticProps = ({ params }) => { if (params.slug === 'deleted-post') { return { redirect: { - permanent: true // or false - destination: '/some-location' - } + permanent: true, // or false + destination: '/some-location', + }, } } return { props: { // data - } + }, } } ``` diff --git a/examples/cms-wordpress/components/avatar.js b/examples/cms-wordpress/components/avatar.js index 89a89ad7074ab..f50126872f356 100644 --- a/examples/cms-wordpress/components/avatar.js +++ b/examples/cms-wordpress/components/avatar.js @@ -1,17 +1,22 @@ export default function Avatar({ author }) { - const name = - author.firstName && author.lastName + const name = author + ? author.firstName && author.lastName ? `${author.firstName} ${author.lastName}` : author.name + : null return ( -
- {name} -
{name}
-
+ <> + {author && ( +
+ {name} +
{name}
+
+ )} + ) } diff --git a/examples/cms-wordpress/components/hero-post.js b/examples/cms-wordpress/components/hero-post.js index b089453b9fce6..33788a6548b66 100644 --- a/examples/cms-wordpress/components/hero-post.js +++ b/examples/cms-wordpress/components/hero-post.js @@ -14,7 +14,9 @@ export default function HeroPost({ return (
- + {coverImage && ( + + )}
diff --git a/examples/cms-wordpress/components/more-stories.js b/examples/cms-wordpress/components/more-stories.js index 95c1a1a28e67a..d77cd124e75c5 100644 --- a/examples/cms-wordpress/components/more-stories.js +++ b/examples/cms-wordpress/components/more-stories.js @@ -11,9 +11,9 @@ export default function MoreStories({ posts }) { diff --git a/examples/cms-wordpress/pages/index.js b/examples/cms-wordpress/pages/index.js index c599db29d31a7..e6c987fb8870e 100644 --- a/examples/cms-wordpress/pages/index.js +++ b/examples/cms-wordpress/pages/index.js @@ -22,9 +22,9 @@ export default function Index({ allPosts: { edges }, preview }) { {heroPost && ( diff --git a/examples/cms-wordpress/pages/posts/[slug].js b/examples/cms-wordpress/pages/posts/[slug].js index b3f3ae7e5c88a..b57d69536ff98 100644 --- a/examples/cms-wordpress/pages/posts/[slug].js +++ b/examples/cms-wordpress/pages/posts/[slug].js @@ -41,9 +41,9 @@ export default function Post({ post, posts, preview }) { diff --git a/examples/i18n-routing/README.md b/examples/i18n-routing/README.md index fa6c4a9394a65..79fe1e41207ea 100644 --- a/examples/i18n-routing/README.md +++ b/examples/i18n-routing/README.md @@ -8,7 +8,7 @@ For further documentation on this feature see the documentation [here](https://n 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) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/i18n-routing) ## How to use diff --git a/examples/i18n-routing/pages/gsp/[slug].js b/examples/i18n-routing/pages/gsp/[slug].js index 896537fe56cf4..5b22eec70f187 100644 --- a/examples/i18n-routing/pages/gsp/[slug].js +++ b/examples/i18n-routing/pages/gsp/[slug].js @@ -11,7 +11,7 @@ export default function GspPage(props) { return (
-

getServerSideProps page

+

getStaticProps page

Current slug: {query.slug}

Current locale: {props.locale}

Default locale: {defaultLocale}

diff --git a/examples/i18n-routing/pages/gsp/index.js b/examples/i18n-routing/pages/gsp/index.js index 4243a6034c467..905742584018e 100644 --- a/examples/i18n-routing/pages/gsp/index.js +++ b/examples/i18n-routing/pages/gsp/index.js @@ -7,7 +7,7 @@ export default function GspPage(props) { return (
-

getServerSideProps page

+

getStaticProps page

Current locale: {props.locale}

Default locale: {defaultLocale}

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

diff --git a/examples/using-preact/next.config.js b/examples/using-preact/next.config.js index c55926dfd847c..14a8224307a61 100644 --- a/examples/using-preact/next.config.js +++ b/examples/using-preact/next.config.js @@ -1,3 +1,4 @@ +const preact = require('preact') const withPrefresh = require('@prefresh/next') module.exports = withPrefresh({ @@ -25,14 +26,32 @@ module.exports = withPrefresh({ const aliases = config.resolve.alias || (config.resolve.alias = {}) aliases.react = aliases['react-dom'] = 'preact/compat' - // Automatically inject Preact DevTools: - if (dev && !isServer) { - const entry = config.entry - config.entry = () => - entry().then((entries) => { - entries['main.js'] = ['preact/debug'].concat(entries['main.js'] || []) - return entries - }) + if (dev) { + if (isServer) { + // Remove circular `__self` and `__source` props only meant for development + let oldVNodeHook = preact.options.vnode + preact.options.vnode = (vnode) => { + const props = vnode.props + if (props != null) { + if ('__self' in props) props.__self = null + if ('__source' in props) props.__source = null + } + + if (oldVNodeHook) { + oldVNodeHook(vnode) + } + } + } else { + // Automatically inject Preact DevTools: + const entry = config.entry + config.entry = () => + entry().then((entries) => { + entries['main.js'] = ['preact/debug'].concat( + entries['main.js'] || [] + ) + return entries + }) + } } return config diff --git a/examples/using-preact/package.json b/examples/using-preact/package.json index 14a28fae73bae..e60ee7e876bc7 100644 --- a/examples/using-preact/package.json +++ b/examples/using-preact/package.json @@ -6,17 +6,15 @@ "build": "next build", "start": "next start" }, - "devDependencies": { - "react-refresh": "^0.8.3" - }, + "devDependencies": {}, "dependencies": { - "@prefresh/next": "^0.3.0", - "next": "^9.4.0", - "preact": "^10.4.4", - "preact-render-to-string": "^5.1.9", - "react": "github:preact-compat/react#1.0.0", - "react-dom": "github:preact-compat/react-dom#1.0.0", - "react-ssr-prepass": "npm:preact-ssr-prepass@^1.0.1" + "@prefresh/next": "^1.3.0", + "next": "10.0.0", + "preact": "^10.5.4", + "preact-render-to-string": "^5.1.10", + "react": "npm:@preact/compat@^0.0.3", + "react-dom": "npm:@preact/compat@^0.0.3", + "react-ssr-prepass": "npm:preact-ssr-prepass@^1.1.2" }, "license": "MIT" } diff --git a/examples/using-preact/pages/index.js b/examples/using-preact/pages/index.js index 90d95353b509d..62301b996922a 100644 --- a/examples/using-preact/pages/index.js +++ b/examples/using-preact/pages/index.js @@ -4,9 +4,23 @@ export default function Home() { return (
Hello World.{' '} - - About - +
) } diff --git a/examples/using-preact/pages/ssg.js b/examples/using-preact/pages/ssg.js new file mode 100644 index 0000000000000..58adabc4b5b77 --- /dev/null +++ b/examples/using-preact/pages/ssg.js @@ -0,0 +1,9 @@ +export default function SSG({ framework }) { + return
{framework} ssg example
+} + +export function getStaticProps() { + return { + props: { framework: 'preact' }, + } +} diff --git a/examples/using-preact/pages/ssr.js b/examples/using-preact/pages/ssr.js new file mode 100644 index 0000000000000..695e329a85918 --- /dev/null +++ b/examples/using-preact/pages/ssr.js @@ -0,0 +1,9 @@ +export default function SSR({ framework }) { + return
{framework} ssr example
+} + +export function getServerSideProps() { + return { + props: { framework: 'preact' }, + } +} diff --git a/examples/with-magic/next.config.js b/examples/with-magic/next.config.js deleted file mode 100644 index 5b8efdfbd80c2..0000000000000 --- a/examples/with-magic/next.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - devIndicators: { - autoPrerender: false, - }, -} diff --git a/examples/with-next-page-transitions/pages/about.js b/examples/with-next-page-transitions/pages/about.js index 074cf4075cb53..8cc10dc4f0220 100644 --- a/examples/with-next-page-transitions/pages/about.js +++ b/examples/with-next-page-transitions/pages/about.js @@ -40,4 +40,6 @@ About.defaultProps = { pageTransitionReadyToEnter: () => {}, } +About.pageTransitionDelayEnter = true + export default About diff --git a/lerna.json b/lerna.json index 298d1f4531f9a..52becfda12de1 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "10.0.0" + "version": "10.0.1-canary.6" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 1f8c7a8c085de..6a330f1a72111 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "10.0.0", + "version": "10.0.1-canary.6", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index b371f75dc8a6e..8668765a45fa0 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": "10.0.0", + "version": "10.0.1-canary.6", "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 1c448034b3b4d..3bcff4d50a192 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "10.0.0", + "version": "10.0.1-canary.6", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index e0e8d94a99895..93581fa5127a9 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "10.0.0", + "version": "10.0.1-canary.6", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 3ce238ffa075b..e3c242c583024 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "10.0.0", + "version": "10.0.1-canary.6", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 3018732251645..f7433417576ef 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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 810bf474b5944..b604f51e75b21 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": "10.0.0", + "version": "10.0.1-canary.6", "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 7a4b76cbbf4d6..048ca8abbaf5a 100644 --- a/packages/next-plugin-sentry/package.json +++ b/packages/next-plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-sentry", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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 0aa11303f2a83..7faf05be4a459 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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 cbdea2d3558fa..b7703c6ee8bcc 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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-module/src/index.js b/packages/next-polyfill-module/src/index.js index 8fb8b7cb42c3d..73b6c052f089c 100644 --- a/packages/next-polyfill-module/src/index.js +++ b/packages/next-polyfill-module/src/index.js @@ -31,6 +31,7 @@ if (!('trimEnd' in String.prototype)) { */ if (!('description' in Symbol.prototype)) { Object.defineProperty(Symbol.prototype, 'description', { + configurable: true, get: function get() { var m = /\((.*)\)/.exec(this.toString()) return m ? m[1] : undefined diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 4cab925816dd7..3a6f5f8ef4edd 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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 909283becc48a..a25898130740c 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -808,7 +808,7 @@ export default async function build( } } - if (isSsg && !isFallback) { + if (isSsg) { // remove non-locale prefixed variant from defaultMap delete defaultMap[page] } diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 5c049139d3458..3e552304226b1 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -969,9 +969,6 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_BUILD_INDICATOR': JSON.stringify( config.devIndicators.buildActivity ), - 'process.env.__NEXT_PRERENDER_INDICATOR': JSON.stringify( - config.devIndicators.autoPrerender - ), 'process.env.__NEXT_PLUGINS': JSON.stringify( config.experimental.plugins ), @@ -1196,7 +1193,6 @@ export default async function getBaseWebpackConfig( trailingSlash: config.trailingSlash, modern: config.experimental.modern, buildActivity: config.devIndicators.buildActivity, - autoPrerender: config.devIndicators.autoPrerender, plugins: config.experimental.plugins, reactStrictMode: config.reactStrictMode, reactMode: config.experimental.reactMode, diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 5b24329dd36b3..f5d06c37a95af 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -85,6 +85,17 @@ const nextServerlessLoader: loader.Loader = function () { .reduce((prev, key) => { let value = query[key] + // if the value matches the default value we can't rely + // on the parsed params, this is used to signal if we need + // to parse x-now-route-matches or not + const isDefaultValue = Array.isArray(value) + ? value.every((val, idx) => val === defaultRouteMatches[key][idx]) + : value === defaultRouteMatches[key] + + if (isDefaultValue || typeof value === 'undefined') { + hasValidParams = false + } + ${ '' // non-provided optional values should be undefined so normalize @@ -127,6 +138,7 @@ const nextServerlessLoader: loader.Loader = function () { } ` : '' + const envLoading = ` const { processEnv } = require('@next/env') processEnv(${Buffer.from(loadedEnvFiles, 'base64').toString()}) @@ -155,6 +167,7 @@ const nextServerlessLoader: loader.Loader = function () { const dynamicRouteMatcher = pageIsDynamicRoute ? ` const dynamicRouteMatcher = getRouteMatcher(getRouteRegex("${page}")) + const defaultRouteMatches = dynamicRouteMatcher("${page}") ` : '' @@ -230,10 +243,12 @@ const nextServerlessLoader: loader.Loader = function () { let locales = i18n.locales let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) - let acceptPreferredLocale = accept.language( - req.headers['accept-language'], - i18n.locales - ) + let acceptPreferredLocale = i18n.localeDetection !== false + ? accept.language( + req.headers['accept-language'], + i18n.locales + ) + : detectedLocale const { host } = req.headers || {} // remove port from host and remove port if present @@ -355,7 +370,10 @@ const nextServerlessLoader: loader.Loader = function () { return } - detectedLocale = detectedLocale || defaultLocale + detectedLocale = + localePathResult.detectedLocale || + (detectedDomain && detectedDomain.defaultLocale) || + defaultLocale ` : ` const i18n = {} @@ -387,8 +405,6 @@ const nextServerlessLoader: loader.Loader = function () { ${defaultRouteRegex} - ${normalizeDynamicRouteParams} - ${handleRewrites} export default async (req, res) => { @@ -399,7 +415,9 @@ const nextServerlessLoader: loader.Loader = function () { // to ensure we are using the correct values const trustQuery = req.headers['${vercelHeader}'] const parsedUrl = handleRewrites(parseUrl(req.url, true)) + let hasValidParams = true + ${normalizeDynamicRouteParams} ${handleBasePath} const params = ${ @@ -484,7 +502,6 @@ const nextServerlessLoader: loader.Loader = function () { ${dynamicRouteMatcher} ${defaultRouteRegex} - ${normalizeDynamicRouteParams} ${handleRewrites} export let config = compMod['confi' + 'g'] || (compMod.then && compMod.then(mod => mod['confi' + 'g'])) || {} @@ -517,6 +534,9 @@ const nextServerlessLoader: loader.Loader = function () { const fromExport = renderMode === 'export' || renderMode === true; const nextStartMode = renderMode === 'passthrough' + let hasValidParams = true + + ${normalizeDynamicRouteParams} setLazyProp({ req }, 'cookies', getCookieParser(req)) @@ -620,6 +640,7 @@ const nextServerlessLoader: loader.Loader = function () { ` : `const params = {};` } + ${ // Temporary work around: `x-now-route-matches` is a platform header // _only_ set for `Prerender` requests. We should move this logic @@ -627,7 +648,7 @@ const nextServerlessLoader: loader.Loader = function () { // removing reliance on `req.url` and using `req.query` instead // (which is needed for "custom routes" anyway). pageIsDynamicRoute - ? `const nowParams = req.headers && req.headers["x-now-route-matches"] + ? `const nowParams = !hasValidParams && req.headers && req.headers["x-now-route-matches"] ? getRouteMatcher( (function() { const { re, groups, routeKeys } = getRouteRegex("${page}"); @@ -784,7 +805,10 @@ const nextServerlessLoader: loader.Loader = function () { getStaticPaths: undefined, getServerSideProps: undefined, Component: NotFoundComponent, - err: undefined + err: undefined, + locale: detectedLocale, + locales, + defaultLocale: i18n.defaultLocale, })) sendPayload(req, res, result, 'html', ${ diff --git a/packages/next/client/dev/prerender-indicator.js b/packages/next/client/dev/prerender-indicator.js deleted file mode 100644 index 0e395afb738e5..0000000000000 --- a/packages/next/client/dev/prerender-indicator.js +++ /dev/null @@ -1,204 +0,0 @@ -import Router from '../router' - -export default function initializeBuildWatcher() { - const shadowHost = document.createElement('div') - shadowHost.id = '__next-prerender-indicator' - // Make sure container is fixed and on a high zIndex so it shows - shadowHost.style.position = 'fixed' - shadowHost.style.bottom = '20px' - shadowHost.style.right = '10px' - shadowHost.style.width = 0 - shadowHost.style.height = 0 - shadowHost.style.zIndex = 99998 - shadowHost.style.transition = 'all 100ms ease' - - document.body.appendChild(shadowHost) - - let shadowRoot - let prefix = '' - - if (shadowHost.attachShadow) { - shadowRoot = shadowHost.attachShadow({ mode: 'open' }) - } else { - // If attachShadow is undefined then the browser does not support - // the Shadow DOM, we need to prefix all the names so there - // will be no conflicts - shadowRoot = shadowHost - prefix = '__next-prerender-indicator-' - } - - // Container - const container = createContainer(prefix) - shadowRoot.appendChild(container) - - // CSS - const css = createCss(prefix) - shadowRoot.appendChild(css) - - const expandEl = container.querySelector('a') - const closeEl = container.querySelector(`#${prefix}close`) - - // State - const dismissKey = '__NEXT_DISMISS_PRERENDER_INDICATOR' - const dismissUntil = parseInt(window.localStorage.getItem(dismissKey), 10) - const dismissed = dismissUntil > new Date().getTime() - - let isVisible = !dismissed && window.__NEXT_DATA__.nextExport - - function updateContainer() { - if (isVisible) { - container.classList.add(`${prefix}visible`) - } else { - container.classList.remove(`${prefix}visible`) - } - } - const expandedClass = `${prefix}expanded` - let toggleTimeout - - const toggleExpand = (expand = true) => { - clearTimeout(toggleTimeout) - - toggleTimeout = setTimeout(() => { - if (expand) { - expandEl.classList.add(expandedClass) - closeEl.style.display = 'flex' - } else { - expandEl.classList.remove(expandedClass) - closeEl.style.display = 'none' - } - }, 50) - } - - closeEl.addEventListener('click', () => { - const oneHourAway = new Date().getTime() + 1 * 60 * 60 * 1000 - window.localStorage.setItem(dismissKey, oneHourAway + '') - isVisible = false - updateContainer() - }) - closeEl.addEventListener('mouseenter', () => toggleExpand()) - closeEl.addEventListener('mouseleave', () => toggleExpand(false)) - expandEl.addEventListener('mouseenter', () => toggleExpand()) - expandEl.addEventListener('mouseleave', () => toggleExpand(false)) - - Router.events.on('routeChangeComplete', () => { - isVisible = window.next.isPrerendered - updateContainer() - }) - updateContainer() -} - -function createContainer(prefix) { - const container = document.createElement('div') - container.id = `${prefix}container` - container.innerHTML = ` - - -
- - - - - - Prerendered Page -
-
- ` - return container -} - -function createCss(prefix) { - const css = document.createElement('style') - css.textContent = ` - #${prefix}container { - position: absolute; - display: none; - bottom: 10px; - right: 15px; - } - - #${prefix}close { - top: -10px; - right: -10px; - border: none; - width: 18px; - height: 18px; - color: #333333; - font-size: 16px; - cursor: pointer; - display: none; - position: absolute; - background: #ffffff; - border-radius: 100%; - align-items: center; - flex-direction: column; - justify-content: center; - } - - #${prefix}container a { - color: inherit; - text-decoration: none; - width: 15px; - height: 23px; - overflow: hidden; - - border-radius: 3px; - background: #fff; - color: #000; - font: initial; - cursor: pointer; - letter-spacing: initial; - text-shadow: initial; - text-transform: initial; - visibility: initial; - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - - padding: 4px 2px; - align-items: center; - box-shadow: 0 11px 40px 0 rgba(0, 0, 0, 0.25), 0 2px 10px 0 rgba(0, 0, 0, 0.12); - - display: flex; - transition: opacity 0.1s ease, bottom 0.1s ease, width 0.3s ease; - animation: ${prefix}fade-in 0.1s ease-in-out; - } - - #${prefix}icon-wrapper { - width: 140px; - height: 20px; - display: flex; - flex-shrink: 0; - align-items: center; - position: relative; - } - - #${prefix}icon-wrapper svg { - flex-shrink: 0; - margin-right: 3px; - } - - #${prefix}container a.${prefix}expanded { - width: 135px; - } - - #${prefix}container.${prefix}visible { - display: flex; - bottom: 10px; - opacity: 1; - } - - @keyframes ${prefix}fade-in { - from { - bottom: 0px; - opacity: 0; - } - to { - bottom: 10px; - opacity: 1; - } - } - ` - - return css -} diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index f861def1a3002..5c06ed1de05b4 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -13,6 +13,15 @@ const loaders = new Map string>([ type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default' +const VALID_LAYOUT_VALUES = [ + 'fill', + 'fixed', + 'intrinsic', + 'responsive', + undefined, +] as const +type LayoutValue = typeof VALID_LAYOUT_VALUES[number] + type ImageData = { deviceSizes: number[] imageSizes: number[] @@ -31,8 +40,18 @@ type ImageProps = Omit< loading?: LoadingValue unoptimized?: boolean } & ( - | { width: number | string; height: number | string; unsized?: false } - | { width?: number | string; height?: number | string; unsized: true } + | { + width?: never + height?: never + /** @deprecated Use `layout="fill"` instead */ + unsized: true + } + | { width?: never; height?: never; layout: 'fill' } + | { + width: number | string + height: number | string + layout?: Exclude + } ) const imageData: ImageData = process.env.__NEXT_IMAGE_OPTS as any @@ -44,8 +63,9 @@ const { domains: configDomains, } = imageData // sort smallest to largest +const allSizes = [...configDeviceSizes, ...configImageSizes] configDeviceSizes.sort((a, b) => a - b) -configImageSizes.sort((a, b) => a - b) +allSizes.sort((a, b) => a - b) let cachedObserver: IntersectionObserver @@ -86,34 +106,40 @@ function unLazifyImage(lazyImage: HTMLImageElement): void { lazyImage.classList.remove('__lazy') } -function getDeviceSizes(width: number | undefined): number[] { - if (typeof width !== 'number') { - return configDeviceSizes - } - if (configImageSizes.includes(width)) { - return [width] - } - const widths: number[] = [] - for (let size of configDeviceSizes) { - widths.push(size) - if (size >= width) { - break - } +function getSizes( + width: number | undefined, + layout: LayoutValue +): { sizes: number[]; kind: 'w' | 'x' } { + if ( + typeof width !== 'number' || + layout === 'fill' || + layout === 'responsive' + ) { + return { sizes: configDeviceSizes, kind: 'w' } } - return widths + + const sizes = [ + ...new Set( + [width, width * 2, width * 3].map( + (w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1] + ) + ), + ] + return { sizes, kind: 'x' } } function computeSrc( src: string, unoptimized: boolean, + layout: LayoutValue, width?: number, quality?: number ): string { if (unoptimized) { return src } - const widths = getDeviceSizes(width) - const largest = widths[widths.length - 1] + const { sizes } = getSizes(width, layout) + const largest = sizes[sizes.length - 1] return callLoader({ src, width: largest, quality }) } @@ -131,6 +157,7 @@ function callLoader(loaderProps: CallLoaderProps) { type SrcSetData = { src: string unoptimized: boolean + layout: LayoutValue width?: number quality?: number } @@ -138,6 +165,7 @@ type SrcSetData = { function generateSrcSet({ src, unoptimized, + layout, width, quality, }: SrcSetData): string | undefined { @@ -147,14 +175,21 @@ function generateSrcSet({ return undefined } - return getDeviceSizes(width) - .map((w) => `${callLoader({ src, width: w, quality })} ${w}w`) + const { sizes, kind } = getSizes(width, layout) + return sizes + .map( + (size, i) => + `${callLoader({ src, width: size, quality })} ${ + kind === 'w' ? size : i + 1 + }${kind}` + ) .join(', ') } type PreloadData = { src: string unoptimized: boolean + layout: LayoutValue width: number | undefined sizes?: string quality?: number @@ -162,8 +197,9 @@ type PreloadData = { function generatePreload({ src, - width, unoptimized = false, + layout, + width, sizes, quality, }: PreloadData): ReactElement { @@ -176,9 +212,15 @@ function generatePreload({ @@ -205,19 +247,40 @@ export default function Image({ quality, width, height, - unsized, - ...rest + ...all }: ImageProps) { const thisEl = useRef(null) + let rest: Partial = all + let layout: NonNullable = sizes ? 'responsive' : 'intrinsic' + let unsized = false + if ('unsized' in rest) { + unsized = Boolean(rest.unsized) + // Remove property so it's not spread into image: + delete rest['unsized'] + } else if ('layout' in rest) { + // Override default layout if the user specified one: + if (rest.layout) layout = rest.layout + + // Remove property so it's not spread into image: + delete rest['layout'] + } + 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 } + { width, height, quality } )}` ) } + if (!VALID_LAYOUT_VALUES.includes(layout)) { + throw new Error( + `Image with src "${src}" has invalid "layout" property. Provided "${layout}" should be one of ${VALID_LAYOUT_VALUES.map( + String + ).join(',')}.` + ) + } 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( @@ -227,7 +290,12 @@ export default function Image({ } if (priority && loading === 'lazy') { throw new Error( - `Image with src "${src}" has both "priority" and "loading=lazy" properties. Only one should be used.` + `Image with src "${src}" has both "priority" and "loading='lazy'" properties. Only one should be used.` + ) + } + if (unsized) { + throw new Error( + `Image with src "${src}" has deprecated "unsized" property, which was removed in favor of the "layout='fill'" property` ) } } @@ -265,63 +333,112 @@ export default function Image({ const heightInt = getInt(height) const qualityInt = getInt(quality) - let divStyle: React.CSSProperties | undefined - let imgStyle: React.CSSProperties | undefined - let wrapperStyle: React.CSSProperties | undefined + let wrapperStyle: JSX.IntrinsicElements['div']['style'] | undefined + let sizerStyle: JSX.IntrinsicElements['div']['style'] | undefined + let sizerSvg: string | undefined + let imgStyle: JSX.IntrinsicElements['img']['style'] = { + visibility: lazy ? 'hidden' : 'visible', + + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + + boxSizing: 'border-box', + padding: 0, + border: 'none', + margin: 'auto', + + display: 'block', + width: 0, + height: 0, + minWidth: '100%', + maxWidth: '100%', + minHeight: '100%', + maxHeight: '100%', + } if ( typeof widthInt !== 'undefined' && typeof heightInt !== 'undefined' && - !unsized + layout !== 'fill' ) { - // // const quotient = heightInt / widthInt - const ratio = isNaN(quotient) ? 1 : quotient * 100 - wrapperStyle = { - maxWidth: '100%', - width: widthInt, - } - divStyle = { - position: 'relative', - paddingBottom: `${ratio}%`, - } - imgStyle = { - visibility: lazy ? 'hidden' : 'visible', - height: '100%', - left: '0', - position: 'absolute', - top: '0', - width: '100%', + const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%` + if (layout === 'responsive') { + // + wrapperStyle = { + display: 'block', + overflow: 'hidden', + position: 'relative', + + boxSizing: 'border-box', + margin: 0, + } + sizerStyle = { display: 'block', boxSizing: 'border-box', paddingTop } + } else if (layout === 'intrinsic') { + // + wrapperStyle = { + display: 'inline-block', + maxWidth: '100%', + overflow: 'hidden', + position: 'relative', + boxSizing: 'border-box', + margin: 0, + } + sizerStyle = { + boxSizing: 'border-box', + display: 'block', + maxWidth: '100%', + } + sizerSvg = `` + } else if (layout === 'fixed') { + // + wrapperStyle = { + overflow: 'hidden', + boxSizing: 'border-box', + display: 'inline-block', + position: 'relative', + width: widthInt, + height: heightInt, + } } } else if ( typeof widthInt === 'undefined' && typeof heightInt === 'undefined' && - unsized + layout === 'fill' ) { - // - if (process.env.NODE_ENV !== 'production') { - if (priority) { - // - console.warn( - `Image with src "${src}" has both "priority" and "unsized" properties. Only one should be used.` - ) - } + // + wrapperStyle = { + display: 'block', + overflow: 'hidden', + + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + + boxSizing: 'border-box', + margin: 0, } } else { // if (process.env.NODE_ENV !== 'production') { throw new Error( - `Image with src "${src}" must use "width" and "height" properties or "unsized" property.` + `Image with src "${src}" must use "width" and "height" properties or "layout='fill'" property.` ) } } // Generate attribute values - const imgSrc = computeSrc(src, unoptimized, widthInt, qualityInt) + const imgSrc = computeSrc(src, unoptimized, layout, widthInt, qualityInt) const imgSrcSet = generateSrcSet({ src, - width: widthInt, unoptimized, + layout, + width: widthInt, quality: qualityInt, }) @@ -355,27 +472,45 @@ export default function Image({ // it's too late for preloads const shouldPreload = priority && typeof window === 'undefined' + if (unsized) { + wrapperStyle = undefined + sizerStyle = undefined + imgStyle = undefined + } return (
-
- {shouldPreload - ? generatePreload({ - src, - width: widthInt, - unoptimized, - sizes, - quality: qualityInt, - }) - : ''} - -
+ {shouldPreload + ? generatePreload({ + src, + layout, + unoptimized, + width: widthInt, + sizes, + quality: qualityInt, + }) + : null} + {sizerStyle ? ( +
+ {sizerSvg ? ( + + ) : null} +
+ ) : null} +
) } @@ -389,7 +524,8 @@ function normalizeSrc(src: string) { } function imgixLoader({ root, src, width, quality }: LoaderProps): string { - const params = ['auto=format', 'w=' + width] + // Demo: https://static.imgix.net/daisy.png?format=auto&fit=max&w=300 + const params = ['auto=format', 'fit=max', 'w=' + width] let paramsString = '' if (quality) { params.push('q=' + quality) @@ -406,7 +542,8 @@ function akamaiLoader({ root, src, width }: LoaderProps): string { } function cloudinaryLoader({ root, src, width, quality }: LoaderProps): string { - const params = ['f_auto', 'w_' + width] + // Demo: https://res.cloudinary.com/demo/image/upload/w_300,c_limit/turtles.jpg + const params = ['f_auto', 'c_limit', 'w_' + width] let paramsString = '' if (quality) { params.push('q_' + quality) diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index 54f178e673b39..4c28435d64a84 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -18,7 +18,11 @@ import * as envConfig from '../next-server/lib/runtime-config' import type { NEXT_DATA } from '../next-server/lib/utils' import { getURL, loadGetInitialProps, ST } from '../next-server/lib/utils' import initHeadManager from './head-manager' -import PageLoader, { looseToArray, StyleSheetTuple } from './page-loader' +import PageLoader, { + INITIAL_CSS_LOAD_ERROR, + looseToArray, + StyleSheetTuple, +} from './page-loader' import measureWebVitals from './performance-relayer' import { createRouter, makePublicRouterInstance } from './router' @@ -92,11 +96,21 @@ if (process.env.__NEXT_I18N_SUPPORT) { detectDomainLocale, } = require('../next-server/lib/i18n/detect-domain-locale') as typeof import('../next-server/lib/i18n/detect-domain-locale') + const { + parseRelativeUrl, + } = require('../next-server/lib/router/utils/parse-relative-url') as typeof import('../next-server/lib/router/utils/parse-relative-url') + + const { + formatUrl, + } = require('../next-server/lib/router/utils/format-url') as typeof import('../next-server/lib/router/utils/format-url') + if (locales) { - const localePathResult = normalizeLocalePath(asPath, locales) + const parsedAs = parseRelativeUrl(asPath) + const localePathResult = normalizeLocalePath(parsedAs.pathname, locales) if (localePathResult.detectedLocale) { - asPath = asPath.substr(localePathResult.detectedLocale.length + 1) || '/' + parsedAs.pathname = localePathResult.pathname + asPath = formatUrl(parsedAs) } else { // derive the default locale if it wasn't detected in the asPath // since we don't prerender static pages with all possible default @@ -276,6 +290,9 @@ export default async (opts: { webpackHMR?: any } = {}) => { } } } catch (error) { + if (INITIAL_CSS_LOAD_ERROR in error) { + throw error + } // This catches errors like throwing in the top level of a module initialErr = error } diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 08d3ff085d5d8..2592816f44c09 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -261,8 +261,6 @@ function Link(props: React.PropsWithChildren) { } const p = props.prefetch !== false - const [childElm, setChildElm] = React.useState() - const router = useRouter() const pathname = (router && router.pathname) || '/' @@ -276,25 +274,8 @@ function Link(props: React.PropsWithChildren) { } }, [pathname, props.href, props.as]) - React.useEffect(() => { - if ( - p && - IntersectionObserver && - childElm && - childElm.tagName && - isLocalURL(href) - ) { - // Join on an invalid URI character - const isPrefetched = prefetched[href + '%' + as] - if (!isPrefetched) { - return listenToIntersections(childElm, () => { - prefetch(router, href, as) - }) - } - } - }, [p, childElm, href, as, router]) - 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} @@ -302,22 +283,49 @@ function Link(props: React.PropsWithChildren) { // This will return the first child, if multiple are provided it will throw an error const child: any = Children.only(children) + const childRef: any = child && typeof child === 'object' && child.ref + + const cleanup = React.useRef<() => void>() + const setRef = React.useCallback( + (el: Element) => { + // cleanup previous event handlers + if (cleanup.current) { + cleanup.current() + cleanup.current = undefined + } + + if (p && IntersectionObserver && el && el.tagName && isLocalURL(href)) { + // Join on an invalid URI character + const isPrefetched = prefetched[href + '%' + as] + if (!isPrefetched) { + cleanup.current = listenToIntersections(el, () => { + prefetch(router, href, as, { + locale: + typeof locale !== 'undefined' + ? locale + : router && router.locale, + }) + }) + } + } + + if (childRef) { + if (typeof childRef === 'function') childRef(el) + else if (typeof childRef === 'object') { + childRef.current = el + } + } + }, + [p, childRef, href, as, router, locale] + ) + const childProps: { onMouseEnter?: React.MouseEventHandler onClick: React.MouseEventHandler href?: string ref?: any } = { - ref: (el: any) => { - if (el) setChildElm(el) - - if (child && typeof child === 'object' && child.ref) { - if (typeof child.ref === 'function') child.ref(el) - else if (typeof child.ref === 'object') { - child.ref.current = el - } - } - }, + ref: setRef, onClick: (e: React.MouseEvent) => { if (child.props && typeof child.props.onClick === 'function') { child.props.onClick(e) diff --git a/packages/next/client/next-dev.js b/packages/next/client/next-dev.js index 1b6a1a3e307a2..dc5b3625b1824 100644 --- a/packages/next/client/next-dev.js +++ b/packages/next/client/next-dev.js @@ -4,7 +4,6 @@ import EventSourcePolyfill from './dev/event-source-polyfill' import initOnDemandEntries from './dev/on-demand-entries-client' import initWebpackHMR from './dev/webpack-hot-middleware-client' import initializeBuildWatcher from './dev/dev-build-watcher' -import initializePrerenderIndicator from './dev/prerender-indicator' import { displayContent } from './dev/fouc' import { getEventSourceWrapper } from './dev/error-overlay/eventsource' import * as querystring from '../next-server/lib/router/utils/querystring' @@ -80,13 +79,6 @@ initNext({ webpackHMR }) buildIndicatorHandler = handler }) } - if ( - process.env.__NEXT_PRERENDER_INDICATOR && - // disable by default in electron - !(typeof process !== 'undefined' && 'electron' in process.versions) - ) { - initializePrerenderIndicator() - } // delay rendering until after styles have been applied in development displayContent(() => { diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 58d6ccf647d31..c3340e3b20b49 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -1,15 +1,14 @@ import { ComponentType } from 'react' import type { ClientSsgManifest } from '../build' import type { ClientBuildManifest } from '../build/webpack/plugins/build-manifest-plugin' -import mitt from '../next-server/lib/mitt' import type { MittEmitter } from '../next-server/lib/mitt' +import mitt from '../next-server/lib/mitt' import { addBasePath, - markLoadingError, - interpolateAs, addLocale, + interpolateAs, + markLoadingError, } from '../next-server/lib/router/router' - import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' import { parseRelativeUrl } from '../next-server/lib/router/utils/parse-relative-url' @@ -17,22 +16,6 @@ import { parseRelativeUrl } from '../next-server/lib/router/utils/parse-relative export const looseToArray = (input: any): T[] => [].slice.call(input) -function getInitialStylesheets(): StyleSheetTuple[] { - return looseToArray(document.styleSheets) - .filter( - (el: CSSStyleSheet) => - el.ownerNode && - (el.ownerNode as Element).tagName === 'LINK' && - (el.ownerNode as Element).hasAttribute('data-n-p') - ) - .map((sheet) => ({ - href: (sheet.ownerNode as Element).getAttribute('href')!, - text: looseToArray(sheet.cssRules) - .map((r) => r.cssText) - .join(''), - })) -} - function hasRel(rel: string, link?: HTMLLinkElement) { try { link = document.createElement('link') @@ -44,6 +27,8 @@ function pageLoadError(route: string) { return markLoadingError(new Error(`Error loading ${route}`)) } +export const INITIAL_CSS_LOAD_ERROR = Symbol('INITIAL_CSS_LOAD_ERROR') + const relPrefetch = hasRel('preload') && !hasRel('prefetch') ? // https://caniuse.com/#feat=link-rel-preload @@ -203,7 +188,12 @@ export default class PageLoader { * @param {string} href the route href (file-system path) * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes */ - getDataHref(href: string, asPath: string, ssg: boolean, locale?: string) { + getDataHref( + href: string, + asPath: string, + ssg: boolean, + locale?: string | false + ) { const { pathname: hrefPathname, query, search } = parseRelativeUrl(href) const { pathname: asPathname } = parseRelativeUrl(asPath) const route = normalizeRoute(hrefPathname) @@ -229,7 +219,7 @@ export default class PageLoader { * @param {string} href the route href (file-system path) * @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes */ - prefetchData(href: string, asPath: string, locale?: string) { + prefetchData(href: string, asPath: string, locale?: string | false) { const { pathname: hrefPathname } = parseRelativeUrl(href) const route = normalizeRoute(hrefPathname) return this.promisedSsgManifest!.then( @@ -408,7 +398,9 @@ export default class PageLoader { // should resolve instantly. Promise.all(cssFiles.map((d) => fetchStyleSheet(d))).catch( (err) => { - if (isInitialLoad) return getInitialStylesheets() + if (isInitialLoad) { + Object.defineProperty(err, INITIAL_CSS_LOAD_ERROR, {}) + } throw err } ) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 0f94004e934ce..57220c567751a 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -22,7 +22,7 @@ export type Header = { headers: Array<{ key: string; value: string }> } -const allowedStatusCodes = new Set([301, 302, 303, 307, 308]) +export const allowedStatusCodes = new Set([301, 302, 303, 307, 308]) export function getRedirectStatus(route: Redirect): number { return ( diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 728a78d9a2de0..a626c36dcd1a6 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -62,7 +62,10 @@ export function addLocale( defaultLocale?: string ) { if (process.env.__NEXT_I18N_SUPPORT) { - return locale && locale !== defaultLocale && !path.startsWith('/' + locale) + return locale && + locale !== defaultLocale && + !path.startsWith('/' + locale + '/') && + path !== '/' + locale ? addPathPrefix(path, '/' + locale) : path } @@ -71,7 +74,8 @@ export function addLocale( export function delLocale(path: string, locale?: string) { if (process.env.__NEXT_I18N_SUPPORT) { - return locale && path.startsWith('/' + locale) + return locale && + (path.startsWith('/' + locale + '/') || path === '/' + locale) ? path.substr(locale.length + 1) || '/' : path } @@ -269,6 +273,7 @@ export type NextRouter = BaseRouter & export type PrefetchOptions = { priority?: boolean + locale?: string | false } export type PrivateRouteInfo = { @@ -853,6 +858,12 @@ export default class Router implements BaseRouter { window.scrollTo((options as any)._N_X, (options as any)._N_Y) } } + + if (process.env.__NEXT_I18N_SUPPORT) { + if (this.locale) { + document.documentElement.lang = this.locale + } + } Router.events.emit('routeChangeComplete', as) return true @@ -1171,6 +1182,26 @@ export default class Router implements BaseRouter { let { pathname } = parsed + if (process.env.__NEXT_I18N_SUPPORT) { + const normalizeLocalePath = require('../i18n/normalize-locale-path') + .normalizeLocalePath as typeof import('../i18n/normalize-locale-path').normalizeLocalePath + + if (options.locale === false) { + pathname = normalizeLocalePath!(pathname, this.locales).pathname + parsed.pathname = pathname + url = formatWithValidation(parsed) + + let parsedAs = parseRelativeUrl(asPath) + const localePathResult = normalizeLocalePath!( + parsedAs.pathname, + this.locales + ) + parsedAs.pathname = localePathResult.pathname + options.locale = localePathResult.detectedLocale || options.locale + asPath = formatWithValidation(parsedAs) + } + } + const pages = await this.pageLoader.getPageList() parsed = this._resolveHref(parsed, pages) as typeof parsed @@ -1190,7 +1221,7 @@ export default class Router implements BaseRouter { this.pageLoader.prefetchData( url, asPath, - this.locale, + typeof options.locale !== 'undefined' ? options.locale : this.locale, this.defaultLocale ), this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route), diff --git a/packages/next/next-server/lib/router/utils/prepare-destination.ts b/packages/next/next-server/lib/router/utils/prepare-destination.ts index 90df77c446c5b..9eb46a80c9bad 100644 --- a/packages/next/next-server/lib/router/utils/prepare-destination.ts +++ b/packages/next/next-server/lib/router/utils/prepare-destination.ts @@ -19,6 +19,10 @@ export default function prepareDestination( port?: string } & ReturnType = {} as any + // clone query so we don't modify the original + query = Object.assign({}, query) + delete query.__nextLocale + if (destination.startsWith('/')) { parsedDestination = parseRelativeUrl(destination) } else { diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index a4fa310e3b328..9700fe1c4d3e7 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -25,15 +25,14 @@ const defaultConfig: { [key: string]: any } = { compress: true, analyticsId: process.env.VERCEL_ANALYTICS_ID || '', images: { - deviceSizes: [320, 420, 768, 1024, 1200], - imageSizes: [], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], domains: [], path: '/_next/image', loader: 'default', }, devIndicators: { buildActivity: true, - autoPrerender: true, }, onDemandEntries: { maxInactiveAge: 60 * 1000, @@ -220,14 +219,14 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (typeof images !== 'object') { throw new Error( - `Specified images should be an object received ${typeof images}.\nSee more info here: https://err.sh/nextjs/invalid-images-config` + `Specified images should be an object received ${typeof images}.\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } if (images.domains) { if (!Array.isArray(images.domains)) { throw new Error( - `Specified images.domains should be an Array received ${typeof images.domains}.\nSee more info here: https://err.sh/nextjs/invalid-images-config` + `Specified images.domains should be an Array received ${typeof images.domains}.\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } @@ -244,7 +243,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { throw new Error( `Specified images.domains should be an Array of strings received invalid values (${invalid.join( ', ' - )}).\nSee more info here: https://err.sh/nextjs/invalid-images-config` + )}).\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } } @@ -252,7 +251,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { const { deviceSizes } = images if (!Array.isArray(deviceSizes)) { throw new Error( - `Specified images.deviceSizes should be an Array received ${typeof deviceSizes}.\nSee more info here: https://err.sh/nextjs/invalid-images-config` + `Specified images.deviceSizes should be an Array received ${typeof deviceSizes}.\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } @@ -270,7 +269,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { throw new Error( `Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( ', ' - )}).\nSee more info here: https://err.sh/nextjs/invalid-images-config` + )}).\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } } @@ -278,7 +277,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { const { imageSizes } = images if (!Array.isArray(imageSizes)) { throw new Error( - `Specified images.imageSizes should be an Array received ${typeof imageSizes}.\nSee more info here: https://err.sh/nextjs/invalid-images-config` + `Specified images.imageSizes should be an Array received ${typeof imageSizes}.\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } @@ -296,7 +295,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { throw new Error( `Specified images.imageSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join( ', ' - )}).\nSee more info here: https://err.sh/nextjs/invalid-images-config` + )}).\nSee more info here: https://err.sh/next.js/invalid-images-config` ) } } @@ -316,13 +315,13 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (i18nType !== 'object') { throw new Error( - `Specified i18n should be an object received ${i18nType}.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n should be an object received ${i18nType}.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } if (!Array.isArray(i18n.locales)) { throw new Error( - `Specified i18n.locales should be an Array received ${typeof i18n.locales}.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n.locales should be an Array received ${typeof i18n.locales}.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } @@ -330,7 +329,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (!i18n.defaultLocale || defaultLocaleType !== 'string') { throw new Error( - `Specified i18n.defaultLocale should be a string.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n.defaultLocale should be a string.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } @@ -374,14 +373,14 @@ function assignDefaults(userConfig: { [key: string]: any }) { .map((item: any) => JSON.stringify(item)) .join( '\n' - )}\n\ndomains value must follow format { domain: 'example.fr', defaultLocale: 'fr', locales: ['fr'] }.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + )}\n\ndomains value must follow format { domain: 'example.fr', defaultLocale: 'fr', locales: ['fr'] }.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } } if (!Array.isArray(i18n.locales)) { throw new Error( - `Specified i18n.locales must be an array of locale strings e.g. ["en-US", "nl-NL"] received ${typeof i18n.locales}.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n.locales must be an array of locale strings e.g. ["en-US", "nl-NL"] received ${typeof i18n.locales}.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } @@ -402,7 +401,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (!i18n.locales.includes(i18n.defaultLocale)) { throw new Error( - `Specified i18n.defaultLocale should be included in i18n.locales.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n.defaultLocale should be included in i18n.locales.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } @@ -412,14 +411,14 @@ function assignDefaults(userConfig: { [key: string]: any }) { ...i18n.locales.filter((locale: string) => locale !== i18n.defaultLocale), ] - const localeDetectionType = typeof i18n.locales.localeDetection + const localeDetectionType = typeof i18n.localeDetection if ( localeDetectionType !== 'boolean' && localeDetectionType !== 'undefined' ) { throw new Error( - `Specified i18n.localeDetection should be undefined or a boolean received ${localeDetectionType}.\nSee more info here: https://err.sh/nextjs/invalid-i18n-config` + `Specified i18n.localeDetection should be undefined or a boolean received ${localeDetectionType}.\nSee more info here: https://err.sh/next.js/invalid-i18n-config` ) } } diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts index 238fb247cd2a6..e6f8d05936273 100644 --- a/packages/next/next-server/server/image-optimizer.ts +++ b/packages/next/next-server/server/image-optimizer.ts @@ -18,8 +18,8 @@ const PNG = 'image/png' const JPEG = 'image/jpeg' const GIF = 'image/gif' const SVG = 'image/svg+xml' -const MIME_TYPES = [/* AVIF, */ WEBP, PNG, JPEG] const CACHE_VERSION = 1 +const MODERN_TYPES = [/* AVIF, */ WEBP] const ANIMATABLE_TYPES = [WEBP, PNG, GIF] const VECTOR_TYPES = [SVG] @@ -49,7 +49,7 @@ export async function imageOptimizer( const { headers } = req const { url, w, q } = parsedUrl.query - const mimeType = mediaType(headers.accept, MIME_TYPES) || '' + const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept) let href: string if (!url) { @@ -259,6 +259,8 @@ export async function imageOptimizer( try { const transformer = sharp(upstreamBuffer) + transformer.rotate() // auto rotate based on EXIF data + const { width: metaWidth } = await transformer.metadata() if (metaWidth && metaWidth > width) { @@ -294,6 +296,11 @@ export async function imageOptimizer( return { finished: true } } +function getSupportedMimeType(options: string[], accept = ''): string { + const mimeType = mediaType(accept, options) + return accept.includes(mimeType) ? mimeType : '' +} + function getHash(items: (string | number | undefined)[]) { const hash = createHash('sha256') for (let item of items) { diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 2a116b19ad1ff..8b6b4c3c4b79a 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -312,10 +312,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 - ) + let acceptPreferredLocale = + i18n.localeDetection !== false + ? accept.language(req.headers['accept-language'], i18n.locales) + : detectedLocale const { host } = req?.headers || {} // remove port from host and remove port if present @@ -429,7 +429,11 @@ export default class Server { res.end() return } - parsedUrl.query.__nextLocale = detectedLocale || defaultLocale + + parsedUrl.query.__nextLocale = + localePathResult.detectedLocale || + detectedDomain?.defaultLocale || + defaultLocale } res.statusCode = 200 @@ -966,8 +970,19 @@ export default class Server { // if basePath is defined require it be present if (basePath) { - if (pathParts[0] !== basePath.substr(1)) return { finished: false } - pathParts.shift() + const basePathParts = basePath.split('/') + // remove first empty value + basePathParts.shift() + + if ( + !basePathParts.every((part: string, idx: number) => { + return part === pathParts[idx] + }) + ) { + return { finished: false } + } + + pathParts.splice(0, basePathParts.length) } const path = `/${pathParts.join('/')}` diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index d497c70036ac4..44ec6c5c03751 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -52,6 +52,7 @@ import { FontManifest, getFontDefinitionFromManifest } from './font-utils' import { LoadComponentsReturnType, ManifestItem } from './load-components' import { normalizePagePath } from './normalize-page-path' import optimizeAmp from './optimize-amp' +import { allowedStatusCodes } from '../../lib/load-custom-routes' function noRouter() { const message = @@ -307,38 +308,60 @@ const invalidKeysMsg = (methodName: string, invalidKeys: string[]) => { type Redirect = { permanent: boolean destination: string + statusCode?: number } -function checkRedirectValues(redirect: Redirect, req: IncomingMessage) { - const { destination, permanent } = redirect - let invalidPermanent = typeof permanent !== 'boolean' - let invalidDestination = typeof destination !== 'string' +function checkRedirectValues( + redirect: Redirect, + req: IncomingMessage, + method: 'getStaticProps' | 'getServerSideProps' +) { + const { destination, permanent, statusCode } = redirect + let errors: string[] = [] + + const hasStatusCode = typeof statusCode !== 'undefined' + const hasPermanent = typeof permanent !== 'undefined' + + if (hasPermanent && hasStatusCode) { + errors.push(`\`permanent\` and \`statusCode\` can not both be provided`) + } else if (hasPermanent && typeof permanent !== 'boolean') { + errors.push(`\`permanent\` must be \`true\` or \`false\``) + } else if (hasStatusCode && !allowedStatusCodes.has(statusCode!)) { + errors.push( + `\`statusCode\` must undefined or one of ${[...allowedStatusCodes].join( + ', ' + )}` + ) + } + const destinationType = typeof destination - if (invalidPermanent || invalidDestination) { + if (destinationType !== 'string') { + errors.push( + `\`destination\` should be string but received ${destinationType}` + ) + } + + if (errors.length > 0) { throw new Error( - `Invalid redirect object returned from getStaticProps for ${req.url}\n` + - `Expected${ - invalidPermanent - ? ` \`permanent\` to be boolean but received ${typeof permanent}` - : '' - }${invalidPermanent && invalidDestination ? ' and' : ''}${ - invalidDestination - ? ` \`destinatino\` to be string but received ${typeof destination}` - : '' - }\n` + + `Invalid redirect object returned from ${method} for ${req.url}\n` + + errors.join(' and ') + + '\n' + `See more info here: https://err.sh/vercel/next.js/invalid-redirect-gssp` ) } } function handleRedirect(res: ServerResponse, redirect: Redirect) { - const statusCode = redirect.permanent + const statusCode = redirect.statusCode + ? redirect.statusCode + : redirect.permanent ? PERMANENT_REDIRECT_STATUS : TEMPORARY_REDIRECT_STATUS - if (redirect.permanent) { + if (statusCode === PERMANENT_REDIRECT_STATUS) { res.setHeader('Refresh', `0;url=${redirect.destination}`) } + res.statusCode = statusCode res.setHeader('Location', redirect.destination) res.end() @@ -654,7 +677,7 @@ export async function renderToHTML( data.redirect && typeof data.redirect === 'object' ) { - checkRedirectValues(data.redirect, req) + checkRedirectValues(data.redirect, req, 'getStaticProps') if (isBuildTimeSSG) { throw new Error( @@ -791,7 +814,7 @@ export async function renderToHTML( } if ('redirect' in data && typeof data.redirect === 'object') { - checkRedirectValues(data.redirect, req) + checkRedirectValues(data.redirect, req, 'getServerSideProps') if (isDataReq) { ;(data as any).props = { diff --git a/packages/next/next-server/server/router.ts b/packages/next/next-server/server/router.ts index 9c858ab724bc3..ba78dee256f31 100644 --- a/packages/next/next-server/server/router.ts +++ b/packages/next/next-server/server/router.ts @@ -177,6 +177,17 @@ export default class Router { currentPathname = replaceBasePath(this.basePath, currentPathname!) } + // re-add locale for custom-routes to allow matching against + if ( + isCustomRoute && + (req as any).__nextStrippedLocale && + parsedUrl.query.__nextLocale + ) { + currentPathname = `/${parsedUrl.query.__nextLocale}${ + currentPathname === '/' ? '' : currentPathname + }` + } + const newParams = testRoute.match(currentPathname) // Check if the match function matched diff --git a/packages/next/package.json b/packages/next/package.json index 59c1a4edd2a2a..799b48c7e2cd4 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "10.0.0", + "version": "10.0.1-canary.6", "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": "10.0.0", - "@next/polyfill-module": "10.0.0", - "@next/react-dev-overlay": "10.0.0", - "@next/react-refresh-utils": "10.0.0", + "@next/env": "10.0.1-canary.6", + "@next/polyfill-module": "10.0.1-canary.6", + "@next/react-dev-overlay": "10.0.1-canary.6", + "@next/react-refresh-utils": "10.0.1-canary.6", "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": "10.0.0", + "@next/polyfill-nomodule": "10.0.1-canary.6", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", "@taskr/watch": "1.1.0", diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 0b129671e54a4..4b0334d927835 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -163,8 +163,11 @@ export class Head extends Component< dynamicImports, } = this.context const cssFiles = files.allFiles.filter((f) => f.endsWith('.css')) - const sharedFiles = new Set(files.sharedFiles) + const sharedFiles: Set = new Set(files.sharedFiles) + // Unmanaged files are CSS files that will be handled directly by the + // webpack runtime (`mini-css-extract-plugin`). + let unmangedFiles: Set = new Set([]) let dynamicCssFiles = dedupe( dynamicImports.filter((f) => f.file.endsWith('.css')) ).map((f) => f.file) @@ -173,13 +176,14 @@ export class Head extends Component< dynamicCssFiles = dynamicCssFiles.filter( (f) => !(existing.has(f) || sharedFiles.has(f)) ) + unmangedFiles = new Set(dynamicCssFiles) cssFiles.push(...dynamicCssFiles) } const cssLinkElements: JSX.Element[] = [] cssFiles.forEach((file) => { const isSharedFile = sharedFiles.has(file) - + const isUnmanagedFile = unmangedFiles.has(file) cssLinkElements.push( ) }) diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index 2701d42e089d0..0b0f345fa3fdd 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": "10.0.0", + "version": "10.0.1-canary.6", "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 40ecae8aa38fa..152c6431e223b 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": "10.0.0", + "version": "10.0.1-canary.6", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/test/integration/basepath/pages/absolute-url-basepath.js b/test/integration/basepath/pages/absolute-url-basepath.js index 330865a6b5bc6..2eb79686863cb 100644 --- a/test/integration/basepath/pages/absolute-url-basepath.js +++ b/test/integration/basepath/pages/absolute-url-basepath.js @@ -1,5 +1,6 @@ import React from 'react' import Link from 'next/link' +import { useRouter } from 'next/router' export async function getServerSideProps({ query: { port } }) { if (!port) { @@ -9,10 +10,14 @@ export async function getServerSideProps({ query: { port } }) { } export default function Page({ port }) { + const router = useRouter() return ( <> - - http://localhost:{port}/docs/something-else + + + http://localhost:{port} + {router.basePath}/something-else + ) diff --git a/test/integration/basepath/pages/invalid-manual-basepath.js b/test/integration/basepath/pages/invalid-manual-basepath.js index 6c188e0834e8a..1f5d165ce7a38 100644 --- a/test/integration/basepath/pages/invalid-manual-basepath.js +++ b/test/integration/basepath/pages/invalid-manual-basepath.js @@ -1,8 +1,9 @@ import Link from 'next/link' +import { useRouter } from 'next/router' export default () => ( <> - +

Hello World

diff --git a/test/integration/basepath/test/index.test.js b/test/integration/basepath/test/index.test.js index bc12692064d23..a643c53d8a381 100644 --- a/test/integration/basepath/test/index.test.js +++ b/test/integration/basepath/test/index.test.js @@ -4,13 +4,10 @@ import webdriver from 'next-webdriver' import { join, resolve } from 'path' import url from 'url' import { - nextServer, launchApp, findPort, killApp, nextBuild, - startApp, - stopApp, waitFor, check, getBrowserBodyText, @@ -36,6 +33,9 @@ jest.setTimeout(1000 * 60 * 2) const appDir = join(__dirname, '..') let externalApp +let app +let appPort +let basePath = '/docs' const nextConfig = new File(resolve(appDir, 'next.config.js')) @@ -52,10 +52,327 @@ afterAll(async () => { externalApp.close() }) -const runTests = (context, dev = false) => { +const runTests = (dev = false) => { if (dev) { + describe('Hot Module Reloading', () => { + describe('delete a page and add it back', () => { + it('should load the page properly', async () => { + const contactPagePath = join( + __dirname, + '../', + 'pages', + 'hmr', + 'contact.js' + ) + const newContactPagePath = join( + __dirname, + '../', + 'pages', + 'hmr', + '_contact.js' + ) + let browser + try { + browser = await webdriver(appPort, `${basePath}/hmr/contact`) + const text = await browser.elementByCss('p').text() + expect(text).toBe('This is the contact page.') + + // Rename the file to mimic a deleted page + renameSync(contactPagePath, newContactPagePath) + + await check( + () => getBrowserBodyText(browser), + /This page could not be found/ + ) + + // Rename the file back to the original filename + renameSync(newContactPagePath, contactPagePath) + + // wait until the page comes back + await check( + () => getBrowserBodyText(browser), + /This is the contact page/ + ) + } finally { + if (browser) { + await browser.close() + } + if (existsSync(newContactPagePath)) { + renameSync(newContactPagePath, contactPagePath) + } + } + }) + }) + + describe('editing a page', () => { + it('should detect the changes and display it', async () => { + let browser + try { + browser = await webdriver(appPort, `${basePath}/hmr/about`) + const text = await browser.elementByCss('p').text() + expect(text).toBe('This is the about page.') + + const aboutPagePath = join( + __dirname, + '../', + 'pages', + 'hmr', + 'about.js' + ) + + const originalContent = readFileSync(aboutPagePath, 'utf8') + const editedContent = originalContent.replace( + 'This is the about page', + 'COOL page' + ) + + // change the content + writeFileSync(aboutPagePath, editedContent, 'utf8') + + await check(() => getBrowserBodyText(browser), /COOL page/) + + // add the original content + writeFileSync(aboutPagePath, originalContent, 'utf8') + + await check( + () => getBrowserBodyText(browser), + /This is the about page/ + ) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should not reload unrelated pages', async () => { + let browser + try { + browser = await webdriver(appPort, `${basePath}/hmr/counter`) + const text = await browser + .elementByCss('button') + .click() + .elementByCss('button') + .click() + .elementByCss('p') + .text() + expect(text).toBe('COUNT: 2') + + const aboutPagePath = join( + __dirname, + '../', + 'pages', + 'hmr', + 'about.js' + ) + + const originalContent = readFileSync(aboutPagePath, 'utf8') + const editedContent = originalContent.replace( + 'This is the about page', + 'COOL page' + ) + + // Change the about.js page + writeFileSync(aboutPagePath, editedContent, 'utf8') + + // wait for 5 seconds + await waitFor(5000) + + // Check whether the this page has reloaded or not. + const newText = await browser.elementByCss('p').text() + expect(newText).toBe('COUNT: 2') + + // restore the about page content. + writeFileSync(aboutPagePath, originalContent, 'utf8') + } finally { + if (browser) { + await browser.close() + } + } + }) + + // Added because of a regression in react-hot-loader, see issues: #4246 #4273 + // Also: https://github.com/zeit/styled-jsx/issues/425 + it('should update styles correctly', async () => { + let browser + try { + browser = await webdriver(appPort, `${basePath}/hmr/style`) + const pTag = await browser.elementByCss('.hmr-style-page p') + const initialFontSize = await pTag.getComputedCss('font-size') + + expect(initialFontSize).toBe('100px') + + const pagePath = join(__dirname, '../', 'pages', 'hmr', 'style.js') + + const originalContent = readFileSync(pagePath, 'utf8') + const editedContent = originalContent.replace('100px', '200px') + + // Change the page + writeFileSync(pagePath, editedContent, 'utf8') + + try { + // Check whether the this page has reloaded or not. + await check(async () => { + const editedPTag = await browser.elementByCss( + '.hmr-style-page p' + ) + return editedPTag.getComputedCss('font-size') + }, /200px/) + } finally { + // Finally is used so that we revert the content back to the original regardless of the test outcome + // restore the about page content. + writeFileSync(pagePath, originalContent, 'utf8') + } + } finally { + if (browser) { + await browser.close() + } + } + }) + + // Added because of a regression in react-hot-loader, see issues: #4246 #4273 + // Also: https://github.com/zeit/styled-jsx/issues/425 + it('should update styles in a stateful component correctly', async () => { + let browser + const pagePath = join( + __dirname, + '../', + 'pages', + 'hmr', + 'style-stateful-component.js' + ) + const originalContent = readFileSync(pagePath, 'utf8') + try { + browser = await webdriver( + appPort, + `${basePath}/hmr/style-stateful-component` + ) + const pTag = await browser.elementByCss('.hmr-style-page p') + const initialFontSize = await pTag.getComputedCss('font-size') + + expect(initialFontSize).toBe('100px') + const editedContent = originalContent.replace('100px', '200px') + + // Change the page + writeFileSync(pagePath, editedContent, 'utf8') + + // Check whether the this page has reloaded or not. + await check(async () => { + const editedPTag = await browser.elementByCss('.hmr-style-page p') + return editedPTag.getComputedCss('font-size') + }, /200px/) + } finally { + if (browser) { + await browser.close() + } + writeFileSync(pagePath, originalContent, 'utf8') + } + }) + + // Added because of a regression in react-hot-loader, see issues: #4246 #4273 + // Also: https://github.com/zeit/styled-jsx/issues/425 + it('should update styles in a dynamic component correctly', async () => { + let browser = null + let secondBrowser = null + const pagePath = join( + __dirname, + '../', + 'components', + 'hmr', + 'dynamic.js' + ) + const originalContent = readFileSync(pagePath, 'utf8') + try { + browser = await webdriver( + appPort, + `${basePath}/hmr/style-dynamic-component` + ) + const div = await browser.elementByCss('#dynamic-component') + const initialClientClassName = await div.getAttribute('class') + const initialFontSize = await div.getComputedCss('font-size') + + expect(initialFontSize).toBe('100px') + + const initialHtml = await renderViaHTTP( + appPort, + `${basePath}/hmr/style-dynamic-component` + ) + expect(initialHtml.includes('100px')).toBeTruthy() + + const $initialHtml = cheerio.load(initialHtml) + const initialServerClassName = $initialHtml( + '#dynamic-component' + ).attr('class') + + expect( + initialClientClassName === initialServerClassName + ).toBeTruthy() + + const editedContent = originalContent.replace('100px', '200px') + + // Change the page + writeFileSync(pagePath, editedContent, 'utf8') + + // wait for 5 seconds + await waitFor(5000) + + secondBrowser = await webdriver( + appPort, + `${basePath}/hmr/style-dynamic-component` + ) + // Check whether the this page has reloaded or not. + const editedDiv = await secondBrowser.elementByCss( + '#dynamic-component' + ) + const editedClientClassName = await editedDiv.getAttribute('class') + const editedFontSize = await editedDiv.getComputedCss('font-size') + const browserHtml = await secondBrowser + .elementByCss('html') + .getAttribute('innerHTML') + + expect(editedFontSize).toBe('200px') + expect(browserHtml.includes('font-size:200px;')).toBe(true) + expect(browserHtml.includes('font-size:100px;')).toBe(false) + + const editedHtml = await renderViaHTTP( + appPort, + `${basePath}/hmr/style-dynamic-component` + ) + expect(editedHtml.includes('200px')).toBeTruthy() + const $editedHtml = cheerio.load(editedHtml) + const editedServerClassName = $editedHtml( + '#dynamic-component' + ).attr('class') + + expect(editedClientClassName === editedServerClassName).toBe(true) + } finally { + // Finally is used so that we revert the content back to the original regardless of the test outcome + // restore the about page content. + writeFileSync(pagePath, originalContent, 'utf8') + + if (browser) { + await browser.close() + } + + if (secondBrowser) { + secondBrowser.close() + } + } + }) + }) + }) + + it('should respect basePath in amphtml link rel', async () => { + const html = await renderViaHTTP(appPort, `${basePath}/amp-hybrid`) + const $ = cheerio.load(html) + const expectedAmpHtmlUrl = `${basePath}/amp-hybrid?amp=1` + expect($('link[rel=amphtml]').first().attr('href')).toBe( + expectedAmpHtmlUrl + ) + }) + it('should render error in dev overlay correctly', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.elementByCss('#trigger-error').click() expect(await hasRedbox(browser)).toBe(true) @@ -79,11 +396,11 @@ const runTests = (context, dev = false) => { const routesManifest = await fs.readJSON( join(appDir, '.next/routes-manifest.json') ) - expect(routesManifest.basePath).toBe('/docs') + expect(routesManifest.basePath).toBe(basePath) }) it('should prefetch pages correctly when manually called', async () => { - const browser = await webdriver(context.appPort, '/docs/other-page') + const browser = await webdriver(appPort, `${basePath}/other-page`) await browser.eval('window.next.router.prefetch("/gssp")') await check( @@ -107,7 +424,7 @@ const runTests = (context, dev = false) => { }) it('should prefetch pages correctly in viewport with ', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.next.router.prefetch("/gssp")') await check( @@ -136,56 +453,53 @@ const runTests = (context, dev = false) => { } it('should 404 for public file without basePath', async () => { - const res = await fetchViaHTTP(context.appPort, '/data.txt') + const res = await fetchViaHTTP(appPort, '/data.txt') expect(res.status).toBe(404) }) it('should serve public file with basePath correctly', async () => { - const res = await fetchViaHTTP(context.appPort, '/docs/data.txt') + const res = await fetchViaHTTP(appPort, `${basePath}/data.txt`) expect(res.status).toBe(200) expect(await res.text()).toBe('hello world') }) it('should rewrite with basePath by default', async () => { - const html = await renderViaHTTP(context.appPort, '/docs/rewrite-1') + const html = await renderViaHTTP(appPort, `${basePath}/rewrite-1`) expect(html).toContain('getServerSideProps') }) it('should not rewrite without basePath without disabling', async () => { - const res = await fetchViaHTTP(context.appPort, '/rewrite-1') + const res = await fetchViaHTTP(appPort, '/rewrite-1') expect(res.status).toBe(404) }) it('should not rewrite with basePath when set to false', async () => { // won't 404 as it matches the dynamic [slug] route - const html = await renderViaHTTP( - context.appPort, - '/docs/rewrite-no-basePath' - ) + const html = await renderViaHTTP(appPort, `${basePath}/rewrite-no-basePath`) expect(html).toContain('slug') }) it('should rewrite without basePath when set to false', async () => { - const html = await renderViaHTTP(context.appPort, '/rewrite-no-basePath') + const html = await renderViaHTTP(appPort, '/rewrite-no-basePath') expect(html).toContain('hello from external') }) it('should redirect with basePath by default', async () => { const res = await fetchViaHTTP( - context.appPort, - '/docs/redirect-1', + appPort, + `${basePath}/redirect-1`, undefined, { redirect: 'manual', } ) const { pathname } = url.parse(res.headers.get('location') || '') - expect(pathname).toBe('/docs/somewhere-else') + expect(pathname).toBe(`${basePath}/somewhere-else`) expect(res.status).toBe(307) }) it('should not redirect without basePath without disabling', async () => { - const res = await fetchViaHTTP(context.appPort, '/redirect-1', undefined, { + const res = await fetchViaHTTP(appPort, '/redirect-1', undefined, { redirect: 'manual', }) expect(res.status).toBe(404) @@ -193,16 +507,13 @@ const runTests = (context, dev = false) => { it('should not redirect with basePath when set to false', async () => { // won't 404 as it matches the dynamic [slug] route - const html = await renderViaHTTP( - context.appPort, - '/docs/rewrite-no-basePath' - ) + const html = await renderViaHTTP(appPort, `${basePath}/rewrite-no-basePath`) expect(html).toContain('slug') }) it('should redirect without basePath when set to false', async () => { const res = await fetchViaHTTP( - context.appPort, + appPort, '/redirect-no-basepath', undefined, { @@ -216,66 +527,69 @@ const runTests = (context, dev = false) => { // it('should add header with basePath by default', async () => { - const res = await fetchViaHTTP(context.appPort, '/docs/add-header') + const res = await fetchViaHTTP(appPort, `${basePath}/add-header`) expect(res.headers.get('x-hello')).toBe('world') }) it('should not add header without basePath without disabling', async () => { - const res = await fetchViaHTTP(context.appPort, '/add-header') + const res = await fetchViaHTTP(appPort, '/add-header') expect(res.headers.get('x-hello')).toBe(null) }) it('should not add header with basePath when set to false', async () => { const res = await fetchViaHTTP( - context.appPort, - '/docs/add-header-no-basepath' + appPort, + `${basePath}/add-header-no-basepath` ) expect(res.headers.get('x-hello')).toBe(null) }) it('should add header without basePath when set to false', async () => { - const res = await fetchViaHTTP(context.appPort, '/add-header-no-basepath') + const res = await fetchViaHTTP(appPort, '/add-header-no-basepath') expect(res.headers.get('x-hello')).toBe('world') }) it('should not update URL for a 404', async () => { - const browser = await webdriver(context.appPort, '/missing') + const browser = await webdriver(appPort, '/missing') const pathname = await browser.eval(() => window.location.pathname) expect(await browser.eval(() => window.next.router.asPath)).toBe('/missing') expect(pathname).toBe('/missing') }) it('should handle 404 urls that start with basePath', async () => { - const browser = await webdriver(context.appPort, '/docshello') + const browser = await webdriver(appPort, `${basePath}hello`) expect(await browser.eval(() => window.next.router.asPath)).toBe( - '/docshello' + `${basePath}hello` ) expect(await browser.eval(() => window.location.pathname)).toBe( - '/docshello' + `${basePath}hello` ) }) it('should navigate back to a non-basepath 404 that starts with basepath', async () => { - const browser = await webdriver(context.appPort, '/docshello') + const browser = await webdriver(appPort, `${basePath}hello`) await browser.eval(() => (window.navigationMarker = true)) await browser.eval(() => window.next.router.push('/hello')) await browser.waitForElementByCss('#pathname') await browser.back() - check(() => browser.eval(() => window.location.pathname), '/docshello') + check( + () => browser.eval(() => window.location.pathname), + `${basePath}hello` + ) expect(await browser.eval(() => window.next.router.asPath)).toBe( - '/docshello' + `${basePath}hello` ) expect(await browser.eval(() => window.navigationMarker)).toBe(true) }) it('should update dynamic params after mount correctly', async () => { - const browser = await webdriver(context.appPort, '/docs/hello-dynamic') + const browser = await webdriver(appPort, `${basePath}/hello-dynamic`) const text = await browser.elementByCss('#slug').text() expect(text).toContain('slug: hello-dynamic') }) it('should navigate to index page with getStaticProps', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNavigate = "hi"') await browser.elementByCss('#index-gsp').click() @@ -296,7 +610,7 @@ const runTests = (context, dev = false) => { const href = url.parse(fullHref).pathname if ( - href.startsWith('/docs/_next/data') && + href.startsWith(`${basePath}/_next/data`) && href.endsWith('index.json') && !href.endsWith('index/index.json') ) { @@ -309,7 +623,7 @@ const runTests = (context, dev = false) => { }) it('should navigate to nested index page with getStaticProps', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNavigate = "hi"') await browser.elementByCss('#nested-index-gsp').click() @@ -330,7 +644,7 @@ const runTests = (context, dev = false) => { const href = url.parse(fullHref).pathname if ( - href.startsWith('/docs/_next/data') && + href.startsWith(`${basePath}/_next/data`) && href.endsWith('index/index.json') ) { found = true @@ -342,17 +656,17 @@ const runTests = (context, dev = false) => { }) it('should work with nested folder with same name as basePath', async () => { - const html = await renderViaHTTP(context.appPort, '/docs/docs/another') + const html = await renderViaHTTP(appPort, `${basePath}/docs/another`) expect(html).toContain('hello from another') - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.next.router.push("/docs/another")') await check(() => browser.elementByCss('p').text(), /hello from another/) }) it('should work with normal dynamic page', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.elementByCss('#dynamic-link').click() await check( () => browser.eval(() => document.documentElement.innerHTML), @@ -361,15 +675,15 @@ const runTests = (context, dev = false) => { }) it('should work with hash links', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.elementByCss('#hashlink').click() const url = new URL(await browser.eval(() => window.location.href)) - expect(url.pathname).toBe('/docs/hello') + expect(url.pathname).toBe(`${basePath}/hello`) expect(url.hash).toBe('#hashlink') }) it('should work with catch-all page', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.elementByCss('#catchall-link').click() await check( () => browser.eval(() => document.documentElement.innerHTML), @@ -379,30 +693,30 @@ const runTests = (context, dev = false) => { it('should redirect trailing slash correctly', async () => { const res = await fetchViaHTTP( - context.appPort, - '/docs/hello/', + appPort, + `${basePath}/hello/`, {}, { redirect: 'manual' } ) expect(res.status).toBe(308) const { pathname } = new URL(res.headers.get('location')) - expect(pathname).toBe('/docs/hello') + expect(pathname).toBe(`${basePath}/hello`) }) it('should redirect trailing slash on root correctly', async () => { const res = await fetchViaHTTP( - context.appPort, - '/docs/', + appPort, + `${basePath}/`, {}, { redirect: 'manual' } ) expect(res.status).toBe(308) const { pathname } = new URL(res.headers.get('location')) - expect(pathname).toBe('/docs') + expect(pathname).toBe(`${basePath}`) }) it('should navigate an absolute url', async () => { - const browser = await webdriver(context.appPort, `/docs/absolute-url`) + const browser = await webdriver(appPort, `${basePath}/absolute-url`) await browser.waitForElementByCss('#absolute-link').click() await check( () => browser.eval(() => window.location.origin), @@ -412,8 +726,8 @@ const runTests = (context, dev = false) => { it('should navigate an absolute local url with basePath', async () => { const browser = await webdriver( - context.appPort, - `/docs/absolute-url-basepath?port=${context.appPort}` + appPort, + `${basePath}/absolute-url-basepath?port=${appPort}` ) await browser.eval(() => (window._didNotNavigate = true)) await browser.waitForElementByCss('#absolute-link').click() @@ -427,8 +741,8 @@ const runTests = (context, dev = false) => { it('should navigate an absolute local url without basePath', async () => { const browser = await webdriver( - context.appPort, - `/docs/absolute-url-no-basepath?port=${context.appPort}` + appPort, + `${basePath}/absolute-url-no-basepath?port=${appPort}` ) await browser.waitForElementByCss('#absolute-link').click() await check( @@ -442,8 +756,8 @@ const runTests = (context, dev = false) => { it('should 404 when manually adding basePath with ', async () => { const browser = await webdriver( - context.appPort, - '/docs/invalid-manual-basepath' + appPort, + `${basePath}/invalid-manual-basepath` ) await browser.eval('window.beforeNav = "hi"') await browser.elementByCss('#other-page-link').click() @@ -459,9 +773,9 @@ const runTests = (context, dev = false) => { }) it('should 404 when manually adding basePath with router.push', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNav = "hi"') - await browser.eval('window.next.router.push("/docs/other-page")') + await browser.eval(`window.next.router.push("${basePath}/other-page")`) await check(() => browser.eval('window.beforeNav'), { test(content) { @@ -474,9 +788,9 @@ const runTests = (context, dev = false) => { }) it('should 404 when manually adding basePath with router.replace', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNav = "hi"') - await browser.eval('window.next.router.replace("/docs/other-page")') + await browser.eval(`window.next.router.replace("${basePath}/other-page")`) await check(() => browser.eval('window.beforeNav'), { test(content) { @@ -489,7 +803,7 @@ const runTests = (context, dev = false) => { }) it('should show the hello page under the /docs prefix', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { const text = await browser.elementByCss('h1').text() expect(text).toBe('Hello World') @@ -499,7 +813,7 @@ const runTests = (context, dev = false) => { }) it('should have correct router paths on first load of /', async () => { - const browser = await webdriver(context.appPort, '/docs') + const browser = await webdriver(appPort, `${basePath}`) await browser.waitForElementByCss('#pathname') const pathname = await browser.elementByCss('#pathname').text() expect(pathname).toBe('/') @@ -508,7 +822,7 @@ const runTests = (context, dev = false) => { }) it('should have correct router paths on first load of /hello', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.waitForElementByCss('#pathname') const pathname = await browser.elementByCss('#pathname').text() expect(pathname).toBe('/hello') @@ -517,7 +831,7 @@ const runTests = (context, dev = false) => { }) it('should fetch data for getStaticProps without reloading', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNavigate = true') await browser.elementByCss('#gsp-link').click() await browser.waitForElementByCss('#gsp') @@ -531,7 +845,7 @@ const runTests = (context, dev = false) => { }) it('should fetch data for getServerSideProps without reloading', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) await browser.eval('window.beforeNavigate = true') await browser.elementByCss('#gssp-link').click() await browser.waitForElementByCss('#gssp') @@ -547,27 +861,27 @@ const runTests = (context, dev = false) => { }) it('should have correct href for a link', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) const href = await browser.elementByCss('a').getAttribute('href') const { pathname } = url.parse(href) - expect(pathname).toBe('/docs/other-page') + expect(pathname).toBe(`${basePath}/other-page`) }) it('should have correct href for a link to /', async () => { - const browser = await webdriver(context.appPort, '/docs/link-to-root') + const browser = await webdriver(appPort, `${basePath}/link-to-root`) const href = await browser.elementByCss('#link-back').getAttribute('href') const { pathname } = url.parse(href) - expect(pathname).toBe('/docs') + expect(pathname).toBe(`${basePath}`) }) it('should show 404 for page not under the /docs prefix', async () => { - const text = await renderViaHTTP(context.appPort, '/hello') + const text = await renderViaHTTP(appPort, '/hello') expect(text).not.toContain('Hello World') expect(text).toContain('This page could not be found') }) it('should show the other-page page under the /docs prefix', async () => { - const browser = await webdriver(context.appPort, '/docs/other-page') + const browser = await webdriver(appPort, `${basePath}/other-page`) try { const text = await browser.elementByCss('h1').text() expect(text).toBe('Hello Other') @@ -577,13 +891,13 @@ const runTests = (context, dev = false) => { }) it('should have basePath field on Router', async () => { - const html = await renderViaHTTP(context.appPort, '/docs/hello') + const html = await renderViaHTTP(appPort, `${basePath}/hello`) const $ = cheerio.load(html) - expect($('#base-path').text()).toBe('/docs') + expect($('#base-path').text()).toBe(`${basePath}`) }) it('should navigate to the page without refresh', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { await browser.eval('window.itdidnotrefresh = "hello"') const text = await browser @@ -601,7 +915,7 @@ const runTests = (context, dev = false) => { }) it('should use urls with basepath in router events', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { await browser.eval('window._clearEventLog()') await browser @@ -611,9 +925,9 @@ const runTests = (context, dev = false) => { const eventLog = await browser.eval('window._getEventLog()') expect(eventLog).toEqual([ - ['routeChangeStart', '/docs/other-page'], - ['beforeHistoryChange', '/docs/other-page'], - ['routeChangeComplete', '/docs/other-page'], + ['routeChangeStart', `${basePath}/other-page`], + ['beforeHistoryChange', `${basePath}/other-page`], + ['routeChangeComplete', `${basePath}/other-page`], ]) } finally { await browser.close() @@ -621,15 +935,15 @@ const runTests = (context, dev = false) => { }) it('should use urls with basepath in router events for hash changes', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { await browser.eval('window._clearEventLog()') await browser.elementByCss('#hash-change').click() const eventLog = await browser.eval('window._getEventLog()') expect(eventLog).toEqual([ - ['hashChangeStart', '/docs/hello#some-hash'], - ['hashChangeComplete', '/docs/hello#some-hash'], + ['hashChangeStart', `${basePath}/hello#some-hash`], + ['hashChangeComplete', `${basePath}/hello#some-hash`], ]) } finally { await browser.close() @@ -637,7 +951,7 @@ const runTests = (context, dev = false) => { }) it('should use urls with basepath in router events for cancelled routes', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { await browser.eval('window._clearEventLog()') await browser @@ -649,11 +963,11 @@ const runTests = (context, dev = false) => { const eventLog = await browser.eval('window._getEventLog()') expect(eventLog).toEqual([ - ['routeChangeStart', '/docs/slow-route'], - ['routeChangeError', 'Route Cancelled', true, '/docs/slow-route'], - ['routeChangeStart', '/docs/other-page'], - ['beforeHistoryChange', '/docs/other-page'], - ['routeChangeComplete', '/docs/other-page'], + ['routeChangeStart', `${basePath}/slow-route`], + ['routeChangeError', 'Route Cancelled', true, `${basePath}/slow-route`], + ['routeChangeStart', `${basePath}/other-page`], + ['beforeHistoryChange', `${basePath}/other-page`], + ['routeChangeComplete', `${basePath}/other-page`], ]) } finally { await browser.close() @@ -661,7 +975,7 @@ const runTests = (context, dev = false) => { }) it('should use urls with basepath in router events for failed route change', async () => { - const browser = await webdriver(context.appPort, '/docs/hello') + const browser = await webdriver(appPort, `${basePath}/hello`) try { await browser.eval('window._clearEventLog()') await browser.elementByCss('#error-route').click() @@ -670,12 +984,12 @@ const runTests = (context, dev = false) => { const eventLog = await browser.eval('window._getEventLog()') expect(eventLog).toEqual([ - ['routeChangeStart', '/docs/error-route'], + ['routeChangeStart', `${basePath}/error-route`], [ 'routeChangeError', 'Failed to load static props', null, - '/docs/error-route', + `${basePath}/error-route`, ], ]) } finally { @@ -684,7 +998,7 @@ const runTests = (context, dev = false) => { }) it('should allow URL query strings without refresh', async () => { - const browser = await webdriver(context.appPort, '/docs/hello?query=true') + const browser = await webdriver(appPort, `${basePath}/hello?query=true`) try { await browser.eval('window.itdidnotrefresh = "hello"') await new Promise((resolve, reject) => { @@ -702,7 +1016,7 @@ const runTests = (context, dev = false) => { }) it('should correctly replace state when same asPath but different url', async () => { - const browser = await webdriver(context.appPort, '/docs') + const browser = await webdriver(appPort, `${basePath}`) try { await browser.elementByCss('#hello-link').click() await browser.waitForElementByCss('#something-else-link') @@ -719,386 +1033,94 @@ const runTests = (context, dev = false) => { } describe('basePath development', () => { - let server - - let context = {} - beforeAll(async () => { - context.appPort = await findPort() - server = await launchApp(join(__dirname, '..'), context.appPort, { + appPort = await findPort() + app = await launchApp(join(__dirname, '..'), appPort, { env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, }) }) - afterAll(async () => { - await killApp(server) - }) - - runTests(context, true) - - describe('Hot Module Reloading', () => { - describe('delete a page and add it back', () => { - it('should load the page properly', async () => { - const contactPagePath = join( - __dirname, - '../', - 'pages', - 'hmr', - 'contact.js' - ) - const newContactPagePath = join( - __dirname, - '../', - 'pages', - 'hmr', - '_contact.js' - ) - let browser - try { - browser = await webdriver(context.appPort, '/docs/hmr/contact') - const text = await browser.elementByCss('p').text() - expect(text).toBe('This is the contact page.') - - // Rename the file to mimic a deleted page - renameSync(contactPagePath, newContactPagePath) - - await check( - () => getBrowserBodyText(browser), - /This page could not be found/ - ) + afterAll(() => killApp(app)) - // Rename the file back to the original filename - renameSync(newContactPagePath, contactPagePath) - - // wait until the page comes back - await check( - () => getBrowserBodyText(browser), - /This is the contact page/ - ) - } finally { - if (browser) { - await browser.close() - } - if (existsSync(newContactPagePath)) { - renameSync(newContactPagePath, contactPagePath) - } - } - }) - }) - - describe('editing a page', () => { - it('should detect the changes and display it', async () => { - let browser - try { - browser = await webdriver(context.appPort, '/docs/hmr/about') - const text = await browser.elementByCss('p').text() - expect(text).toBe('This is the about page.') - - const aboutPagePath = join( - __dirname, - '../', - 'pages', - 'hmr', - 'about.js' - ) - - const originalContent = readFileSync(aboutPagePath, 'utf8') - const editedContent = originalContent.replace( - 'This is the about page', - 'COOL page' - ) - - // change the content - writeFileSync(aboutPagePath, editedContent, 'utf8') - - await check(() => getBrowserBodyText(browser), /COOL page/) - - // add the original content - writeFileSync(aboutPagePath, originalContent, 'utf8') - - await check( - () => getBrowserBodyText(browser), - /This is the about page/ - ) - } finally { - if (browser) { - await browser.close() - } - } - }) - - it('should not reload unrelated pages', async () => { - let browser - try { - browser = await webdriver(context.appPort, '/docs/hmr/counter') - const text = await browser - .elementByCss('button') - .click() - .elementByCss('button') - .click() - .elementByCss('p') - .text() - expect(text).toBe('COUNT: 2') - - const aboutPagePath = join( - __dirname, - '../', - 'pages', - 'hmr', - 'about.js' - ) - - const originalContent = readFileSync(aboutPagePath, 'utf8') - const editedContent = originalContent.replace( - 'This is the about page', - 'COOL page' - ) - - // Change the about.js page - writeFileSync(aboutPagePath, editedContent, 'utf8') - - // wait for 5 seconds - await waitFor(5000) - - // Check whether the this page has reloaded or not. - const newText = await browser.elementByCss('p').text() - expect(newText).toBe('COUNT: 2') - - // restore the about page content. - writeFileSync(aboutPagePath, originalContent, 'utf8') - } finally { - if (browser) { - await browser.close() - } - } - }) - - // Added because of a regression in react-hot-loader, see issues: #4246 #4273 - // Also: https://github.com/zeit/styled-jsx/issues/425 - it('should update styles correctly', async () => { - let browser - try { - browser = await webdriver(context.appPort, '/docs/hmr/style') - const pTag = await browser.elementByCss('.hmr-style-page p') - const initialFontSize = await pTag.getComputedCss('font-size') - - expect(initialFontSize).toBe('100px') - - const pagePath = join(__dirname, '../', 'pages', 'hmr', 'style.js') - - const originalContent = readFileSync(pagePath, 'utf8') - const editedContent = originalContent.replace('100px', '200px') - - // Change the page - writeFileSync(pagePath, editedContent, 'utf8') - - try { - // Check whether the this page has reloaded or not. - await check(async () => { - const editedPTag = await browser.elementByCss('.hmr-style-page p') - return editedPTag.getComputedCss('font-size') - }, /200px/) - } finally { - // Finally is used so that we revert the content back to the original regardless of the test outcome - // restore the about page content. - writeFileSync(pagePath, originalContent, 'utf8') - } - } finally { - if (browser) { - await browser.close() - } - } - }) - - // Added because of a regression in react-hot-loader, see issues: #4246 #4273 - // Also: https://github.com/zeit/styled-jsx/issues/425 - it('should update styles in a stateful component correctly', async () => { - let browser - const pagePath = join( - __dirname, - '../', - 'pages', - 'hmr', - 'style-stateful-component.js' - ) - const originalContent = readFileSync(pagePath, 'utf8') - try { - browser = await webdriver( - context.appPort, - '/docs/hmr/style-stateful-component' - ) - const pTag = await browser.elementByCss('.hmr-style-page p') - const initialFontSize = await pTag.getComputedCss('font-size') - - expect(initialFontSize).toBe('100px') - const editedContent = originalContent.replace('100px', '200px') - - // Change the page - writeFileSync(pagePath, editedContent, 'utf8') - - // Check whether the this page has reloaded or not. - await check(async () => { - const editedPTag = await browser.elementByCss('.hmr-style-page p') - return editedPTag.getComputedCss('font-size') - }, /200px/) - } finally { - if (browser) { - await browser.close() - } - writeFileSync(pagePath, originalContent, 'utf8') - } - }) - - // Added because of a regression in react-hot-loader, see issues: #4246 #4273 - // Also: https://github.com/zeit/styled-jsx/issues/425 - it('should update styles in a dynamic component correctly', async () => { - let browser = null - let secondBrowser = null - const pagePath = join( - __dirname, - '../', - 'components', - 'hmr', - 'dynamic.js' - ) - const originalContent = readFileSync(pagePath, 'utf8') - try { - browser = await webdriver( - context.appPort, - '/docs/hmr/style-dynamic-component' - ) - const div = await browser.elementByCss('#dynamic-component') - const initialClientClassName = await div.getAttribute('class') - const initialFontSize = await div.getComputedCss('font-size') - - expect(initialFontSize).toBe('100px') - - const initialHtml = await renderViaHTTP( - context.appPort, - '/docs/hmr/style-dynamic-component' - ) - expect(initialHtml.includes('100px')).toBeTruthy() - - const $initialHtml = cheerio.load(initialHtml) - const initialServerClassName = $initialHtml( - '#dynamic-component' - ).attr('class') - - expect(initialClientClassName === initialServerClassName).toBeTruthy() - - const editedContent = originalContent.replace('100px', '200px') - - // Change the page - writeFileSync(pagePath, editedContent, 'utf8') - - // wait for 5 seconds - await waitFor(5000) - - secondBrowser = await webdriver( - context.appPort, - '/docs/hmr/style-dynamic-component' - ) - // Check whether the this page has reloaded or not. - const editedDiv = await secondBrowser.elementByCss( - '#dynamic-component' - ) - const editedClientClassName = await editedDiv.getAttribute('class') - const editedFontSize = await editedDiv.getComputedCss('font-size') - const browserHtml = await secondBrowser - .elementByCss('html') - .getAttribute('innerHTML') - - expect(editedFontSize).toBe('200px') - expect(browserHtml.includes('font-size:200px;')).toBe(true) - expect(browserHtml.includes('font-size:100px;')).toBe(false) - - const editedHtml = await renderViaHTTP( - context.appPort, - '/docs/hmr/style-dynamic-component' - ) - expect(editedHtml.includes('200px')).toBeTruthy() - const $editedHtml = cheerio.load(editedHtml) - const editedServerClassName = $editedHtml('#dynamic-component').attr( - 'class' - ) - - expect(editedClientClassName === editedServerClassName).toBe(true) - } finally { - // Finally is used so that we revert the content back to the original regardless of the test outcome - // restore the about page content. - writeFileSync(pagePath, originalContent, 'utf8') - - if (browser) { - await browser.close() - } + runTests(true) +}) - if (secondBrowser) { - secondBrowser.close() - } - } - }) +describe('basePath production', () => { + beforeAll(async () => { + await nextBuild(appDir, [], { + env: { + EXTERNAL_APP: `http://localhost:${externalApp.address().port}`, + }, }) + appPort = await findPort() + app = await nextStart(appDir, appPort) }) + afterAll(() => killApp(app)) + + runTests() it('should respect basePath in amphtml link rel', async () => { - const html = await renderViaHTTP(context.appPort, '/docs/amp-hybrid') + const html = await renderViaHTTP(appPort, `${basePath}/amp-hybrid`) const $ = cheerio.load(html) - const expectedAmpHtmlUrl = '/docs/amp-hybrid?amp=1' + const expectedAmpHtmlUrl = `${basePath}/amp-hybrid.amp` expect($('link[rel=amphtml]').first().attr('href')).toBe(expectedAmpHtmlUrl) }) }) -describe('basePath production', () => { - let context = {} - let server - let app +describe('multi-level basePath development', () => { + beforeAll(async () => { + basePath = '/hello/world' + nextConfig.replace(`basePath: '/docs'`, `basePath: '/hello/world'`) + appPort = await findPort() + app = await launchApp(join(__dirname, '..'), appPort, { + env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, + }) + }) + afterAll(async () => { + basePath = '/docs' + nextConfig.replace(`basePath: '/hello/world'`, `basePath: '/docs'`) + await killApp(app) + }) + + runTests(true) +}) +describe('multi-level basePath production', () => { beforeAll(async () => { + basePath = '/hello/world' + nextConfig.replace(`basePath: '/docs'`, `basePath: '/hello/world'`) await nextBuild(appDir, [], { env: { EXTERNAL_APP: `http://localhost:${externalApp.address().port}`, }, }) - app = nextServer({ - dir: join(__dirname, '../'), - dev: false, - quiet: true, - }) - - server = await startApp(app) - context.appPort = server.address().port + appPort = await findPort() + app = await nextStart(appDir, appPort) }) - - afterAll(() => stopApp(server)) - - runTests(context) - - it('should respect basePath in amphtml link rel', async () => { - const html = await renderViaHTTP(context.appPort, '/docs/amp-hybrid') - const $ = cheerio.load(html) - const expectedAmpHtmlUrl = '/docs/amp-hybrid.amp' - expect($('link[rel=amphtml]').first().attr('href')).toBe(expectedAmpHtmlUrl) + afterAll(async () => { + basePath = '/docs' + nextConfig.replace(`basePath: '/hello/world'`, `basePath: '/docs'`) + await killApp(app) }) + + runTests() }) describe('basePath serverless', () => { - let context = {} - let app - beforeAll(async () => { await nextConfig.replace( '// replace me', `target: 'experimental-serverless-trace',` ) await nextBuild(appDir) - context.appPort = await findPort() - app = await nextStart(appDir, context.appPort) + appPort = await findPort() + app = await nextStart(appDir, appPort) }) afterAll(async () => { await killApp(app) await nextConfig.restore() }) - runTests(context) + runTests() it('should always strip basePath in serverless-loader', async () => { const appPort = await findPort() diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index 7e32ab2dbb4f8..f58ce529aa060 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -95,16 +95,16 @@ describe('Build Output', () => { expect(indexSize.endsWith('B')).toBe(true) // should be no bigger than 60.8 kb - expect(parseFloat(indexFirstLoad) - 61.2).toBeLessThanOrEqual(0) + expect(parseFloat(indexFirstLoad) - 61.3).toBeLessThanOrEqual(0) expect(indexFirstLoad.endsWith('kB')).toBe(true) expect(parseFloat(err404Size) - 3.5).toBeLessThanOrEqual(0) expect(err404Size.endsWith('kB')).toBe(true) - expect(parseFloat(err404FirstLoad) - 64.4).toBeLessThanOrEqual(0) + expect(parseFloat(err404FirstLoad) - 64.5).toBeLessThanOrEqual(0) expect(err404FirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(sharedByAll) - 61).toBeLessThanOrEqual(0) + expect(parseFloat(sharedByAll) - 61.1).toBeLessThanOrEqual(0) expect(sharedByAll.endsWith('kB')).toBe(true) if (_appSize.endsWith('kB')) { diff --git a/test/integration/gssp-redirect/pages/gsp-blog/[post].js b/test/integration/gssp-redirect/pages/gsp-blog/[post].js index cd3fae7419a73..65c129cbe99e5 100644 --- a/test/integration/gssp-redirect/pages/gsp-blog/[post].js +++ b/test/integration/gssp-redirect/pages/gsp-blog/[post].js @@ -25,10 +25,27 @@ export const getStaticProps = ({ params }) => { destination = params.post.split('dest-').pop().replace(/_/g, '/') } + let permanent = undefined + let statusCode = undefined + + if (params.post.includes('statusCode-')) { + permanent = parseInt( + params.post.split('statusCode-').pop().split('-').shift(), + 10 + ) + } + + if (params.post.includes('permanent')) { + permanent = true + } else if (!statusCode) { + permanent = false + } + return { redirect: { destination, - permanent: params.post.includes('permanent'), + permanent, + statusCode, }, } } diff --git a/test/integration/gssp-redirect/pages/gssp-blog/[post].js b/test/integration/gssp-redirect/pages/gssp-blog/[post].js index d01d19f31ae49..0afeb687d1be0 100644 --- a/test/integration/gssp-redirect/pages/gssp-blog/[post].js +++ b/test/integration/gssp-redirect/pages/gssp-blog/[post].js @@ -15,10 +15,27 @@ export const getServerSideProps = ({ params }) => { destination = params.post.split('dest-').pop().replace(/_/g, '/') } + let permanent = undefined + let statusCode = undefined + + if (params.post.includes('statusCode-')) { + statusCode = parseInt( + params.post.split('statusCode-').pop().split('-').shift(), + 10 + ) + } + + if (params.post.includes('permanent')) { + permanent = true + } else if (!statusCode) { + permanent = false + } + return { redirect: { destination, - permanent: params.post.includes('permanent'), + permanent, + statusCode, }, } } diff --git a/test/integration/gssp-redirect/test/index.test.js b/test/integration/gssp-redirect/test/index.test.js index 4852aac6ad91d..964f0afb00429 100644 --- a/test/integration/gssp-redirect/test/index.test.js +++ b/test/integration/gssp-redirect/test/index.test.js @@ -55,6 +55,40 @@ const runTests = () => { expect(res.headers.get('refresh')).toMatch(/url=\/404/) }) + it('should apply statusCode 301 redirect when visited directly for GSSP page', async () => { + const res = await fetchViaHTTP( + appPort, + '/gssp-blog/redirect-statusCode-301', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(301) + + const { pathname } = url.parse(res.headers.get('location')) + + expect(pathname).toBe('/404') + expect(res.headers.get('refresh')).toBe(null) + }) + + it('should apply statusCode 303 redirect when visited directly for GSSP page', async () => { + const res = await fetchViaHTTP( + appPort, + '/gssp-blog/redirect-statusCode-303', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(303) + + const { pathname } = url.parse(res.headers.get('location')) + + expect(pathname).toBe('/404') + expect(res.headers.get('refresh')).toBe(null) + }) + it('should apply redirect when fallback GSP page is visited directly (internal dynamic)', async () => { const browser = await webdriver( appPort, diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js index e6aec3f6e6bab..2493a70593d7e 100644 --- a/test/integration/i18n-support/next.config.js +++ b/test/integration/i18n-support/next.config.js @@ -1,6 +1,7 @@ module.exports = { // target: 'experimental-serverless-trace', i18n: { + // localeDetection: false, locales: ['nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr', 'en-US', 'en'], defaultLocale: 'en-US', domains: [ @@ -20,4 +21,70 @@ module.exports = { }, ], }, + async redirects() { + return [ + { + source: '/en-US/redirect', + destination: '/somewhere-else', + permanent: false, + }, + { + source: '/nl/redirect', + destination: '/somewhere-else', + permanent: false, + }, + { + source: '/redirect', + destination: '/somewhere-else', + permanent: false, + }, + ] + }, + async rewrites() { + return [ + { + source: '/en-US/rewrite', + destination: '/another', + }, + { + source: '/nl/rewrite', + destination: '/another', + }, + { + source: '/rewrite', + destination: '/another', + }, + ] + }, + async headers() { + return [ + { + source: '/en-US/add-header', + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + { + source: '/nl/add-header', + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + { + source: '/add-header', + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + ] + }, } diff --git a/test/integration/i18n-support/pages/404.js b/test/integration/i18n-support/pages/404.js index f228ca559b1c4..d5056521f6f55 100644 --- a/test/integration/i18n-support/pages/404.js +++ b/test/integration/i18n-support/pages/404.js @@ -7,10 +7,12 @@ export default function NotFound(props) { ) } -export const getStaticProps = ({ locale }) => { +export const getStaticProps = ({ locale, locales, defaultLocale }) => { return { props: { locale, + locales, + defaultLocale, is404: true, }, } diff --git a/test/integration/i18n-support/pages/_app.js b/test/integration/i18n-support/pages/_app.js new file mode 100644 index 0000000000000..51785b1cc8ca5 --- /dev/null +++ b/test/integration/i18n-support/pages/_app.js @@ -0,0 +1,19 @@ +if (typeof window !== 'undefined') { + window.caughtWarns = [] + + const origWarn = window.console.warn + const origError = window.console.error + + window.console.warn = function (...args) { + window.caughtWarns.push(args.join(' ')) + origWarn(...args) + } + window.console.error = function (...args) { + window.caughtWarns.push(args.join(' ')) + origError(...args) + } +} + +export default function MyApp({ Component, pageProps }) { + return +} diff --git a/test/integration/i18n-support/pages/another.js b/test/integration/i18n-support/pages/another.js index d7d1febc49580..20cdbe8c89a30 100644 --- a/test/integration/i18n-support/pages/another.js +++ b/test/integration/i18n-support/pages/another.js @@ -9,6 +9,7 @@ export default function Page(props) {

another page

{JSON.stringify(props)}

{router.locale}

+

{router.defaultLocale}

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

{router.pathname}

diff --git a/test/integration/i18n-support/pages/frank.js b/test/integration/i18n-support/pages/frank.js new file mode 100644 index 0000000000000..56d0ebaea4ff1 --- /dev/null +++ b/test/integration/i18n-support/pages/frank.js @@ -0,0 +1,56 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + return ( + <> +

frank page

+

{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 + +
+ + ) +} + +export const getStaticProps = ({ locale, locales, defaultLocale }) => { + return { + props: { + locale, + locales, + defaultLocale, + }, + } +} diff --git a/test/integration/i18n-support/pages/gsp/fallback/[slug].js b/test/integration/i18n-support/pages/gsp/fallback/[slug].js index d0f6e91503912..c45fe9a10c8a8 100644 --- a/test/integration/i18n-support/pages/gsp/fallback/[slug].js +++ b/test/integration/i18n-support/pages/gsp/fallback/[slug].js @@ -11,6 +11,7 @@ export default function Page(props) {

gsp page

{JSON.stringify(props)}

{router.locale}

+

{router.defaultLocale}

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

{router.pathname}

@@ -24,6 +25,11 @@ export default function Page(props) { } export const getStaticProps = ({ params, locale, locales, defaultLocale }) => { + // ensure getStaticProps isn't called without params + if (!params || !params.slug) { + throw new Error(`missing params ${JSON.stringify(params)}`) + } + return { props: { params, diff --git a/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js index a97bd749443a1..91a4d74bea583 100644 --- a/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js +++ b/test/integration/i18n-support/pages/gsp/no-fallback/[slug].js @@ -11,6 +11,7 @@ export default function Page(props) {

gsp page

{JSON.stringify(props)}

{router.locale}

+

{router.defaultLocale}

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

{router.pathname}

@@ -24,6 +25,11 @@ export default function Page(props) { } export const getStaticProps = ({ params, locale, locales, defaultLocale }) => { + // ensure getStaticProps isn't called without params + if (!params || !params.slug) { + throw new Error(`missing params ${JSON.stringify(params)}`) + } + return { props: { params, diff --git a/test/integration/i18n-support/pages/index.js b/test/integration/i18n-support/pages/index.js index 345e004151c9f..c5cbdf31d99f2 100644 --- a/test/integration/i18n-support/pages/index.js +++ b/test/integration/i18n-support/pages/index.js @@ -9,6 +9,7 @@ export default function Page(props) {

index page

{JSON.stringify(props)}

{router.locale}

+

{router.defaultLocale}

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

{router.pathname}

diff --git a/test/integration/i18n-support/pages/links.js b/test/integration/i18n-support/pages/links.js index 9e358b7900206..247a92d33dad5 100644 --- a/test/integration/i18n-support/pages/links.js +++ b/test/integration/i18n-support/pages/links.js @@ -10,6 +10,7 @@ export default function Page(props) {

{JSON.stringify(props)}

{router.locale}

+

{router.defaultLocale}

{JSON.stringify(router.locales)}

{JSON.stringify(router.query)}

{router.pathname}

diff --git a/test/integration/i18n-support/pages/not-found/blocking-fallback/[slug].js b/test/integration/i18n-support/pages/not-found/blocking-fallback/[slug].js index 7257f7422968d..c17c1250573e7 100644 --- a/test/integration/i18n-support/pages/not-found/blocking-fallback/[slug].js +++ b/test/integration/i18n-support/pages/not-found/blocking-fallback/[slug].js @@ -22,6 +22,11 @@ export default function Page(props) { } export const getStaticProps = ({ params, locale, locales }) => { + // ensure getStaticProps isn't called without params + if (!params || !params.slug) { + throw new Error(`missing params ${JSON.stringify(params)}`) + } + if (locale === 'en' || locale === 'nl') { return { notFound: true, 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 0fde7256d1aae..6bca78d173b1b 100644 --- a/test/integration/i18n-support/pages/not-found/fallback/[slug].js +++ b/test/integration/i18n-support/pages/not-found/fallback/[slug].js @@ -24,6 +24,11 @@ export default function Page(props) { } export const getStaticProps = ({ params, locale, locales }) => { + // ensure getStaticProps isn't called without params + if (!params || !params.slug) { + throw new Error(`missing params ${JSON.stringify(params)}`) + } + if (locale === 'en' || locale === 'nl') { return { notFound: true, diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index ae28017776676..8ece675dcab96 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -1,5 +1,6 @@ /* eslint-env jest */ +import http from 'http' import url from 'url' import fs from 'fs-extra' import cheerio from 'cheerio' @@ -17,6 +18,8 @@ import { File, waitFor, normalizeRegEx, + getPageFileFromPagesManifest, + check, } from 'next-test-utils' jest.setTimeout(1000 * 60 * 2) @@ -38,6 +41,49 @@ async function addDefaultLocaleCookie(browser) { } function runTests(isDev) { + it('should have correct values for non-prefixed path', async () => { + for (const paths of [ + ['/links', '/links'], + ['/another', '/another'], + ['/gsp/fallback/first', '/gsp/fallback/[slug]'], + ['/gsp/no-fallback/first', '/gsp/no-fallback/[slug]'], + ]) { + const [asPath, pathname] = paths + + const res = await fetchViaHTTP(appPort, asPath, undefined, { + redirect: 'manual', + headers: { + 'accept-language': 'fr', + }, + }) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('html').attr('lang')).toBe('en-US') + expect($('#router-locale').text()).toBe('en-US') + expect($('#router-default-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('#router-pathname').text()).toBe(pathname) + expect($('#router-as-path').text()).toBe(asPath) + } + }) + + it('should not have hydration mis-match from hash', async () => { + const browser = await webdriver(appPort, '/en#') + + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + expect(await browser.elementByCss('#router-locale').text()).toBe('en') + expect(await browser.elementByCss('#router-default-locale').text()).toBe( + 'en-US' + ) + expect( + JSON.parse(await browser.elementByCss('#router-locales').text()) + ).toEqual(locales) + expect(await browser.elementByCss('#router-pathname').text()).toBe('/') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/') + expect(await browser.eval('window.caughtWarns')).toEqual([]) + }) + if (!isDev) { it('should add i18n config to routes-manifest', async () => { const routesManifest = await fs.readJSON( @@ -126,6 +172,11 @@ function runTests(isDev) { initialRevalidateSeconds: false, srcRoute: '/not-found/fallback/[slug]', }, + '/frank': { + dataRoute: `/_next/data/${buildId}/frank.json`, + initialRevalidateSeconds: false, + srcRoute: null, + }, '/gsp': { dataRoute: `/_next/data/${buildId}/gsp.json`, srcRoute: null, @@ -193,11 +244,94 @@ function runTests(isDev) { }) } + it('should apply redirects correctly', async () => { + for (const path of ['/redirect', '/en-US/redirect', '/nl/redirect']) { + const res = await fetchViaHTTP(appPort, path, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + + const parsed = url.parse(res.headers.get('location'), true) + expect(parsed.pathname).toBe('/somewhere-else') + expect(parsed.query).toEqual({}) + } + }) + + it('should apply headers correctly', async () => { + for (const path of ['/add-header', '/en-US/add-header', '/nl/add-header']) { + const res = await fetchViaHTTP(appPort, path, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(404) + expect(res.headers.get('x-hello')).toBe('world') + } + }) + + it('should apply rewrites correctly', async () => { + const checks = [ + { + locale: 'en-US', + path: '/rewrite', + }, + { + locale: 'en-US', + path: '/en-US/rewrite', + }, + { + locale: 'nl', + path: '/nl/rewrite', + }, + ] + + for (const check of checks) { + const res = await fetchViaHTTP(appPort, check.path, undefined, { + redirect: 'manual', + }) + + expect(res.status).toBe(200) + + const html = await res.text() + const $ = cheerio.load(html) + expect($('html').attr('lang')).toBe(check.locale) + expect($('#router-locale').text()).toBe(check.locale) + expect($('#router-pathname').text()).toBe('/another') + expect($('#router-as-path').text()).toBe('/rewrite') + } + }) + it('should navigate with locale prop correctly', async () => { const browser = await webdriver(appPort, '/links?nextLocale=fr') await addDefaultLocaleCookie(browser) await browser.eval('window.beforeNav = 1') + if (!isDev) { + await browser.eval(`(function() { + document.querySelector('#to-gsp').scrollIntoView() + document.querySelector('#to-fallback-first').scrollIntoView() + document.querySelector('#to-no-fallback-first').scrollIntoView() + })()`) + + await check(async () => { + for (const dataPath of [ + '/fr/gsp.json', + '/fr/gsp/fallback/first.json', + '/fr/gsp/fallback/hello.json', + ]) { + const found = await browser.eval(`(function() { + const links = [].slice.call(document.querySelectorAll('link')) + + for (var i = 0; i < links.length; i++) { + if (links[i].href.indexOf("${dataPath}") > -1) { + return true + } + } + return false + })()`) + return found ? 'yes' : 'no' + } + }, 'yes') + } + expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') expect(await browser.elementByCss('#router-as-path').text()).toBe( '/links?nextLocale=fr' @@ -209,6 +343,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'fr' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) await browser.elementByCss('#to-another').click() await browser.waitForElementByCss('#another') @@ -226,6 +363,7 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({}) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('fr') let parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/fr/another') @@ -245,6 +383,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'fr' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/links') @@ -266,16 +407,19 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({}) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('fr') parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/fr/another') expect(parsedUrl.query).toEqual({}) expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.eval('window.caughtWarns')).toEqual([]) }) it('should navigate with locale prop correctly GSP', async () => { const browser = await webdriver(appPort, '/links?nextLocale=nl') await addDefaultLocaleCookie(browser) + await browser.eval('window.beforeNav = 1') expect(await browser.elementByCss('#router-pathname').text()).toBe('/links') expect(await browser.elementByCss('#router-as-path').text()).toBe( @@ -288,6 +432,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'nl' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) await browser.elementByCss('#to-fallback-first').click() await browser.waitForElementByCss('#gsp') @@ -305,6 +452,7 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ slug: 'first' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('nl') let parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') @@ -324,6 +472,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'nl' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/links') @@ -345,10 +496,13 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ slug: 'first' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('nl') parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') expect(parsedUrl.query).toEqual({}) + expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.eval('window.caughtWarns')).toEqual([]) }) it('should navigate with locale false correctly', async () => { @@ -356,6 +510,34 @@ function runTests(isDev) { await addDefaultLocaleCookie(browser) await browser.eval('window.beforeNav = 1') + if (!isDev) { + await browser.eval(`(function() { + document.querySelector('#to-gsp').scrollIntoView() + document.querySelector('#to-fallback-first').scrollIntoView() + document.querySelector('#to-no-fallback-first').scrollIntoView() + })()`) + + await check(async () => { + for (const dataPath of [ + '/fr/gsp.json', + '/fr/gsp/fallback/first.json', + '/fr/gsp/fallback/hello.json', + ]) { + const found = await browser.eval(`(function() { + const links = [].slice.call(document.querySelectorAll('link')) + + for (var i = 0; i < links.length; i++) { + if (links[i].href.indexOf("${dataPath}") > -1) { + return true + } + } + return false + })()`) + return found ? 'yes' : 'no' + } + }, 'yes') + } + expect(await browser.elementByCss('#router-pathname').text()).toBe( '/locale-false' ) @@ -369,6 +551,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'fr' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) await browser.elementByCss('#to-another').click() await browser.waitForElementByCss('#another') @@ -386,6 +571,7 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({}) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('fr') let parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/fr/another') @@ -407,6 +593,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'fr' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/locale-false') @@ -428,16 +617,19 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({}) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('fr') parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/fr/another') expect(parsedUrl.query).toEqual({}) expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.eval('window.caughtWarns')).toEqual([]) }) it('should navigate with locale false correctly GSP', async () => { const browser = await webdriver(appPort, '/locale-false?nextLocale=nl') await addDefaultLocaleCookie(browser) + await browser.eval('window.beforeNav = 1') expect(await browser.elementByCss('#router-pathname').text()).toBe( '/locale-false' @@ -452,6 +644,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'nl' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) await browser.elementByCss('#to-fallback-first').click() await browser.waitForElementByCss('#gsp') @@ -469,6 +664,7 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ slug: 'first' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('nl') let parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') @@ -490,6 +686,9 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ nextLocale: 'nl' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe( + 'en-US' + ) parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/locale-false') @@ -511,10 +710,13 @@ function runTests(isDev) { expect( JSON.parse(await browser.elementByCss('#router-query').text()) ).toEqual({ slug: 'first' }) + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('nl') parsedUrl = url.parse(await browser.eval('window.location.href'), true) expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first') expect(parsedUrl.query).toEqual({}) + expect(await browser.eval('window.beforeNav')).toBe(1) + expect(await browser.eval('window.caughtWarns')).toEqual([]) }) it('should update asPath on the client correctly', async () => { @@ -895,6 +1097,30 @@ function runTests(isDev) { } }) + it('should transition on client properly for page that starts with locale', async () => { + const browser = await webdriver(appPort, '/fr') + await browser.eval(`(function() { + window.beforeNav = 1 + window.next.router.push('/frank') + })()`) + + await browser.waitForElementByCss('#frank') + + 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({}) + expect(await browser.elementByCss('#router-pathname').text()).toBe('/frank') + expect(await browser.elementByCss('#router-as-path').text()).toBe('/frank') + expect( + url.parse(await browser.eval(() => window.location.href)).pathname + ).toBe('/fr/frank') + expect(await browser.eval('window.beforeNav')).toBe(1) + }) + it('should 404 for GSP that returned notFound on client-transition', async () => { const browser = await webdriver(appPort, '/en') await browser.eval(`(function() { @@ -1435,7 +1661,7 @@ function runTests(isDev) { it('should load getStaticProps non-fallback correctly another locale via cookie', async () => { const html = await renderViaHTTP( appPort, - '/gsp/no-fallback/second', + '/nl-NL/gsp/no-fallback/second', {}, { headers: { @@ -1633,6 +1859,135 @@ describe('i18n Support', () => { await killApp(app) }) + it('should have correct props for blocking notFound', async () => { + const serverFile = getPageFileFromPagesManifest( + appDir, + '/not-found/blocking-fallback/[slug]' + ) + const appPort = await findPort() + const mod = require(join(appDir, '.next/serverless', serverFile)) + + const server = http.createServer(async (req, res) => { + try { + await mod.render(req, res) + } catch (err) { + res.statusCode = 500 + res.end('internal err') + } + }) + + await new Promise((resolve, reject) => { + server.listen(appPort, (err) => (err ? reject(err) : resolve())) + }) + console.log('listening on', appPort) + + const res = await fetchViaHTTP( + appPort, + '/nl/not-found/blocking-fallback/first' + ) + server.close() + + expect(res.status).toBe(404) + + const $ = cheerio.load(await res.text()) + const props = JSON.parse($('#props').text()) + + expect($('#not-found').text().length > 0).toBe(true) + expect(props).toEqual({ + is404: true, + locale: 'nl', + locales, + defaultLocale: 'en-US', + }) + }) + runTests() }) + + describe('with localeDetection disabled', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + nextConfig.replace('// localeDetection', 'localeDetection') + + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + nextConfig.restore() + await killApp(app) + }) + + it('should have localeDetection in routes-manifest', async () => { + const routesManifest = await fs.readJSON( + join(appDir, '.next/routes-manifest.json') + ) + + expect(routesManifest.i18n).toEqual({ + localeDetection: false, + locales: ['en-US', 'nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr', 'en'], + defaultLocale: 'en-US', + domains: [ + { + http: true, + domain: 'example.be', + defaultLocale: 'nl-BE', + locales: ['nl', 'nl-NL', 'nl-BE'], + }, + { + http: true, + domain: 'example.fr', + defaultLocale: 'fr', + locales: ['fr-BE'], + }, + ], + }) + }) + + it('should not detect locale from accept-language', async () => { + const res = await fetchViaHTTP( + appPort, + '/', + {}, + { + redirect: 'manual', + headers: { + 'accept-language': 'fr', + }, + } + ) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('html').attr('lang')).toBe('en-US') + expect($('#router-locale').text()).toBe('en-US') + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('#router-pathname').text()).toBe('/') + expect($('#router-as-path').text()).toBe('/') + }) + + it('should set locale from detected path', async () => { + for (const locale of locales) { + const res = await fetchViaHTTP( + appPort, + `/${locale}`, + {}, + { + redirect: 'manual', + headers: { + 'accept-language': 'en-US,en;q=0.9', + }, + } + ) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect(JSON.parse($('#router-locales').text())).toEqual(locales) + expect($('#router-pathname').text()).toBe('/') + expect($('#router-as-path').text()).toBe('/') + } + }) + }) }) diff --git a/test/integration/image-component/basic/next.config.js b/test/integration/image-component/basic/next.config.js index cdd95710263b7..5d8fb97cde549 100644 --- a/test/integration/image-component/basic/next.config.js +++ b/test/integration/image-component/basic/next.config.js @@ -1,7 +1,7 @@ module.exports = { images: { deviceSizes: [480, 1024, 1600, 2000], - imageSizes: [16, 64], + imageSizes: [16, 32, 48, 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 ddd00fd2fe3e9..98f17df0cd3f6 100644 --- a/test/integration/image-component/basic/pages/client-side.js +++ b/test/integration/image-component/basic/pages/client-side.js @@ -54,11 +54,11 @@ const ClientSide = () => { height={400} /> { height={400} /> { expect(await browser.elementById('basic-image').getAttribute('src')).toBe( - 'https://example.com/myaccount/foo.jpg?auto=format&w=480&q=60' + 'https://example.com/myaccount/foo.jpg?auto=format&fit=max&w=1024&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=480') + ).toBe( + 'https://example.com/myaccount/fooslash.jpg?auto=format&fit=max&w=1024' + ) }) 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') + ).toBe( + 'https://example.com/myaccount/foo.jpg?auto=format&fit=max&w=480&q=60 1x, https://example.com/myaccount/foo.jpg?auto=format&fit=max&w=1024&q=60 2x' + ) }) 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') + ).toBe( + 'https://example.com/myaccount/fooslash.jpg?auto=format&fit=max&w=480 1x, https://example.com/myaccount/fooslash.jpg?auto=format&fit=max&w=1024 2x' + ) }) it('should use imageSizes 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' + 'https://example.com/myaccount/icon.png?auto=format&fit=max&w=48' ) 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' + ).toBe( + 'https://example.com/myaccount/icon.png?auto=format&fit=max&w=16 1x, https://example.com/myaccount/icon.png?auto=format&fit=max&w=32 2x, https://example.com/myaccount/icon.png?auto=format&fit=max&w=48 3x' + ) + expect(await browser.elementById('icon-image-32').getAttribute('src')).toBe( + 'https://example.com/myaccount/icon.png?auto=format&fit=max&w=480' ) expect( - await browser.elementById('icon-image-64').getAttribute('srcset') - ).toBe('https://example.com/myaccount/icon.png?auto=format&w=64 64w') + await browser.elementById('icon-image-32').getAttribute('srcset') + ).toBe( + 'https://example.com/myaccount/icon.png?auto=format&fit=max&w=32 1x, https://example.com/myaccount/icon.png?auto=format&fit=max&w=64 2x, https://example.com/myaccount/icon.png?auto=format&fit=max&w=480 3x' + ) }) it('should support the unoptimized attribute', async () => { expect( @@ -80,10 +90,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=1024' + 'https://example.com/myaccount/foo1.jpg?auto=format&fit=max&w=2000' ) 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&fit=max&w=1024 1x, https://example.com/myaccount/foo1.jpg?auto=format&fit=max&w=2000 2x' ) }) it('should not have loaded the second image immediately', async () => { @@ -111,11 +121,11 @@ function lazyLoadingTests() { await check(() => { return browser.elementById('lazy-mid').getAttribute('src') - }, 'https://example.com/myaccount/foo2.jpg?auto=format&w=480') + }, 'https://example.com/myaccount/foo2.jpg?auto=format&fit=max&w=1024') 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&fit=max&w=480 1x, https://example.com/myaccount/foo2.jpg?auto=format&fit=max&w=1024 2x') }) it('should not have loaded the third image after scrolling down', async () => { expect( @@ -160,17 +170,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=1024') + ).toBe('https://example.com/myaccount/foo4.jpg?auto=format&fit=max&w=2000') expect( await browser.elementById('lazy-without-attribute').getAttribute('srcset') ).toBe( - 'https://example.com/myaccount/foo4.jpg?auto=format&w=480 480w, https://example.com/myaccount/foo4.jpg?auto=format&w=1024 1024w' + 'https://example.com/myaccount/foo4.jpg?auto=format&fit=max&w=1024 1x, https://example.com/myaccount/foo4.jpg?auto=format&fit=max&w=1600 2x, https://example.com/myaccount/foo4.jpg?auto=format&fit=max&w=2000 3x' ) }) 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=2000' + 'https://example.com/myaccount/foo5.jpg?auto=format&fit=max&w=2000' ) expect( await browser.elementById('eager-loading').getAttribute('srcset') @@ -210,14 +220,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=480&q=60' + 'https://example.com/myaccount/withpriority.png?auto=format&fit=max&w=1024&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=480' + 'https://example.com/myaccount/fooslash.jpg?auto=format&fit=max&w=1024' ) ).toBe(true) }) @@ -231,7 +241,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=480&q=60' + 'https://example.com/myaccount/withpriority.png?auto=format&fit=max&w=1024&q=60' ) ).toBe(true) }) @@ -248,7 +258,7 @@ describe('Image Component Tests', () => { it('should NOT add a preload tag for a priority image', async () => { expect( await hasPreloadLinkMatchingUrl( - 'https://example.com/myaccount/withpriorityclient.png?auto=format' + 'https://example.com/myaccount/withpriorityclient.png?auto=format&fit=max' ) ).toBe(false) }) @@ -284,11 +294,13 @@ describe('Image Component Tests', () => { browser = await webdriver(appPort, '/missing-observer') expect( await browser.elementById('lazy-no-observer').getAttribute('src') - ).toBe('https://example.com/myaccount/foox.jpg?auto=format&w=1024') + ).toBe( + 'https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=2000' + ) expect( await browser.elementById('lazy-no-observer').getAttribute('srcset') ).toBe( - 'https://example.com/myaccount/foox.jpg?auto=format&w=480 480w, https://example.com/myaccount/foox.jpg?auto=format&w=1024 1024w' + 'https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=1024 1x, https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=2000 2x' ) }) }) @@ -308,11 +320,13 @@ describe('Image Component Tests', () => { browser = await webdriver(appPort, '/missing-observer') expect( await browser.elementById('lazy-no-observer').getAttribute('src') - ).toBe('https://example.com/myaccount/foox.jpg?auto=format&w=1024') + ).toBe( + 'https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=2000' + ) expect( await browser.elementById('lazy-no-observer').getAttribute('srcset') ).toBe( - 'https://example.com/myaccount/foox.jpg?auto=format&w=480 480w, https://example.com/myaccount/foox.jpg?auto=format&w=1024 1024w' + 'https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=1024 1x, https://example.com/myaccount/foox.jpg?auto=format&fit=max&w=2000 2x' ) }) }) diff --git a/test/integration/image-component/default/pages/index.js b/test/integration/image-component/default/pages/index.js index bdbd9edc72b7d..7001dafe7671e 100644 --- a/test/integration/image-component/default/pages/index.js +++ b/test/integration/image-component/default/pages/index.js @@ -4,9 +4,8 @@ import Image from 'next/image' const Page = () => { return (
-

Hello World

+

Home Page

-

This is the index page

) diff --git a/test/integration/image-component/default/pages/invalid-src.js b/test/integration/image-component/default/pages/invalid-src.js index d9920d016b95c..2251163d4d591 100644 --- a/test/integration/image-component/default/pages/invalid-src.js +++ b/test/integration/image-component/default/pages/invalid-src.js @@ -4,8 +4,8 @@ import Image from 'next/image' const Page = () => { return (
-

Hello World

- +

Invalid Source

+
) } diff --git a/test/integration/image-component/default/pages/invalid-unsized.js b/test/integration/image-component/default/pages/invalid-unsized.js new file mode 100644 index 0000000000000..2a776a5aa94f0 --- /dev/null +++ b/test/integration/image-component/default/pages/invalid-unsized.js @@ -0,0 +1,13 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Invalid Unsized

+ +
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-fill.js b/test/integration/image-component/default/pages/layout-fill.js new file mode 100644 index 0000000000000..299e357175a4f --- /dev/null +++ b/test/integration/image-component/default/pages/layout-fill.js @@ -0,0 +1,20 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Fill

+
+ +
+

Layout Fill

+
+ +
+

Layout Fill

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-fixed.js b/test/integration/image-component/default/pages/layout-fixed.js new file mode 100644 index 0000000000000..c4a64a8ac4e33 --- /dev/null +++ b/test/integration/image-component/default/pages/layout-fixed.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Fixed

+ + + + +

Layout Fixed

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-intrinsic.js b/test/integration/image-component/default/pages/layout-intrinsic.js new file mode 100644 index 0000000000000..631706707de34 --- /dev/null +++ b/test/integration/image-component/default/pages/layout-intrinsic.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Intrinsic

+ + + + +

Layout Intrinsic

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-responsive.js b/test/integration/image-component/default/pages/layout-responsive.js new file mode 100644 index 0000000000000..7e849c419e2cd --- /dev/null +++ b/test/integration/image-component/default/pages/layout-responsive.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Responsive

+ + + + +

Layout Responsive

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/rotated.js b/test/integration/image-component/default/pages/rotated.js new file mode 100644 index 0000000000000..14dd07b143e3b --- /dev/null +++ b/test/integration/image-component/default/pages/rotated.js @@ -0,0 +1,14 @@ +import Image from 'next/image' +import React from 'react' + +const Page = () => { + return ( +
+

Hello World

+ +

This is the rotated page

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/public/exif-rotation.jpg b/test/integration/image-component/default/public/exif-rotation.jpg new file mode 100644 index 0000000000000..5470a458f0933 Binary files /dev/null and b/test/integration/image-component/default/public/exif-rotation.jpg differ diff --git a/test/integration/image-component/default/public/wide.png b/test/integration/image-component/default/public/wide.png new file mode 100644 index 0000000000000..b7bb4dc1497ba Binary files /dev/null and b/test/integration/image-component/default/public/wide.png differ diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index ff5ce85c820bc..df114e57b784b 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -10,6 +10,7 @@ import { check, hasRedbox, getRedboxHeader, + waitFor, } from 'next-test-utils' import webdriver from 'next-webdriver' import fs from 'fs-extra' @@ -35,30 +36,38 @@ async function hasImageMatchingUrl(browser, url) { return foundMatch } +async function getComputed(browser, id, prop) { + const val = await browser.eval(`document.getElementById('${id}').${prop}`) + if (typeof val === 'number') { + return val + } + if (typeof val === 'string') { + return parseInt(val, 10) + } + return null +} + +async function getSrc(browser, id) { + const src = await browser.elementById(id).getAttribute('src') + if (src) { + const url = new URL(src) + return url.href.slice(url.origin.length) + } +} + +function getRatio(width, height) { + return Math.round((height / width) * 1000) +} + function runTests(mode) { it('should load the images', async () => { let browser try { browser = await webdriver(appPort, '/') - await check(async () => { - const result = await browser.eval( - `document.getElementById('basic-image').naturalWidth` - ) - - if (result === 0) { - throw new Error('Incorrectly loaded image') - } - - return 'result-correct' - }, /result-correct/) - - await browser.eval( - 'document.getElementById("unsized-image").scrollIntoView()' - ) await check(async () => { const result = await browser.eval( - `document.getElementById('unsized-image').naturalWidth` + `document.getElementById('basic-image').naturalWidth` ) if (result === 0) { @@ -71,7 +80,7 @@ function runTests(mode) { expect( await hasImageMatchingUrl( browser, - `http://localhost:${appPort}/_next/image?url=%2Ftest.jpg&w=420&q=75` + `http://localhost:${appPort}/_next/image?url=%2Ftest.jpg&w=1200&q=75` ) ).toBe(true) } finally { @@ -102,6 +111,187 @@ function runTests(mode) { } }) + it('should work with layout-fixed so resizing window does not resize image', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-fixed') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'fixed1' + expect(await getSrc(browser, id)).toBe( + '/_next/image?url=%2Fwide.png&w=3840&q=75' + ) + expect(await browser.elementById(id).getAttribute('srcset')).toBe( + '/_next/image?url=%2Fwide.png&w=1200&q=75 1x, /_next/image?url=%2Fwide.png&w=3840&q=75 2x' + ) + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-intrinsic so resizing window maintains image aspect ratio', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-intrinsic') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'intrinsic1' + expect(await getSrc(browser, id)).toBe( + '/_next/image?url=%2Fwide.png&w=3840&q=75' + ) + expect(await browser.elementById(id).getAttribute('srcset')).toBe( + '/_next/image?url=%2Fwide.png&w=1200&q=75 1x, /_next/image?url=%2Fwide.png&w=3840&q=75 2x' + ) + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + const newWidth = await getComputed(browser, id, 'width') + const newHeight = await getComputed(browser, id, 'height') + expect(newWidth).toBeLessThan(width) + expect(newHeight).toBeLessThan(height) + expect(getRatio(newWidth, newHeight)).toBe(getRatio(width, height)) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-responsive so resizing window maintains image aspect ratio', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-responsive') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'responsive1' + expect(await getSrc(browser, id)).toBe( + '/_next/image?url=%2Fwide.png&w=3840&q=75' + ) + expect(await browser.elementById(id).getAttribute('srcset')).toBe( + '/_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w' + ) + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBeGreaterThan(width) + expect(await getComputed(browser, id, 'height')).toBeGreaterThan(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + const newWidth = await getComputed(browser, id, 'width') + const newHeight = await getComputed(browser, id, 'height') + expect(newWidth).toBeLessThan(width) + expect(newHeight).toBeLessThan(height) + expect(getRatio(newWidth, newHeight)).toBe(getRatio(width, height)) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-fill to fill the parent but NOT stretch with viewport', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-fill') + const width = 600 + const height = 350 + const delta = 150 + const id = 'fill1' + expect(await getSrc(browser, id)).toBe( + '/_next/image?url=%2Fwide.png&w=3840&q=75' + ) + expect(await browser.elementById(id).getAttribute('srcset')).toBe( + '/_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w' + ) + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + const newWidth = await getComputed(browser, id, 'width') + const newHeight = await getComputed(browser, id, 'height') + expect(newWidth).toBe(width) + expect(newHeight).toBe(height) + expect(getRatio(newWidth, newHeight)).toBe(getRatio(width, height)) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-fill to fill the parent and stretch with viewport', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-fill') + const id = 'fill2' + const width = await getComputed(browser, id, 'width') + const height = await getComputed(browser, id, 'height') + await browser.eval(`document.getElementById("${id}").scrollIntoView()`) + expect(await getSrc(browser, id)).toBe( + '/_next/image?url=%2Fwide.png&w=3840&q=75' + ) + expect(await browser.elementById(id).getAttribute('srcset')).toBe( + '/_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w' + ) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + const delta = 150 + const largeWidth = width + delta + const largeHeight = height + delta + await browser.setDimensions({ + width: largeWidth, + height: largeHeight, + }) + expect(await getComputed(browser, id, 'width')).toBe(largeWidth) + expect(await getComputed(browser, id, 'height')).toBe(largeHeight) + const smallWidth = width - delta + const smallHeight = height - delta + await browser.setDimensions({ + width: smallWidth, + height: smallHeight, + }) + expect(await getComputed(browser, id, 'width')).toBe(smallWidth) + expect(await getComputed(browser, id, 'height')).toBe(smallHeight) + } finally { + if (browser) { + await browser.close() + } + } + }) + if (mode === 'dev') { it('should show missing src error', async () => { const browser = await webdriver(appPort, '/missing-src') @@ -120,6 +310,53 @@ function runTests(mode) { 'Invalid src prop (https://google.com/test.png) on `next/image`, hostname "google.com" is not configured under images in your `next.config.js`' ) }) + + it('should show invalid unsized error', async () => { + const browser = await webdriver(appPort, '/invalid-unsized') + + await hasRedbox(browser) + expect(await getRedboxHeader(browser)).toContain( + 'Image with src "/test.png" has deprecated "unsized" property, which was removed in favor of the "layout=\'fill\'" property' + ) + }) + } + + // Tests that use the `unsized` attribute: + if (mode !== 'dev') { + it('should correctly rotate image', async () => { + let browser + try { + browser = await webdriver(appPort, '/rotated') + + const id = 'exif-rotation-image' + + // Wait for image to load: + await check(async () => { + const result = await browser.eval( + `document.getElementById(${JSON.stringify(id)}).naturalWidth` + ) + + if (result < 1) { + throw new Error('Image not ready') + } + + return 'result-correct' + }, /result-correct/) + + await waitFor(1000) + + const computedWidth = await getComputed(browser, id, 'width') + const computedHeight = await getComputed(browser, id, 'height') + expect(getRatio(computedWidth, computedHeight) / 1000.0).toBeCloseTo( + 1.333, + 1 + ) + } finally { + if (browser) { + await browser.close() + } + } + }) } } diff --git a/test/integration/image-component/typescript/pages/invalid.tsx b/test/integration/image-component/typescript/pages/invalid.tsx index 1c5f586651bc3..2a1daf83e1186 100644 --- a/test/integration/image-component/typescript/pages/invalid.tsx +++ b/test/integration/image-component/typescript/pages/invalid.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' const Invalid = () => { return (
-

Hello World

+

Invalid TS

{ return (
-

Hello World

+

Valid TS

+ /> - + /> +
+ +
+ width={500} + height={500} + /> + width={500} + height={500} + />

This is valid usage of the Image component

) diff --git a/test/integration/image-component/typescript/test/index.test.js b/test/integration/image-component/typescript/test/index.test.js index 16424caff5ed9..ca0b6bb158729 100644 --- a/test/integration/image-component/typescript/test/index.test.js +++ b/test/integration/image-component/typescript/test/index.test.js @@ -45,14 +45,14 @@ describe('TypeScript Image Component', () => { const html = await renderViaHTTP(appPort, '/valid', {}) expect(html).toMatch(/This is valid usage of the Image component/) expect(output).not.toMatch( - /must use "width" and "height" properties or "unsized" property/ + /must use "width" and "height" properties or "layout='fill'" property/ ) }) it('should print error when invalid Image usage', async () => { await renderViaHTTP(appPort, '/invalid', {}) expect(output).toMatch( - /must use "width" and "height" properties or "unsized" property/ + /must use "width" and "height" properties or "layout='fill'" property/ ) }) }) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 15869a9750dbc..3eff52b1d8c86 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -19,7 +19,7 @@ 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')) -const largeSize = 1024 +const largeSize = 1080 // defaults defined in server/config.ts let appPort let app @@ -88,6 +88,26 @@ function runTests({ w, isDev, domains }) { expect(actual).toMatch(expected) }) + it('should maintain jpg format for old Safari', async () => { + const accept = + 'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5' + const query = { w, q: 90, url: '/test.jpg' } + const opts = { headers: { accept } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('image/jpeg') + }) + + it('should maintain png format for old Safari', async () => { + const accept = + 'image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5' + const query = { w, q: 75, url: '/test.png' } + const opts = { headers: { accept } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toContain('image/png') + }) + it('should fail when url is missing', async () => { const query = { w, q: 100 } const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) @@ -173,24 +193,15 @@ function runTests({ w, isDev, domains }) { ) }) - it('should resize relative url and webp accept header', async () => { + it('should resize relative url and webp Firefox accept header', async () => { const query = { url: '/test.png', w, q: 80 } - const opts = { headers: { accept: 'image/webp' } } + 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') await expectWidth(res, w) }) - 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') - await expectWidth(res, w) - }) - it('should resize relative url and png accept header', async () => { const query = { url: '/test.png', w, q: 80 } const opts = { headers: { accept: 'image/png' } } @@ -227,9 +238,11 @@ function runTests({ w, isDev, domains }) { await expectWidth(res, w) }) - it('should resize relative url and wildcard accept header as webp', async () => { + it('should resize relative url and Chrome accept header as webp', async () => { const query = { url: '/test.png', w, q: 80 } - const opts = { headers: { accept: 'image/*' } } + const opts = { + headers: { accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' }, + } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') @@ -428,7 +441,7 @@ describe('Image Optimizer', () => { const domains = ['localhost', 'example.com'] describe('dev support w/o next.config.js', () => { - const size = 320 // defaults defined in server/config.ts + const size = 384 // defaults defined in server/config.ts beforeAll(async () => { appPort = await findPort() app = await launchApp(appDir, appPort) @@ -465,7 +478,7 @@ describe('Image Optimizer', () => { }) describe('Server support w/o next.config.js', () => { - const size = 320 // defaults defined in server/config.ts + const size = 384 // defaults defined in server/config.ts beforeAll(async () => { await nextBuild(appDir) appPort = await findPort() @@ -544,7 +557,8 @@ describe('Image Optimizer', () => { await fs.remove(imagesDir) }) it('should 404 when loader is not default', async () => { - const query = { w: 320, q: 90, url: '/test.svg' } + const size = 384 // defaults defined in server/config.ts + const query = { w: size, q: 90, url: '/test.svg' } const opts = { headers: { accept: 'image/webp' } } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(404) diff --git a/test/integration/link-ref/pages/click-away-race-condition.js b/test/integration/link-ref/pages/click-away-race-condition.js new file mode 100644 index 0000000000000..a5b0d51e281be --- /dev/null +++ b/test/integration/link-ref/pages/click-away-race-condition.js @@ -0,0 +1,50 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import Link from 'next/link' + +const useClickAway = (ref, onClickAway) => { + useEffect(() => { + const handler = (event) => { + const el = ref.current + + // when menu is open and clicked inside menu, A is expected to be false + // when menu is open and clicked outside menu, A is expected to be true + console.log('A', el && !el.contains(event.target)) + + el && !el.contains(event.target) && onClickAway(event) + } + + document.addEventListener('click', handler) + + return () => { + document.removeEventListener('click', handler) + } + }, [onClickAway, ref]) +} + +export default function App() { + const [open, setOpen] = useState(false) + + const menuRef = useRef(null) + + const onClickAway = useCallback(() => { + console.log('click away, open', open) + if (open) { + setOpen(false) + } + }, [open]) + + useClickAway(menuRef, onClickAway) + + return ( +
+
setOpen(true)}> + Open Menu +
+ {open && ( +
+ some link +
+ )} +
+ ) +} diff --git a/test/integration/link-ref/test/index.test.js b/test/integration/link-ref/test/index.test.js index 0251c868f9faf..588517f9745ae 100644 --- a/test/integration/link-ref/test/index.test.js +++ b/test/integration/link-ref/test/index.test.js @@ -49,6 +49,15 @@ const didPrefetch = async (pathname) => { await browser.close() } +function runCommonTests() { + // See https://github.com/vercel/next.js/issues/18437 + it('should not have a race condition with a click handler', async () => { + const browser = await webdriver(appPort, '/click-away-race-condition') + await browser.elementByCss('#click-me').click() + await browser.waitForElementByCss('#the-menu') + }) +} + describe('Invalid hrefs', () => { describe('dev mode', () => { beforeAll(async () => { @@ -57,6 +66,8 @@ describe('Invalid hrefs', () => { }) afterAll(() => killApp(app)) + runCommonTests() + it('should not show error for function component with forwardRef', async () => { await noError('/function') }) @@ -82,6 +93,8 @@ describe('Invalid hrefs', () => { }) afterAll(() => killApp(app)) + runCommonTests() + it('should preload with forwardRef', async () => { await didPrefetch('/function') }) diff --git a/test/integration/nullish-config/test/index.test.js b/test/integration/nullish-config/test/index.test.js index 0f1c73c30187e..dbbd49a9dad7e 100644 --- a/test/integration/nullish-config/test/index.test.js +++ b/test/integration/nullish-config/test/index.test.js @@ -23,9 +23,6 @@ const runTests = () => { amp: { canonicalBase: undefined, }, - devIndicators: { - autoPrerender: undefined, - }, } ` ) @@ -47,9 +44,6 @@ const runTests = () => { amp: { canonicalBase: null, }, - devIndicators: { - autoPrerender: null, - }, } ` ) diff --git a/test/integration/production/components/dynamic-css/with-css.js b/test/integration/production/components/dynamic-css/with-css.js index d988353142973..0fedd10900bb2 100644 --- a/test/integration/production/components/dynamic-css/with-css.js +++ b/test/integration/production/components/dynamic-css/with-css.js @@ -1,3 +1,7 @@ import styles from './with-css.module.css' -export default () =>

With CSS

+export default () => ( +

+ With CSS +

+) diff --git a/test/integration/production/components/dynamic-css/with-css.module.css b/test/integration/production/components/dynamic-css/with-css.module.css index 69fda78f79397..077039d0e7431 100644 --- a/test/integration/production/components/dynamic-css/with-css.module.css +++ b/test/integration/production/components/dynamic-css/with-css.module.css @@ -1,3 +1,4 @@ .content { color: inherit; + display: flex; } diff --git a/test/integration/production/pages/dynamic/pagechange1.js b/test/integration/production/pages/dynamic/pagechange1.js new file mode 100644 index 0000000000000..c2d38159e4c78 --- /dev/null +++ b/test/integration/production/pages/dynamic/pagechange1.js @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic' + +const Hello = dynamic(import('../../components/dynamic-css/with-css')) + +export default function PageChange1() { + return ( +
+ PageChange1 + +
+ ) +} diff --git a/test/integration/production/pages/dynamic/pagechange2.js b/test/integration/production/pages/dynamic/pagechange2.js new file mode 100644 index 0000000000000..f4ebbd1b3e7b3 --- /dev/null +++ b/test/integration/production/pages/dynamic/pagechange2.js @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic' + +const Hello = dynamic(import('../../components/dynamic-css/with-css')) + +export default function PageChange2() { + return ( +
+ PageChange2 + +
+ ) +} diff --git a/test/integration/production/test/dynamic.js b/test/integration/production/test/dynamic.js index 61e4cfaa56f86..3a10ab3eb410f 100644 --- a/test/integration/production/test/dynamic.js +++ b/test/integration/production/test/dynamic.js @@ -40,6 +40,28 @@ export default (context, render) => { expect(cssFiles.length).toBe(1) }) + it('should not remove css styles for same css file between page transitions', async () => { + let browser + try { + browser = await webdriver(context.appPort, '/dynamic/pagechange1') + await check(() => browser.elementByCss('body').text(), /PageChange1/) + const firstElement = await browser.elementById('with-css') + const css1 = await firstElement.getComputedCss('display') + expect(css1).toBe('flex') + await browser.eval(() => + window.next.router.push('/dynamic/pagechange2') + ) + await check(() => browser.elementByCss('body').text(), /PageChange2/) + const secondElement = await browser.elementById('with-css') + const css2 = await secondElement.getComputedCss('display') + expect(css2).toBe(css1) + } finally { + if (browser) { + await browser.close() + } + } + }) + // It seem to be abnormal, dynamic CSS modules are completely self-sufficient, so shared styles are copied across files it('should output two css files even in case of three css module files while one is shared across files', async () => { const $ = await get$('/dynamic/shared-css-module') diff --git a/test/integration/serverless/test/index.test.js b/test/integration/serverless/test/index.test.js index 3a62a349afb5d..7b8eb2d3302f6 100644 --- a/test/integration/serverless/test/index.test.js +++ b/test/integration/serverless/test/index.test.js @@ -280,6 +280,21 @@ describe('Serverless', () => { expect(data.query).toEqual({ slug: paramRaw }) }) + it('should have the correct query string for a now route with invalid matches but correct path', async () => { + const paramRaw = 'dr/first' + const html = await fetchViaHTTP(appPort, `/dr/first`, '', { + headers: { + 'x-now-route-matches': qs.stringify({ + 1: encodeURIComponent(paramRaw), + }), + }, + }).then((res) => res.text()) + const $ = cheerio.load(html) + const data = JSON.parse($('#__NEXT_DATA__').html()) + + expect(data.query).toEqual({ slug: 'first' }) + }) + it('should have the correct query string for a catch all now route', async () => { const paramRaw = ['nested % 1', 'nested/2'] diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index dc5a102f9ed7b..522fc5680ee42 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -100,7 +100,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 171 * 1024 + const delta = responseSizesBytes - 172 * 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/isolated/config.test.js b/test/isolated/config.test.js index 370d179deedc9..640b218507abf 100644 --- a/test/isolated/config.test.js +++ b/test/isolated/config.test.js @@ -104,22 +104,14 @@ describe('config', () => { it('Should ignore configs set to `undefined`', () => { const config = loadConfig(PHASE_DEVELOPMENT_SERVER, null, { target: undefined, - devIndicators: { - autoPrerender: undefined, - }, }) expect(config.target).toBe('server') - expect(config.devIndicators.autoPrerender).toBe(true) }) it('Should ignore configs set to `null`', () => { const config = loadConfig(PHASE_DEVELOPMENT_SERVER, null, { target: null, - devIndicators: { - autoPrerender: null, - }, }) expect(config.target).toBe('server') - expect(config.devIndicators.autoPrerender).toBe(true) }) }) diff --git a/test/lib/next-webdriver.d.ts b/test/lib/next-webdriver.d.ts index fe87e0363ab76..08587bdf1b710 100644 --- a/test/lib/next-webdriver.d.ts +++ b/test/lib/next-webdriver.d.ts @@ -17,6 +17,7 @@ interface Chain { back: () => Chain forward: () => Chain refresh: () => Chain + setDimensions: (opts: { height: number; width: number }) => Chain close: () => Chain quit: () => Chain } diff --git a/test/lib/wd-chain.js b/test/lib/wd-chain.js index 48924f13e343f..2dbbdabb6541f 100644 --- a/test/lib/wd-chain.js +++ b/test/lib/wd-chain.js @@ -126,6 +126,12 @@ export default class Chain { return this.updateChain(() => this.browser.navigate().refresh()) } + setDimensions({ height, width }) { + return this.updateChain(() => + this.browser.manage().window().setRect({ width, height, x: 0, y: 0 }) + ) + } + close() { return this.updateChain(() => Promise.resolve()) }