diff --git a/.eslintignore b/.eslintignore index 016351f511ae9..e748682fbadd5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,4 +8,8 @@ packages/next/compiled/**/* packages/react-refresh-utils/**/*.js packages/react-dev-overlay/lib/** **/__tmp__/** -.github/actions/next-stats-action/.work \ No newline at end of file +.github/actions/next-stats-action/.work +packages/next-codemod/transforms/__testfixtures__/**/* +packages/next-codemod/transforms/__tests__/**/* +packages/next-codemod/**/*.js +packages/next-codemod/**/*.d.ts diff --git a/.github/labeler.json b/.github/labeler.json index cb324c040ebf1..da8660336f90b 100644 --- a/.github/labeler.json +++ b/.github/labeler.json @@ -6,7 +6,8 @@ "type: next": [ "packages/next/**", "packages/react-dev-overlay/**", - "packages/react-refresh-utils/**" + "packages/react-refresh-utils/**", + "packages/next-codemod/**" ] } } diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 51d848de12dc9..3d5dce3de6011 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -70,10 +70,29 @@ jobs: - run: node run-tests.js --timings -g ${{ matrix.group }}/6 -c 3 + testYarnPnP: + runs-on: ubuntu-latest + env: + NODE_OPTIONS: '--unhandled-rejections=strict' + steps: + - uses: actions/checkout@v2 + + - run: yarn install --frozen-lockfile --check-files + + - run: | + mkdir -p ./e2e-tests/next-pnp + cp -r ./examples/with-typescript/. ./e2e-tests/next-pnp + cd ./e2e-tests/next-pnp + touch yarn.lock + yarn set version berry + yarn config set pnpFallbackMode none + yarn link --all --private ../.. + yarn build + testsPass: name: thank you, next runs-on: ubuntu-latest - needs: [lint, checkPrecompiled, testAll] + needs: [lint, checkPrecompiled, testAll, testYarnPnP] steps: - run: exit 0 diff --git a/.gitignore b/.gitignore index faedbe2adbdd3..df46d328e8bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ coverage test/**/out* test/**/next-env.d.ts .DS_Store +/e2e-tests # Editors **/.idea diff --git a/.prettierignore b/.prettierignore index b1adbf783a5a5..d40368c6af1f0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,8 @@ packages/react-refresh-utils/**/*.d.ts packages/react-dev-overlay/lib/** **/__tmp__/** lerna.json -.github/actions/next-stats-action/.work \ No newline at end of file +.github/actions/next-stats-action/.work +packages/next-codemod/transforms/__testfixtures__/**/* +packages/next-codemod/transforms/__tests__/**/* +packages/next-codemod/**/*.js +packages/next-codemod/**/*.d.ts diff --git a/.prettierignore_staged b/.prettierignore_staged index 3e87a0d626523..00f3e004f5736 100644 --- a/.prettierignore_staged +++ b/.prettierignore_staged @@ -3,3 +3,5 @@ **/dist/** packages/next/compiled/**/* lerna.json +packages/next-codemod/transforms/__testfixtures__/**/* +packages/next-codemod/transforms/__tests__/**/* diff --git a/docs/advanced-features/codemods.md b/docs/advanced-features/codemods.md new file mode 100644 index 0000000000000..be387fc827f67 --- /dev/null +++ b/docs/advanced-features/codemods.md @@ -0,0 +1,149 @@ +--- +description: Use codemods to update your codebase when upgrading Next.js to the latest version +--- + +# Next.js Codemods + +Next.js provides Codemod transformations to help upgrade your Next.js codebase when a feature is deprecated. + +Codemods are transformations that run on your codebase programmatically. This allows for a large amount of changes to be applied without having to manually go through every file. + +## Usage + +`npx @next/codemod ` + +- `transform` - name of transform, see available transforms below. +- `path` - files or directory to transform +- `--dry` Do a dry-run, no code will be edited +- `--print` Prints the changed output for comparison + +## Next.js 9 + +### `name-default-component` + +Transforms anonymous components into named components to make sure they work with [Fast Refresh](https://nextjs.org/blog/next-9-4#fast-refresh). + +For example + +```jsx +// my-component.js +export default function () { + return
Hello World
+} +``` + +Transforms into: + +```jsx +// my-component.js +export default function MyComponent() { + return
Hello World
+} +``` + +The component will have a camel cased name based on the name of the file, and it also works with arrow functions. + +#### Usage + +Go to your project + +``` +cd path-to-your-project/ +``` + +Run the codemod: + +``` +npx @next/codemod name-default-component +``` + +### `withamp-to-config` + +Transforms the `withAmp` HOC into Next.js 9 page configuration. + +For example: + +```js +// Before +import { withAmp } from 'next/amp' + +function Home() { + return

My AMP Page

+} + +export default withAmp(Home) +``` + +```js +// After +export default function Home() { + return

My AMP Page

+} + +export const config = { + amp: true, +} +``` + +#### Usage + +Go to your project + +``` +cd path-to-your-project/ +``` + +Run the codemod: + +``` +npx @next/codemod withamp-to-config +``` + +## Next.js 6 + +### `url-to-withrouter` + +Transforms the deprecated automatically injected `url` property on top level pages to using `withRouter` and the `router` property it injects. Read more here: [err.sh/next.js/url-deprecated](https://err.sh/next.js/url-deprecated) + +For example: + +```js +// From +import React from 'react' +export default class extends React.Component { + render() { + const { pathname } = this.props.url + return
Current pathname: {pathname}
+ } +} +``` + +```js +// To +import React from 'react' +import { withRouter } from 'next/router' +export default withRouter( + class extends React.Component { + render() { + const { pathname } = this.props.router + return
Current pathname: {pathname}
+ } + } +) +``` + +This is just one case. All the cases that are transformed (and tested) can be found in the [`__testfixtures__` directory](./transforms/__testfixtures__/url-to-withrouter). + +#### Usage + +Go to your project + +``` +cd path-to-your-project/ +``` + +Run the codemod: + +``` +npx @next/codemod url-to-withrouter +``` diff --git a/docs/advanced-features/module-path-aliases.md b/docs/advanced-features/module-path-aliases.md index 0d88026abdcff..2c2c0e20811ae 100644 --- a/docs/advanced-features/module-path-aliases.md +++ b/docs/advanced-features/module-path-aliases.md @@ -4,7 +4,7 @@ description: Configure module path aliases that allow you to remap certain impor # Absolute Imports and Module path aliases -Next.js automatically supports the `tsconfig.json` and `jsconfig.json` `"paths"` and `"baseUrl"` options. +Next.js automatically supports the `tsconfig.json` and `jsconfig.json` `"paths"` and `"baseUrl"` options since [Next.js 9.4](https://nextjs.org/blog/next-9-4). > Note: `jsconfig.json` can be used when you don't use TypeScript diff --git a/docs/advanced-features/preview-mode.md b/docs/advanced-features/preview-mode.md index dd4065482248c..1e8467620cf3b 100644 --- a/docs/advanced-features/preview-mode.md +++ b/docs/advanced-features/preview-mode.md @@ -28,7 +28,7 @@ In the [Pages documentation](/docs/basic-features/pages.md) and the [Data Fetchi Static Generation is useful when your pages fetch data from a headless CMS. However, it’s not ideal when you’re writing a draft on your headless CMS and want to **preview** the draft immediately on your page. You’d want Next.js to render these pages at **request time** instead of build time and fetch the draft content instead of the published content. You’d want Next.js to bypass Static Generation only for this specific case. -Next.js has the feature called **Preview Mode** which solves this problem. Here’s an instruction on how to use it. +Next.js has a feature called **Preview Mode** which solves this problem. Here are instructions on how to use it. ## Step 1. Create and access a preview API route diff --git a/docs/api-reference/cli.md b/docs/api-reference/cli.md index e97d2b2d59a4d..5b63772e85a2d 100644 --- a/docs/api-reference/cli.md +++ b/docs/api-reference/cli.md @@ -56,6 +56,14 @@ next build --profile After that, you can use the profiler in the same way as you would in development. +You can enable more verbose build output with the `--debug` flag in `next build`. This requires Next.js 9.5.3: + +```bash +next build --debug +``` + +With this flag enabled additional build output like rewrites, redirects, and headers will be shown. + ## Development `next dev` starts the application in development mode with hot-code reloading, error reporting, and more: diff --git a/docs/create-next-app.md b/docs/api-reference/create-next-app.md similarity index 100% rename from docs/create-next-app.md rename to docs/api-reference/create-next-app.md diff --git a/docs/api-reference/data-fetching/getInitialProps.md b/docs/api-reference/data-fetching/getInitialProps.md index d4590c9e797d8..6e428820ecd96 100644 --- a/docs/api-reference/data-fetching/getInitialProps.md +++ b/docs/api-reference/data-fetching/getInitialProps.md @@ -71,8 +71,8 @@ For the initial page load, `getInitialProps` will run on the server only. `getIn - `pathname` - Current route. That is the path of the page in `/pages` - `query` - Query string section of URL parsed as an object - `asPath` - `String` of the actual path (including the query) shown in the browser -- `req` - HTTP request object (server only) -- `res` - HTTP response object (server only) +- `req` - [HTTP request object](https://nodejs.org/api/http.html#http_class_http_incomingmessage 'Class: http.IncomingMessage HTTP | Node.js v14.8.0 Documentation') (server only) +- `res` - [HTTP response object](https://nodejs.org/api/http.html#http_class_http_serverresponse 'Class: http.ServerResponse HTTP | Node.js v14.8.0 Documentation') (server only) - `err` - Error object if any error is encountered during the rendering ## Caveats diff --git a/docs/api-reference/next.config.js/headers.md b/docs/api-reference/next.config.js/headers.md index 4b854562e08e0..c066afeaf1a38 100644 --- a/docs/api-reference/next.config.js/headers.md +++ b/docs/api-reference/next.config.js/headers.md @@ -27,7 +27,6 @@ module.exports = { }, ], }, - , ] }, } @@ -38,6 +37,37 @@ module.exports = { - `source` is the incoming request path pattern. - `headers` is an array of header objects with the `key` and `value` properties. +## Header Overriding Behavior + +If two headers match the same path and set the same header key, the last header key will override the first. Using the below headers, the path `/hello` will result in the header `x-hello` being `world` due to the last header value set being `world`. + +```js +module.exports = { + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'x-hello', + value: 'there', + }, + ], + }, + { + source: '/hello', + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + ], + }, +} +``` + ## Path Matching Path matches are allowed, for example `/blog/:slug` will match `/blog/hello-world` (no nested paths): @@ -59,8 +89,7 @@ module.exports = { }, ], }, - , - ] + ], }, } ``` @@ -86,8 +115,7 @@ module.exports = { }, ], }, - , - ] + ], }, } ``` @@ -109,7 +137,7 @@ module.exports = { }, ], }, - ] + ], }, } ``` diff --git a/docs/basic-features/data-fetching.md b/docs/basic-features/data-fetching.md index 7192803696704..e30ad46ddc251 100644 --- a/docs/basic-features/data-fetching.md +++ b/docs/basic-features/data-fetching.md @@ -60,7 +60,17 @@ The `context` parameter is an object containing the following keys: - `revalidate` - An **optional** amount in seconds after which a page re-generation can occur. More on [Incremental Static Regeneration](#incremental-static-regeneration) > **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, as [explained below](#write-server-side-code-directly). +> Imports used in `getStaticProps` will [not be bundled for the client-side](#write-server-side-code-directly). +> +> This means you can write **server-side code directly in `getStaticProps`**. +> This includes reading from the filesystem or a database. + +> **Note**: You should not use [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to +> call an API route in your application. +> Instead, directly import the API route and call its function yourself. +> You may need to slightly refactor your code for this approach. +> +> Fetching from an external API is fine! ### Simple Example @@ -534,7 +544,17 @@ The `context` parameter is an object containing the following keys: - `previewData`: The preview data set by `setPreviewData`. See the [Preview Mode documentation](/docs/advanced-features/preview-mode.md). > **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, as [explained below](#only-runs-on-server-side). +> Imports used in `getServerSideProps` will not be bundled for the client-side. +> +> This means you can write **server-side code directly in `getServerSideProps`**. +> This includes reading from the filesystem or a database. + +> **Note**: You should not use [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to +> call an API route in your application. +> Instead, directly import the API route and call its function yourself. +> You may need to slightly refactor your code for this approach. +> +> Fetching from an external API is fine! ### Simple example diff --git a/docs/basic-features/fast-refresh.md b/docs/basic-features/fast-refresh.md index a9be9cbcb6e50..0d6040eb9050b 100644 --- a/docs/basic-features/fast-refresh.md +++ b/docs/basic-features/fast-refresh.md @@ -107,6 +107,6 @@ Sometimes, this can lead to unexpected results. For example, even a `useEffect` with an empty array of dependencies would still re-run once during Fast Refresh. However, writing code resilient to occasional re-running of `useEffect` is a good practice even -without Fash Refresh. It will make it easier for you to introduce new dependencies to it later on +without Fast Refresh. It will make it easier for you to introduce new dependencies to it later on and it's enforced by [React Strict Mode](/docs/api-reference/next.config.js/react-strict-mode), which we highly recommend enabling. diff --git a/docs/getting-started.md b/docs/getting-started.md index e07f4282363b3..77e3b7a00f05e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,7 +29,7 @@ yarn create next-app After the installation is complete, follow the instructions to start the development server. Try editing `pages/index.js` and see the result on your browser. -For more information on how to use `create-next-app`, you can review the [`create-next-app` documentation](/docs/create-next-app.md) +For more information on how to use `create-next-app`, you can review the [`create-next-app` documentation](/docs/api-reference/create-next-app.md) ## Manual Setup diff --git a/docs/manifest.json b/docs/manifest.json index e4ff3d7a01605..0fedfb30c20b1 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -176,6 +176,10 @@ { "title": "Debugging", "path": "/docs/advanced-features/debugging.md" + }, + { + "title": "Codemods", + "path": "/docs/advanced-features/codemods.md" } ] }, @@ -191,6 +195,10 @@ "heading": true, "routes": [ { "title": "CLI", "path": "/docs/api-reference/cli.md" }, + { + "title": "Create Next App", + "path": "/docs/api-reference/create-next-app.md" + }, { "title": "next/router", "path": "/docs/api-reference/next/router.md" diff --git a/errors/duplicate-sass.md b/errors/duplicate-sass.md new file mode 100644 index 0000000000000..aa4e6e9b8b39d --- /dev/null +++ b/errors/duplicate-sass.md @@ -0,0 +1,33 @@ +# Duplicate Sass Dependencies + +#### Why This Error Occurred + +Your project has a direct dependency on both `sass` and `node-sass`, two +different package that both compile Sass files! + +Next.js will only use one of these, so it is suggested you remove one or the +other. + +#### Possible Ways to Fix It + +The `sass` package is a modern implementation of Sass in JavaScript that +supports all the new features and does not require any native dependencies. + +Since `sass` is now the canonical implementation, we suggest removing the older +`node-sass` package, which should speed up your builds and project install time. + +**Via npm** + +```bash +npm uninstall node-sass +``` + +**Via Yarn** + +```bash +yarn remove node-sass +``` + +### Useful Links + +- [`sass` package documentation](https://github.com/sass/dart-sass) diff --git a/errors/invalid-page-config.md b/errors/invalid-page-config.md index 327e40d8212a9..6984a9bb1b9e5 100644 --- a/errors/invalid-page-config.md +++ b/errors/invalid-page-config.md @@ -6,7 +6,7 @@ In one of your pages you did `export const config` with an invalid value. #### Possible Ways to Fix It -The page's config must be an object initialized directly when being exported. +The page's config must be an object initialized directly when being exported and not modified dynamically. This is not allowed @@ -14,6 +14,19 @@ This is not allowed export const config = 'hello world' ``` +This is not allowed + +```js +const config = {} +config.amp = true +``` + +This is not allowed + +```js +export { config } from '../config' +``` + This is allowed ```js diff --git a/examples/blog-starter-typescript/components/cover-image.tsx b/examples/blog-starter-typescript/components/cover-image.tsx index 7a4f635dfb521..6497d4c1bb85a 100644 --- a/examples/blog-starter-typescript/components/cover-image.tsx +++ b/examples/blog-starter-typescript/components/cover-image.tsx @@ -18,7 +18,7 @@ const CoverImage = ({ title, src, slug }: Props) => { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/blog-starter-typescript/components/post-header.tsx b/examples/blog-starter-typescript/components/post-header.tsx index 376e3860ecbac..d91161ad0cd52 100644 --- a/examples/blog-starter-typescript/components/post-header.tsx +++ b/examples/blog-starter-typescript/components/post-header.tsx @@ -18,7 +18,7 @@ const PostHeader = ({ title, coverImage, date, author }: Props) => {
-
+
diff --git a/examples/blog-starter/components/cover-image.js b/examples/blog-starter/components/cover-image.js index b9df0f27e2354..d06a95b55ce4f 100644 --- a/examples/blog-starter/components/cover-image.js +++ b/examples/blog-starter/components/cover-image.js @@ -12,7 +12,7 @@ export default function CoverImage({ title, src, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/blog-starter/components/post-header.js b/examples/blog-starter/components/post-header.js index 4a832420cbd27..299600d6bcd2a 100644 --- a/examples/blog-starter/components/post-header.js +++ b/examples/blog-starter/components/post-header.js @@ -10,7 +10,7 @@ export default function PostHeader({ title, coverImage, date, author }) {
-
+
diff --git a/examples/cms-agilitycms/components/cover-image.js b/examples/cms-agilitycms/components/cover-image.js index 9bb01798efb4d..50b4bbc0922d2 100644 --- a/examples/cms-agilitycms/components/cover-image.js +++ b/examples/cms-agilitycms/components/cover-image.js @@ -15,7 +15,7 @@ export default function CoverImage({ title, responsiveImage, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-buttercms/components/cover-image.js b/examples/cms-buttercms/components/cover-image.js index 7230d824f2d47..22a4852faa340 100644 --- a/examples/cms-buttercms/components/cover-image.js +++ b/examples/cms-buttercms/components/cover-image.js @@ -2,7 +2,7 @@ import Link from 'next/link' export default function CoverImage({ title, url, slug }) { return ( -
+
{slug ? ( diff --git a/examples/cms-contentful/components/cover-image.js b/examples/cms-contentful/components/cover-image.js index 3263aaa0eed82..9f52f4e51c199 100644 --- a/examples/cms-contentful/components/cover-image.js +++ b/examples/cms-contentful/components/cover-image.js @@ -12,7 +12,7 @@ export default function CoverImage({ title, url, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-cosmic/components/cover-image.js b/examples/cms-cosmic/components/cover-image.js index 02f176a7851e2..a75776a691266 100644 --- a/examples/cms-cosmic/components/cover-image.js +++ b/examples/cms-cosmic/components/cover-image.js @@ -22,7 +22,7 @@ export default function CoverImage({ title, url, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-datocms/components/cover-image.js b/examples/cms-datocms/components/cover-image.js index d9f54beb0ab73..9c48efc6b9199 100644 --- a/examples/cms-datocms/components/cover-image.js +++ b/examples/cms-datocms/components/cover-image.js @@ -15,7 +15,7 @@ export default function CoverImage({ title, responsiveImage, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-graphcms/components/cover-image.js b/examples/cms-graphcms/components/cover-image.js index e1830c8286bc7..560c40d4c711b 100644 --- a/examples/cms-graphcms/components/cover-image.js +++ b/examples/cms-graphcms/components/cover-image.js @@ -15,7 +15,7 @@ export default function CoverImage({ title, url, slug }) { ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-prismic/components/cover-image.js b/examples/cms-prismic/components/cover-image.js index 3263aaa0eed82..9f52f4e51c199 100644 --- a/examples/cms-prismic/components/cover-image.js +++ b/examples/cms-prismic/components/cover-image.js @@ -12,7 +12,7 @@ export default function CoverImage({ title, url, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-sanity/components/cover-image.js b/examples/cms-sanity/components/cover-image.js index 14a5c12342424..b92487bf39cf9 100644 --- a/examples/cms-sanity/components/cover-image.js +++ b/examples/cms-sanity/components/cover-image.js @@ -16,7 +16,7 @@ export default function CoverImage({ title, url, slug }) { ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-storyblok/components/cover-image.js b/examples/cms-storyblok/components/cover-image.js index 7230d824f2d47..22a4852faa340 100644 --- a/examples/cms-storyblok/components/cover-image.js +++ b/examples/cms-storyblok/components/cover-image.js @@ -2,7 +2,7 @@ import Link from 'next/link' export default function CoverImage({ title, url, slug }) { return ( -
+
{slug ? ( diff --git a/examples/cms-strapi/components/cover-image.js b/examples/cms-strapi/components/cover-image.js index a93d0c9c7cbd2..e59d8c7f04c94 100644 --- a/examples/cms-strapi/components/cover-image.js +++ b/examples/cms-strapi/components/cover-image.js @@ -5,7 +5,7 @@ export default function CoverImage({ title, url, slug }) { url.startsWith('/') ? process.env.NEXT_PUBLIC_STRAPI_API_URL : '' }${url}` return ( -
+
{slug ? ( diff --git a/examples/cms-takeshape/components/cover-image.js b/examples/cms-takeshape/components/cover-image.js index b048c0e20ebda..dfe43d8914b02 100644 --- a/examples/cms-takeshape/components/cover-image.js +++ b/examples/cms-takeshape/components/cover-image.js @@ -17,7 +17,7 @@ export default function CoverImage({ title, coverImage, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/cms-wordpress/components/cover-image.js b/examples/cms-wordpress/components/cover-image.js index c0d33b15f166a..9b4e781c4bcc0 100644 --- a/examples/cms-wordpress/components/cover-image.js +++ b/examples/cms-wordpress/components/cover-image.js @@ -11,7 +11,7 @@ export default function CoverImage({ title, coverImage, slug }) { /> ) return ( -
+
{slug ? ( {image} diff --git a/examples/custom-server-hapi/next-wrapper.js b/examples/custom-server-hapi/next-wrapper.js index 34040bae437cd..b178e9a6088d1 100644 --- a/examples/custom-server-hapi/next-wrapper.js +++ b/examples/custom-server-hapi/next-wrapper.js @@ -10,7 +10,7 @@ const pathWrapper = (app, pathName, opts) => async ( { raw, query, params }, h ) => { - const html = await app.renderToHTML( + const html = await app.render( raw.req, raw.res, pathName, diff --git a/examples/ssr-caching/server.js b/examples/ssr-caching/server.js index 333aca98bbcc0..50f67faf8487a 100644 --- a/examples/ssr-caching/server.js +++ b/examples/ssr-caching/server.js @@ -11,7 +11,7 @@ const handle = app.getRequestHandler() const ssrCache = cacheableResponse({ ttl: 1000 * 60 * 60, // 1hour get: async ({ req, res }) => { - const data = await app.renderToHTML(req, res, req.path, { + const data = await app.render(req, res, req.path, { ...req.query, ...req.params, }) diff --git a/examples/with-filbert/.babelrc b/examples/with-filbert/.babelrc new file mode 100644 index 0000000000000..9cc7017fb9b6c --- /dev/null +++ b/examples/with-filbert/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["macros"] + } \ No newline at end of file diff --git a/examples/with-filbert/package.json b/examples/with-filbert/package.json index b13949c0159cd..6acc42079e3a2 100644 --- a/examples/with-filbert/package.json +++ b/examples/with-filbert/package.json @@ -12,8 +12,9 @@ "author": "Kuldeep Keshwar", "license": "ISC", "dependencies": { - "@filbert-js/core": "^0.0.4", - "@filbert-js/server-stylesheet": "^0.0.4", + "@filbert-js/core": "latest", + "@filbert-js/macro": "latest", + "@filbert-js/server-stylesheet": "latest", "next": "latest", "react": "^16.7.0", "react-dom": "^16.7.0" diff --git a/examples/with-filbert/pages/index.js b/examples/with-filbert/pages/index.js index aeabe9d3b618b..fb4f6eaea4901 100644 --- a/examples/with-filbert/pages/index.js +++ b/examples/with-filbert/pages/index.js @@ -1,11 +1,8 @@ -import { Global, styled } from '@filbert-js/core' +import { Global, css, styled } from '@filbert-js/macro' import React from 'react' -const Text = styled('div')` - color: hotpink; -` -const Heading = styled('h1')` +const Heading = styled.h1` outline: none; text-decoration: none; font-weight: 300; @@ -14,7 +11,6 @@ const Heading = styled('h1')` text-shadow: 0 1px 0 rgba(0, 0, 0, 0.01); padding: 0.4125em 1.25em; color: #3793e0; - &:hover { border-bottom-color: #4682b4; border-bottom: 1px solid; @@ -24,10 +20,10 @@ const Heading = styled('h1')` text-decoration: none; } ` -const Small = styled('div')` +const Small = styled.div` color: black; ` -const Container = styled('div')` +const Container = styled.div` display: flex; flex-direction: column; justify-content: center; @@ -57,7 +53,11 @@ export default function Home() { `} /> - filbert + filbert {' '} @@ -65,7 +65,13 @@ export default function Home() { A light weight(~1KB) css-in-js solution(framework)🎨 - Next JS is awesome +
+ Nextjs is awesome +
) } diff --git a/examples/with-firebase-authentication/utils/auth/useUser.js b/examples/with-firebase-authentication/utils/auth/useUser.js index cbd58d301e342..f300fc1bbf241 100644 --- a/examples/with-firebase-authentication/utils/auth/useUser.js +++ b/examples/with-firebase-authentication/utils/auth/useUser.js @@ -33,7 +33,7 @@ const useUser = () => { // Firebase updates the id token every hour, this // makes sure the react state and the cookie are // both kept up to date - firebase.auth().onIdTokenChanged((user) => { + const cancelAuthListener = firebase.auth().onIdTokenChanged((user) => { if (user) { const userData = mapUserData(user) setUserCookie(userData) @@ -50,6 +50,10 @@ const useUser = () => { return } setUser(userFromCookie) + + return () => { + cancelAuthListener() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/examples/with-flow/flow-typed/next.js.flow b/examples/with-flow/flow-typed/next.js.flow index 102b81a5d5010..e5b2756d88722 100644 --- a/examples/with-flow/flow-typed/next.js.flow +++ b/examples/with-flow/flow-typed/next.js.flow @@ -5,9 +5,6 @@ declare module "next" { prepare(): Promise; getRequestHandler(): any; render(req: any, res: any, pathname: string, query: any): any; - renderToHTML(req: any, res: any, pathname: string, query: string): string; - renderError(err: Error, req: any, res: any, pathname: any, query: any): any; - renderErrorToHTML(err: Error, req: any, res: any, pathname: string, query: any): string; }; declare module.exports: (...opts: any) => NextApp } diff --git a/examples/with-i18n-rosetta/lib/i18n.js b/examples/with-i18n-rosetta/lib/i18n.js index be7f3fa08a850..7a3d5810a454e 100644 --- a/examples/with-i18n-rosetta/lib/i18n.js +++ b/examples/with-i18n-rosetta/lib/i18n.js @@ -14,28 +14,10 @@ export const I18nContext = createContext() i18n.locale(defaultLanguage) export default function I18n({ children, locale, lngDict }) { - const [activeDict, setActiveDict] = useState(() => lngDict) const activeLocaleRef = useRef(locale || defaultLanguage) const [, setTick] = useState(0) const firstRender = useRef(true) - // for initial SSR render - if (locale && firstRender.current === true) { - firstRender.current = false - i18n.locale(locale) - i18n.set(locale, activeDict) - } - - useEffect(() => { - if (locale) { - i18n.locale(locale) - i18n.set(locale, activeDict) - activeLocaleRef.current = locale - // force rerender - setTick((tick) => tick + 1) - } - }, [locale, activeDict]) - const i18nWrapper = { activeLocale: activeLocaleRef.current, t: (...args) => i18n.t(...args), @@ -44,13 +26,26 @@ export default function I18n({ children, locale, lngDict }) { activeLocaleRef.current = l if (dict) { i18n.set(l, dict) - setActiveDict(dict) - } else { - setTick((tick) => tick + 1) } + // force rerender to update view + setTick((tick) => tick + 1) }, } + // for initial SSR render + if (locale && firstRender.current === true) { + firstRender.current = false + i18nWrapper.locale(locale, lngDict) + } + + // when locale is updated + useEffect(() => { + if (locale) { + i18nWrapper.locale(locale, lngDict) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lngDict, locale]) + return ( {children} ) diff --git a/examples/with-i18n-rosetta/pages/[lng]/index.js b/examples/with-i18n-rosetta/pages/[lng]/index.js index 3be9c28225e24..5426562d75b43 100644 --- a/examples/with-i18n-rosetta/pages/[lng]/index.js +++ b/examples/with-i18n-rosetta/pages/[lng]/index.js @@ -19,7 +19,7 @@ const HomePage = () => {

{i18n.t('intro.text')}

{i18n.t('intro.description')}

Current locale: {i18n.activeLocale}
- + Use client-side routing to change language to 'de'
diff --git a/examples/with-msw/.env b/examples/with-msw/.env new file mode 100644 index 0000000000000..f2f2baa1e38dd --- /dev/null +++ b/examples/with-msw/.env @@ -0,0 +1,4 @@ +# Enable API mocking in all environments, this is only for the sake of the example. +# In a real app you should move this variable to `.env.development`, as mocking the +# API should only be done for development. +NEXT_PUBLIC_API_MOCKING="enabled" \ No newline at end of file diff --git a/examples/with-msw/.gitignore b/examples/with-msw/.gitignore new file mode 100644 index 0000000000000..e3b3fe7726885 --- /dev/null +++ b/examples/with-msw/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel \ No newline at end of file diff --git a/examples/with-msw/README.md b/examples/with-msw/README.md new file mode 100644 index 0000000000000..d7c1368e462c7 --- /dev/null +++ b/examples/with-msw/README.md @@ -0,0 +1,29 @@ +# Mock Service Worker Example + +[Mock Service Worker](https://github.com/mswjs/msw) is an API mocking library for browser and Node. It provides seamless mocking by interception of actual requests on the network level using Service Worker API. This makes your application unaware of any mocking being at place. + +In this example we integrate Mock Service Worker with Next by following the next steps: + +1. Define a set of [request handlers](./mocks/handlers.js) shared between client and server. +1. Setup a [Service Worker instance](./mocks/browser.js) that would intercept all runtime client-side requests via `setupWorker` function. +1. Setup a ["server" instance](./mocks/server.js) to intercept any server/build time requests (e.g. the one happening in `getServerSideProps`) via `setupServer` function. + +Mocking is enabled using the `NEXT_PUBLIC_API_MOCKING` environment variable, which for the sake of the example is saved inside `.env` instead of `.env.development`. In a real app you should move the variable to `.env.development` because mocking should only be done for development. + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/with-msw) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-msw with-msw-app +# or +yarn create next-app --example with-msw with-msw-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/with-msw/mocks/browser.js b/examples/with-msw/mocks/browser.js new file mode 100644 index 0000000000000..b234fdae487ba --- /dev/null +++ b/examples/with-msw/mocks/browser.js @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw' +import { handlers } from './handlers' + +export const worker = setupWorker(...handlers) diff --git a/examples/with-msw/mocks/handlers.js b/examples/with-msw/mocks/handlers.js new file mode 100644 index 0000000000000..45db9ab201b84 --- /dev/null +++ b/examples/with-msw/mocks/handlers.js @@ -0,0 +1,26 @@ +import { rest } from 'msw' + +export const handlers = [ + rest.get('https://my.backend/book', (req, res, ctx) => { + return res( + ctx.json({ + title: 'Lord of the Rings', + imageUrl: '/book-cover.jpg', + description: + 'The Lord of the Rings is an epic high-fantasy novel written by English author and scholar J. R. R. Tolkien.', + }) + ) + }), + rest.get('/reviews', (req, res, ctx) => { + return res( + ctx.json([ + { + id: '60333292-7ca1-4361-bf38-b6b43b90cb16', + author: 'John Maverick', + text: + 'Lord of The Rings, is with no absolute hesitation, my most favored and adored book by‑far. The triology is wonderful‑ and I really consider this a legendary fantasy series. It will always keep you at the edge of your seat‑ and the characters you will grow and fall in love with!', + }, + ]) + ) + }), +] diff --git a/examples/with-msw/mocks/index.js b/examples/with-msw/mocks/index.js new file mode 100644 index 0000000000000..4ccbd9ef0e5f7 --- /dev/null +++ b/examples/with-msw/mocks/index.js @@ -0,0 +1,7 @@ +if (typeof window === 'undefined') { + const { server } = require('./server') + server.listen() +} else { + const { worker } = require('./browser') + worker.start() +} diff --git a/examples/with-msw/mocks/server.js b/examples/with-msw/mocks/server.js new file mode 100644 index 0000000000000..86f7d6154ac75 --- /dev/null +++ b/examples/with-msw/mocks/server.js @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/examples/with-msw/package.json b/examples/with-msw/package.json new file mode 100644 index 0000000000000..4af39232c3046 --- /dev/null +++ b/examples/with-msw/package.json @@ -0,0 +1,16 @@ +{ + "name": "with-msw", + "version": "1.0.0", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "license": "MIT", + "dependencies": { + "msw": "^0.20.4", + "next": "latest", + "react": "^16.13.1", + "react-dom": "^16.13.1" + } +} diff --git a/examples/with-msw/pages/_app.js b/examples/with-msw/pages/_app.js new file mode 100644 index 0000000000000..b20c39be7b767 --- /dev/null +++ b/examples/with-msw/pages/_app.js @@ -0,0 +1,7 @@ +if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { + require('../mocks') +} + +export default function App({ Component, pageProps }) { + return +} diff --git a/examples/with-msw/pages/index.js b/examples/with-msw/pages/index.js new file mode 100644 index 0000000000000..f22c03b0b41f4 --- /dev/null +++ b/examples/with-msw/pages/index.js @@ -0,0 +1,43 @@ +import { useState } from 'react' + +export default function Home({ book }) { + const [reviews, setReviews] = useState(null) + + const handleGetReviews = () => { + // Client-side request are mocked by `mocks/browser.js`. + fetch('/reviews') + .then((res) => res.json()) + .then(setReviews) + } + + return ( +
+ {book.title} +

{book.title}

+

{book.description}

+ + {reviews && ( +
    + {reviews.map((review) => ( +
  • +

    {review.text}

    +

    {review.author}

    +
  • + ))} +
+ )} +
+ ) +} + +export async function getServerSideProps() { + // Server-side requests are mocked by `mocks/server.js`. + const res = await fetch('https://my.backend/book') + const book = await res.json() + + return { + props: { + book, + }, + } +} diff --git a/examples/with-msw/public/book-cover.jpg b/examples/with-msw/public/book-cover.jpg new file mode 100644 index 0000000000000..563fe321267e6 Binary files /dev/null and b/examples/with-msw/public/book-cover.jpg differ diff --git a/examples/with-msw/public/mockServiceWorker.js b/examples/with-msw/public/mockServiceWorker.js new file mode 100644 index 0000000000000..fb1c0774bf493 --- /dev/null +++ b/examples/with-msw/public/mockServiceWorker.js @@ -0,0 +1,228 @@ +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ +/* eslint-disable */ +/* tslint:disable */ + +const INTEGRITY_CHECKSUM = 'ca2c3cd7453d8c614e2c19db63ede1a1' +const bypassHeaderName = 'x-msw-bypass' + +let clients = {} + +self.addEventListener('install', function () { + return self.skipWaiting() +}) + +self.addEventListener('activate', async function (event) { + return self.clients.claim() +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + const client = await event.currentTarget.clients.get(clientId) + const allClients = await self.clients.matchAll() + const allClientIds = allClients.map((client) => client.id) + + switch (event.data) { + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + clients = ensureKeys(allClientIds, clients) + clients[clientId] = true + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + clients = ensureKeys(allClientIds, clients) + clients[clientId] = false + break + } + + case 'CLIENT_CLOSED': { + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', async function (event) { + const { clientId, request } = event + const requestClone = request.clone() + const getOriginalResponse = () => fetch(requestClone) + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + event.respondWith( + new Promise(async (resolve, reject) => { + const client = await event.target.clients.get(clientId) + + if ( + // Bypass mocking when no clients active + !client || + // Bypass mocking if the current client has mocking disabled + !clients[clientId] || + // Bypass mocking for navigation requests + request.mode === 'navigate' + ) { + return resolve(getOriginalResponse()) + } + + // Bypass requests with the explicit bypass header + if (requestClone.headers.get(bypassHeaderName) === 'true') { + const modifiedHeaders = serializeHeaders(requestClone.headers) + // Remove the bypass header to comply with the CORS preflight check + delete modifiedHeaders[bypassHeaderName] + + const originalRequest = new Request(requestClone, { + headers: new Headers(modifiedHeaders), + }) + + return resolve(fetch(originalRequest)) + } + + const reqHeaders = serializeHeaders(request.headers) + const body = await request.text() + + const rawClientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + url: request.url, + method: request.method, + headers: reqHeaders, + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body, + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + const clientMessage = rawClientMessage + + switch (clientMessage.type) { + case 'MOCK_SUCCESS': { + setTimeout( + resolve.bind(this, createResponse(clientMessage)), + clientMessage.payload.delay + ) + break + } + + case 'MOCK_NOT_FOUND': { + return resolve(getOriginalResponse()) + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.payload + const networkError = new Error(message) + networkError.name = name + + // Rejecting a request Promise emulates a network error. + return reject(networkError) + } + + case 'INTERNAL_ERROR': { + const parsedBody = JSON.parse(clientMessage.payload.body) + + console.error( + `\ +[MSW] Request handler function for "%s %s" has thrown the following exception: + +${parsedBody.errorType}: ${parsedBody.message} +(see more detailed error stack trace in the mocked response body) + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error. +If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ + `, + request.method, + request.url + ) + + return resolve(createResponse(clientMessage)) + } + } + }).catch((error) => { + console.error( + '[MSW] Failed to mock a "%s" request to "%s": %s', + request.method, + request.url, + error + ) + }) + ) +}) + +function serializeHeaders(headers) { + const reqHeaders = {} + headers.forEach((value, name) => { + reqHeaders[name] = reqHeaders[name] + ? [].concat(reqHeaders[name]).concat(value) + : value + }) + return reqHeaders +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + reject(event.data.error) + } else { + resolve(event.data) + } + } + + client.postMessage(JSON.stringify(message), [channel.port2]) + }) +} + +function createResponse(clientMessage) { + return new Response(clientMessage.payload.body, { + ...clientMessage.payload, + headers: clientMessage.payload.headers, + }) +} + +function ensureKeys(keys, obj) { + return Object.keys(obj).reduce((acc, key) => { + if (keys.includes(key)) { + acc[key] = obj[key] + } + + return acc + }, {}) +} diff --git a/examples/with-next-sitemap/README.md b/examples/with-next-sitemap/README.md new file mode 100644 index 0000000000000..7f8bc3274e493 --- /dev/null +++ b/examples/with-next-sitemap/README.md @@ -0,0 +1,23 @@ +# next-sitemap example + +This example uses [`next-sitemap`](https://github.com/iamvishnusankar/next-sitemap) to generate a sitemap file for all pages (including all pre-rendered/static pages). + +`next-sitemap` allows the generation of sitemaps along with `robots.txt` and provides the feature to split large sitemaps into multiple files. Checkout the [`next-sitemap` documentation](https://github.com/iamvishnusankar/next-sitemap) to learn more. + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/with-next-sitemap) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-next-sitemap with-next-sitemap-app +# or +yarn create next-app --example with-next-sitemap with-next-sitemap-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/with-storybook-typescript/next-env.d.ts b/examples/with-next-sitemap/next-env.d.ts similarity index 100% rename from examples/with-storybook-typescript/next-env.d.ts rename to examples/with-next-sitemap/next-env.d.ts diff --git a/examples/with-next-sitemap/next-sitemap.js b/examples/with-next-sitemap/next-sitemap.js new file mode 100644 index 0000000000000..79a7b9c6f3b77 --- /dev/null +++ b/examples/with-next-sitemap/next-sitemap.js @@ -0,0 +1,12 @@ +module.exports = { + siteUrl: 'https://example.com', + generateRobotsTxt: true, + // optional + robotsTxtOptions: { + additionalSitemaps: [ + 'https://example.com/my-custom-sitemap-1.xml', + 'https://example.com/my-custom-sitemap-2.xml', + 'https://example.com/my-custom-sitemap-3.xml', + ], + }, +} diff --git a/examples/with-next-sitemap/package.json b/examples/with-next-sitemap/package.json new file mode 100644 index 0000000000000..3eabaec079718 --- /dev/null +++ b/examples/with-next-sitemap/package.json @@ -0,0 +1,21 @@ +{ + "name": "with-next-sitemap", + "version": "1.0.0", + "license": "MIT", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start", + "postbuild": "next-sitemap" + }, + "dependencies": { + "next": "latest", + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "devDependencies": { + "@types/node": "14.6.0", + "@types/react": "^16.9.45", + "next-sitemap": "latest" + } +} diff --git a/examples/with-next-sitemap/pages/[dynamic].tsx b/examples/with-next-sitemap/pages/[dynamic].tsx new file mode 100644 index 0000000000000..1847e6aba2de4 --- /dev/null +++ b/examples/with-next-sitemap/pages/[dynamic].tsx @@ -0,0 +1,34 @@ +import { GetStaticPaths, GetStaticProps } from 'next' +import { useRouter } from 'next/router' + +const DynamicPage = () => { + const { query } = useRouter() + + return ( +
+

Dynamic Page

+

Query: {query.dynamic}

+
+ ) +} + +export const getStaticProps: GetStaticProps = async () => { + return { + props: { + dynamic: 'hello', + }, + } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [...Array(10000)].map((_, index) => ({ + params: { + dynamic: `page-${index}`, + }, + })), + fallback: false, + } +} + +export default DynamicPage diff --git a/examples/with-next-sitemap/pages/index.tsx b/examples/with-next-sitemap/pages/index.tsx new file mode 100644 index 0000000000000..0b46074eb66e1 --- /dev/null +++ b/examples/with-next-sitemap/pages/index.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link' + +const HelloWorld = () => ( +
+

Hello World Page

+
    +
  1. + + Link to dynamic page 1 + +
  2. +
  3. + + Link to dynamic page 2 + +
  4. +
  5. + + Link to dynamic page 3 + +
  6. +
  7. + + Link to dynamic page 4 + +
  8. +
  9. + + Link to dynamic page 5 + +
  10. +
+
+) + +export default HelloWorld diff --git a/examples/with-next-sitemap/tsconfig.json b/examples/with-next-sitemap/tsconfig.json new file mode 100644 index 0000000000000..93a83a407c40c --- /dev/null +++ b/examples/with-next-sitemap/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/examples/with-storybook-typescript/.gitignore b/examples/with-reactstrap/.gitignore similarity index 100% rename from examples/with-storybook-typescript/.gitignore rename to examples/with-reactstrap/.gitignore diff --git a/examples/with-reactstrap/README.md b/examples/with-reactstrap/README.md new file mode 100644 index 0000000000000..373166b01c1f9 --- /dev/null +++ b/examples/with-reactstrap/README.md @@ -0,0 +1,21 @@ +# reactstrap Example + +This example shows how to use Next.js with [reactstrap](https://reactstrap.github.io/). + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/git?s=https://github.com/vercel/next.js/tree/canary/examples/with-reactstrap) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-reactstrap with-reactstrap-app +# or +yarn create next-app --example with-reactstrap with-reactstrap-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/with-reactstrap/package.json b/examples/with-reactstrap/package.json new file mode 100644 index 0000000000000..d70af506fdabf --- /dev/null +++ b/examples/with-reactstrap/package.json @@ -0,0 +1,16 @@ +{ + "name": "with-reactstrap", + "version": "0.0.1", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "bootstrap": "^4.5.0", + "next": "latest", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "reactstrap": "^8.5.1" + } +} diff --git a/examples/with-reactstrap/pages/_app.jsx b/examples/with-reactstrap/pages/_app.jsx new file mode 100644 index 0000000000000..bbd8269522fcb --- /dev/null +++ b/examples/with-reactstrap/pages/_app.jsx @@ -0,0 +1,5 @@ +import '../styles/index.css' + +export default function MyApp({ Component, pageProps }) { + return +} diff --git a/examples/with-reactstrap/pages/index.jsx b/examples/with-reactstrap/pages/index.jsx new file mode 100644 index 0000000000000..86ac829e0a04c --- /dev/null +++ b/examples/with-reactstrap/pages/index.jsx @@ -0,0 +1,106 @@ +import Head from 'next/head' +import { + Container, + Row, + Col, + Button, + Card, + CardText, + CardTitle, + CardBody, +} from 'reactstrap' + +export default function Home() { + return ( + + + ReactJS with reactstrap + + + +

+ Welcome to Next.js! +

+

+ Get started by editing pages/index.js +

+ + + + + + Documentation + + Find in-depth information about Next.js features and API. + + + + + + + + + Learn + + Learn about Next.js in an interactive course with quizzes! + + + + + + + + + + + Examples + + Discover and deploy boilerplate example Next.js projects. + + + + + + + + + Deploy + + Instantly deploy your Next.js site to a public URL with + Vercel. + + + + + + + +
+ + +
+ ) +} diff --git a/examples/with-reactstrap/public/favicon-32x32.png b/examples/with-reactstrap/public/favicon-32x32.png new file mode 100644 index 0000000000000..e3b4277bf093d Binary files /dev/null and b/examples/with-reactstrap/public/favicon-32x32.png differ diff --git a/examples/with-reactstrap/public/favicon.ico b/examples/with-reactstrap/public/favicon.ico new file mode 100644 index 0000000000000..4965832f2c9b0 Binary files /dev/null and b/examples/with-reactstrap/public/favicon.ico differ diff --git a/examples/with-reactstrap/public/vercel.svg b/examples/with-reactstrap/public/vercel.svg new file mode 100644 index 0000000000000..fbf0e25a651c2 --- /dev/null +++ b/examples/with-reactstrap/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/examples/with-reactstrap/styles/index.css b/examples/with-reactstrap/styles/index.css new file mode 100644 index 0000000000000..1583ab2eee329 --- /dev/null +++ b/examples/with-reactstrap/styles/index.css @@ -0,0 +1,26 @@ +/* ensure all pages have Bootstrap CSS */ +@import '~bootstrap/dist/css/bootstrap.min.css'; + +.md-container { + max-width: 800px; + padding-top: 2rem; +} + +.sml-card { + width: 22rem; + margin: 1rem 0; +} + +.cntr-footer { + height: 80px; + margin-top: 20px; + display: flex; + align-items: center; + justify-content: center; + border-top: 1px solid #ccc; +} + +.sml-logo { + height: 1em; + margin-left: 10px; +} diff --git a/examples/with-sentry/next.config.js b/examples/with-sentry/next.config.js index 21f6635917c7f..272fef9c62884 100644 --- a/examples/with-sentry/next.config.js +++ b/examples/with-sentry/next.config.js @@ -62,6 +62,7 @@ module.exports = withSourceMaps({ new SentryWebpackPlugin({ include: '.next', ignore: ['node_modules'], + stripPrefix: ['webpack://_N_E/'], urlPrefix: '~/_next', release: COMMIT_SHA, }) diff --git a/examples/with-storybook-typescript/.storybook/addons.js b/examples/with-storybook-typescript/.storybook/addons.js deleted file mode 100644 index 402ccc13eba33..0000000000000 --- a/examples/with-storybook-typescript/.storybook/addons.js +++ /dev/null @@ -1,2 +0,0 @@ -import '@storybook/addon-actions/register' -import '@storybook/addon-links/register' diff --git a/examples/with-storybook-typescript/.storybook/config.js b/examples/with-storybook-typescript/.storybook/config.js deleted file mode 100644 index e01335c2f110a..0000000000000 --- a/examples/with-storybook-typescript/.storybook/config.js +++ /dev/null @@ -1,25 +0,0 @@ -import { configure, addParameters } from '@storybook/react' - -addParameters({ - options: { - storySort: (a, b) => { - // We want the Welcome story at the top - if (a[1].kind === 'Welcome') { - return -1 - } - - // Sort the other stories by ID - // https://github.com/storybookjs/storybook/issues/548#issuecomment-530305279 - return a[1].kind === b[1].kind - ? 0 - : a[1].id.localeCompare(b[1].id, { numeric: true }) - }, - }, -}) - -// automatically import all files ending in *.stories.(ts|tsx) -const req = require.context('../stories', true, /.stories.tsx?$/) - -// the first argument can be an array too, so if you want to load from different locations or -// different extensions, you can do it like this: configure([req1, req2], module) -configure(req, module) diff --git a/examples/with-storybook-typescript/.storybook/main.js b/examples/with-storybook-typescript/.storybook/main.js deleted file mode 100644 index a9f47cffb9cfc..0000000000000 --- a/examples/with-storybook-typescript/.storybook/main.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - addons: ['@storybook/preset-typescript'], -} diff --git a/examples/with-storybook-typescript/README.md b/examples/with-storybook-typescript/README.md deleted file mode 100644 index c37520be320b1..0000000000000 --- a/examples/with-storybook-typescript/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Example app with Storybook and TypeScript. - -This example shows a default set up of Storybook plus TypeScript, using [@storybook/preset-typescript](https://github.com/storybookjs/presets/tree/master/packages/preset-typescript). Also included in the example is a custom component included in both Storybook and the Next.js application. - -## How to use - -### Using `create-next-app` - -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: - -```bash -npx create-next-app --example with-storybook-typescript with-storybook-app -# or -yarn create next-app --example with-storybook-typescript with-storybook-app -``` - -### Download manually - -Download the example: - -```bash -curl https://codeload.github.com/vercel/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/with-storybook-typescript -cd with-storybook-typescript -``` - -Install it and run: - -```bash -npm install -npm run dev -# or -yarn -yarn dev -``` - -## Run Storybook - -```bash -npm run storybook -# or -yarn storybook -``` - -## Build Static Storybook - -```bash -npm run build-storybook -# or -yarn build-storybook -``` - -You can use [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) to deploy Storybook. Specify `storybook-static` as the output directory. diff --git a/examples/with-storybook-typescript/components/index.tsx b/examples/with-storybook-typescript/components/index.tsx deleted file mode 100644 index c9eaaf748d055..0000000000000 --- a/examples/with-storybook-typescript/components/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from 'react' -export default function Home() { - return
Hello World
-} diff --git a/examples/with-storybook-typescript/package.json b/examples/with-storybook-typescript/package.json deleted file mode 100644 index 4aa78f1f1824b..0000000000000 --- a/examples/with-storybook-typescript/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "with-storybook", - "version": "1.0.0", - "main": "index.js", - "scripts": { - "dev": "next", - "build": "next build", - "start": "next start", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook" - }, - "dependencies": { - "next": "latest", - "react": "^16.7.0", - "react-dom": "^16.7.0" - }, - "license": "ISC", - "devDependencies": { - "@storybook/addon-actions": "5.3.19", - "@storybook/addon-links": "5.3.19", - "@storybook/addons": "5.3.19", - "@storybook/preset-typescript": "3.0.0", - "@storybook/react": "5.3.19", - "@types/node": "14.0.13", - "@types/react": "16.9.38", - "babel-loader": "^8.0.5", - "fork-ts-checker-webpack-plugin": "5.0.4", - "ts-loader": "7.0.5", - "typescript": "3.9.5" - } -} diff --git a/examples/with-storybook-typescript/pages/index.tsx b/examples/with-storybook-typescript/pages/index.tsx deleted file mode 100644 index 37e7e01d2c642..0000000000000 --- a/examples/with-storybook-typescript/pages/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import HelloWorld from '../components' - -export default function Home() { - return ( -
-

Simple Storybook Example

- -
- ) -} diff --git a/examples/with-storybook-typescript/stories/button.stories.tsx b/examples/with-storybook-typescript/stories/button.stories.tsx deleted file mode 100644 index adcd12fa27712..0000000000000 --- a/examples/with-storybook-typescript/stories/button.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react' -import { action } from '@storybook/addon-actions' -import { Button } from '@storybook/react/demo' - -export default { title: 'Button' } - -export const withText = () => ( - -) - -export const withSomeEmoji = () => ( - -) diff --git a/examples/with-storybook-typescript/stories/helloWorld.stories.tsx b/examples/with-storybook-typescript/stories/helloWorld.stories.tsx deleted file mode 100644 index 1c524fc556c66..0000000000000 --- a/examples/with-storybook-typescript/stories/helloWorld.stories.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react' -import HelloWorld from '../components' - -export default { title: 'Hello World' } - -export const simpleComponent = () => diff --git a/examples/with-storybook-typescript/stories/welcome.stories.tsx b/examples/with-storybook-typescript/stories/welcome.stories.tsx deleted file mode 100644 index 2466b9882ddfc..0000000000000 --- a/examples/with-storybook-typescript/stories/welcome.stories.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react' -import { linkTo } from '@storybook/addon-links' -import { Welcome } from '@storybook/react/demo' - -export default { title: 'Welcome' } - -export const toStorybook = () => diff --git a/examples/with-storybook/.storybook/addons.js b/examples/with-storybook/.storybook/addons.js deleted file mode 100644 index 402ccc13eba33..0000000000000 --- a/examples/with-storybook/.storybook/addons.js +++ /dev/null @@ -1,2 +0,0 @@ -import '@storybook/addon-actions/register' -import '@storybook/addon-links/register' diff --git a/examples/with-storybook/.storybook/config.js b/examples/with-storybook/.storybook/config.js deleted file mode 100644 index 28c65f4e7fca5..0000000000000 --- a/examples/with-storybook/.storybook/config.js +++ /dev/null @@ -1,25 +0,0 @@ -import { configure, addParameters } from '@storybook/react' - -addParameters({ - options: { - storySort: (a, b) => { - // We want the Welcome story at the top - if (a[1].kind === 'Welcome') { - return -1 - } - - // Sort the other stories by ID - // https://github.com/storybookjs/storybook/issues/548#issuecomment-530305279 - return a[1].kind === b[1].kind - ? 0 - : a[1].id.localeCompare(b[1].id, { numeric: true }) - }, - }, -}) - -// automatically import all files ending in *.stories.js -const req = require.context('../stories', true, /.stories.js$/) - -// the first argument can be an array too, so if you want to load from different locations or -// different extensions, you can do it like this: configure([req1, req2], module) -configure(req, module) diff --git a/examples/with-storybook/.storybook/main.js b/examples/with-storybook/.storybook/main.js new file mode 100644 index 0000000000000..bb74a535b057d --- /dev/null +++ b/examples/with-storybook/.storybook/main.js @@ -0,0 +1,4 @@ +module.exports = { + stories: ['../stories/*.stories.@(ts|tsx|js|jsx|mdx)'], + addons: ['@storybook/addon-actions', '@storybook/addon-links'], +} diff --git a/examples/with-storybook/.storybook/preview.js b/examples/with-storybook/.storybook/preview.js new file mode 100644 index 0000000000000..2108b79403292 --- /dev/null +++ b/examples/with-storybook/.storybook/preview.js @@ -0,0 +1,16 @@ +export const parameters = { + options: { + storySort: (a, b) => { + // We want the Welcome story at the top + if (b[1].kind === 'Welcome') { + return 1 + } + + // Sort the other stories by ID + // https://github.com/storybookjs/storybook/issues/548#issuecomment-530305279 + return a[1].kind === b[1].kind + ? 0 + : a[1].id.localeCompare(b[1].id, { numeric: true }) + }, + }, +} diff --git a/examples/with-storybook/README.md b/examples/with-storybook/README.md index 6a3239cb0a15f..451ff3d205e27 100644 --- a/examples/with-storybook/README.md +++ b/examples/with-storybook/README.md @@ -2,6 +2,10 @@ This example shows a default set up of Storybook. Also included in the example is a custom component included in both Storybook and the Next.js application. +### TypeScript + +As of v6.0, Storybook has built-in TypeScript support, so no configuration is needed. If you want to customize the default configuration, refer to the [TypeScript docs](https://storybook.js.org/docs/react/configure/typescript). + ## How to use ### Using `create-next-app` diff --git a/examples/with-storybook/package.json b/examples/with-storybook/package.json index f44b2f5e06445..276059e139922 100644 --- a/examples/with-storybook/package.json +++ b/examples/with-storybook/package.json @@ -16,10 +16,10 @@ }, "license": "ISC", "devDependencies": { - "@storybook/addon-actions": "5.2.3", - "@storybook/addon-links": "5.2.3", - "@storybook/addons": "5.2.3", - "@storybook/react": "5.2.3", + "@storybook/addon-actions": "6.0.5", + "@storybook/addon-links": "6.0.5", + "@storybook/addons": "6.0.5", + "@storybook/react": "6.0.5", "babel-loader": "^8.0.5" } } diff --git a/examples/with-supabase-auth-realtime-db/.env.local.example b/examples/with-supabase-auth-realtime-db/.env.local.example new file mode 100644 index 0000000000000..b6fedec92e308 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/.env.local.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_KEY=your-anon-key \ No newline at end of file diff --git a/examples/with-supabase-auth-realtime-db/.gitignore b/examples/with-supabase-auth-realtime-db/.gitignore new file mode 100644 index 0000000000000..1437c53f70bc2 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/with-supabase-auth-realtime-db/README.md b/examples/with-supabase-auth-realtime-db/README.md new file mode 100644 index 0000000000000..7e944e3f6fa61 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/README.md @@ -0,0 +1,139 @@ +# Realtime chat example using Supabase + +This is a full-stack Slack clone example using: + +- Frontend: + - Next.js. + - [Supabase](https://supabase.io/docs/library/getting-started) for user management and realtime data syncing. +- Backend: + - [app.supabase.io](https://app.supabase.io/): hosted Postgres database with restful API for usage with Supabase.js. + +![Demo animation gif](./docs/slack-clone-demo.gif) + +This example is a clone of the [Slack Clone example](https://github.com/supabase/supabase/tree/master/examples/slack-clone) in the supabase repo, feel free to check it out! + +## Deploy your own + +Once you have access to [the environment variables you'll need](#step-3-set-up-environment-variables), deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/git?c=1&s=https://github.com/vercel/next.js/tree/canary/examples/with-supabase-auth-realtime-db&env=NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_KEY&envDescription=Required%20to%20connect%20the%20app%to%Supabase&envLink=https://github.com/vercel/next.js/tree/canary/examples/with-supabase-auth-realtime-db%23step-3-set-up-environment-variables&project-name=supabase-slack-clone&repo-name=supabase-slack-clone) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-supabase-auth-realtime-db realtime-chat-app +# or +yarn create next-app --example with-supabase-auth-realtime-db realtime-chat-app +``` + +## Configuration + +### Step 1. Create a new Supabase project + +Sign up to Supabase - [https://app.supabase.io](https://app.supabase.io) and create a new project. Wait for your database to start. + +### Step 2. Run the "Slack Clone" Quickstart + +Once your database has started, run the "Slack Clone" quickstart. + +![Slack Clone Quick Start](https://user-images.githubusercontent.com/10214025/88916135-1b1d7a00-d298-11ea-82e7-e2c18314e805.png) + +### Step 3. Set up environment variables + +In your Supabase project, go to Project Settings (the cog icon), open the API tab, and find your **API URL** and **anon** key, you'll need these in the next step. + +![image](https://user-images.githubusercontent.com/10214025/88916245-528c2680-d298-11ea-8a71-708f93e1ce4f.png) + +Next, copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git): + +```bash +cp .env.local.example .env.local +``` + +Then set each variable on `.env.local`: + +- `NEXT_PUBLIC_SUPABASE_URL` should be the **API URL** +- `NEXT_PUBLIC_SUPABASE_KEY` should be the **anon** key + +The **anon** key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data. Read more about this [below](#postgres-row-level-security). + +> **_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser. + +### Step 4. Run Next.js in development mode + +```bash +npm install +npm run dev + +# or + +yarn install +yarn dev +``` + +Visit [http://localhost:3000](http://localhost:3000) and start chatting! Open a channel across two browser tabs to see everything getting updated in realtime 🥳. If it doesn't work, post on [GitHub discussions](https://github.com/vercel/next.js/discussions). + +### Step 5. Deploy on Vercel + +You can deploy this app to the cloud with [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). + +#### Deploy Your Local Project + +To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/import/git?utm_source=github&utm_medium=readme&utm_campaign=next-example). + +**Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file. + +#### Deploy from Our Template + +Alternatively, you can deploy using our template by clicking on the Deploy button below. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/git?c=1&s=https://github.com/vercel/next.js/tree/canary/examples/with-supabase-auth-realtime-db&env=NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_KEY&envDescription=Required%20to%20connect%20the%20app%to%Supabase&envLink=https://github.com/vercel/next.js/tree/canary/examples/with-supabase-auth-realtime-db%23step-3-set-up-environment-variables&project-name=supabase-slack-clone&repo-name=supabase-slack-clone) + +## Supabase details + +### Postgres Row level security + +This project uses very high-level Authorization using Postgres' Role Level Security. +When you start a Postgres database on Supabase, we populate it with an `auth` schema, and some helper functions. +When a user logs in, they are issued a JWT with the role `authenticated` and thier UUID. +We can use these details to provide fine-grained control over what each user can and cannot do. + +This is a trimmed-down schema, with the policies: + +```sql +-- USER PROFILES +CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE'); +CREATE TABLE public.users ( + id uuid NOT NULL PRIMARY KEY, -- UUID from auth.users (Supabase) + username text, + status user_status DEFAULT 'OFFLINE'::public.user_status +); +ALTER TABLE public.users ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow logged-in read access" on public.users FOR SELECT USING ( auth.role() = 'authenticated' ); +CREATE POLICY "Allow individual insert access" on public.users FOR INSERT WITH CHECK ( auth.uid() = id ); +CREATE POLICY "Allow individual update access" on public.users FOR UPDATE USING ( auth.uid() = id ); + +-- CHANNELS +CREATE TABLE public.channels ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + slug text NOT NULL UNIQUE +); +ALTER TABLE public.channels ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow logged-in full access" on public.channels FOR ALL USING ( auth.role() = 'authenticated' ); + +-- MESSAGES +CREATE TABLE public.messages ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + inserted_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + message text, + user_id uuid REFERENCES public.users NOT NULL, + channel_id bigint REFERENCES public.channels NOT NULL +); +ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow logged-in read access" on public.messages USING ( auth.role() = 'authenticated' ); +CREATE POLICY "Allow individual insert access" on public.messages FOR INSERT WITH CHECK ( auth.uid() = user_id ); +CREATE POLICY "Allow individual update access" on public.messages FOR UPDATE USING ( auth.uid() = user_id ); +``` diff --git a/examples/with-supabase-auth-realtime-db/components/Layout.js b/examples/with-supabase-auth-realtime-db/components/Layout.js new file mode 100644 index 0000000000000..1b94dea575aa5 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/components/Layout.js @@ -0,0 +1,80 @@ +import Link from 'next/link' +import { useContext } from 'react' +import UserContext from '~/lib/UserContext' +import { addChannel } from '~/lib/Store' + +export default function Layout(props) { + const { signOut } = useContext(UserContext) + + const slugify = (text) => { + return text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, '') // Trim - from end of text + } + + const newChannel = async () => { + const slug = prompt('Please enter your name') + if (slug) { + addChannel(slugify(slug)) + } + } + + return ( +
+ {/* Sidebar */} + + + {/* Messages */} +
{props.children}
+
+ ) +} + +const SidebarItem = ({ channel, isActiveChannel }) => ( + <> +
  • + + {channel.slug} + +
  • + +) diff --git a/examples/with-supabase-auth-realtime-db/components/Message.js b/examples/with-supabase-auth-realtime-db/components/Message.js new file mode 100644 index 0000000000000..1590a0ab9c095 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/components/Message.js @@ -0,0 +1,10 @@ +const Message = ({ message }) => ( + <> +
    +

    {message.author.username}

    +

    {message.message}

    +
    + +) + +export default Message diff --git a/examples/with-supabase-auth-realtime-db/components/MessageInput.js b/examples/with-supabase-auth-realtime-db/components/MessageInput.js new file mode 100644 index 0000000000000..1c0e3501beb57 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/components/MessageInput.js @@ -0,0 +1,28 @@ +import { useState } from 'react' + +const MessageInput = ({ onSubmit }) => { + const [messageText, setMessageText] = useState('') + + const submitOnEnter = (event) => { + // Watch for enter key + if (event.keyCode === 13) { + onSubmit(messageText) + setMessageText('') + } + } + + return ( + <> + setMessageText(e.target.value)} + onKeyDown={(e) => submitOnEnter(e)} + /> + + ) +} + +export default MessageInput diff --git a/examples/with-supabase-auth-realtime-db/docs/slack-clone-demo.gif b/examples/with-supabase-auth-realtime-db/docs/slack-clone-demo.gif new file mode 100644 index 0000000000000..8dff9b017beae Binary files /dev/null and b/examples/with-supabase-auth-realtime-db/docs/slack-clone-demo.gif differ diff --git a/examples/with-supabase-auth-realtime-db/jsconfig.json b/examples/with-supabase-auth-realtime-db/jsconfig.json new file mode 100644 index 0000000000000..7af8f6e0edda1 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./*"] + } + } +} diff --git a/examples/with-supabase-auth-realtime-db/lib/Store.js b/examples/with-supabase-auth-realtime-db/lib/Store.js new file mode 100644 index 0000000000000..b22779c735a08 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/lib/Store.js @@ -0,0 +1,169 @@ +import { useState, useEffect } from 'react' +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_KEY +) + +/** + * @param {number} channelId the currently selected Channel + */ +export const useStore = (props) => { + const [channels, setChannels] = useState([]) + const [messages, setMessages] = useState([]) + const [users] = useState(new Map()) + const [newMessage, handleNewMessage] = useState(null) + const [newChannel, handleNewChannel] = useState(null) + const [newOrUpdatedUser, handleNewOrUpdatedUser] = useState(null) + + // Load initial data and set up listeners + useEffect(() => { + // Get Channels + fetchChannels(setChannels) + // Listen for new messages + const messageListener = supabase + .from('messages') + .on('INSERT', (payload) => handleNewMessage(payload.new)) + .subscribe() + // Listen for changes to our users + const userListener = supabase + .from('users') + .on('*', (payload) => handleNewOrUpdatedUser(payload.new)) + .subscribe() + // Listen for new channels + const channelListener = supabase + .from('channels') + .on('INSERT', (payload) => handleNewChannel(payload.new)) + .subscribe() + // Cleanup on unmount + return () => { + messageListener.unsubscribe() + userListener.unsubscribe() + channelListener.unsubscribe() + } + }, []) + + // Update when the route changes + useEffect(() => { + if (props?.channelId > 0) { + fetchMessages(props.channelId, (messages) => { + messages.forEach((x) => users.set(x.user_id, x.author)) + setMessages(messages) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.channelId]) + + // New message recieved from Postgres + useEffect(() => { + if (newMessage && newMessage.channel_id === Number(props.channelId)) { + const handleAsync = async () => { + let authorId = newMessage.user_id + if (!users.get(authorId)) + await fetchUser(authorId, (user) => handleNewOrUpdatedUser(user)) + setMessages(messages.concat(newMessage)) + } + handleAsync() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newMessage]) + + // New channel recieved from Postgres + useEffect(() => { + if (newChannel) setChannels(channels.concat(newChannel)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newChannel]) + + // New or updated user recieved from Postgres + useEffect(() => { + if (newOrUpdatedUser) users.set(newOrUpdatedUser.id, newOrUpdatedUser) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newOrUpdatedUser]) + + return { + // We can export computed values here to map the authors to each message + messages: messages.map((x) => ({ ...x, author: users.get(x.user_id) })), + channels: channels.sort((a, b) => a.slug.localeCompare(b.slug)), + users, + } +} + +/** + * Fetch all channels + * @param {function} setState Optionally pass in a hook or callback to set the state + */ +export const fetchChannels = async (setState) => { + try { + let { body } = await supabase.from('channels').select('*') + if (setState) setState(body) + return body + } catch (error) { + console.log('error', error) + } +} + +/** + * Fetch a single user + * @param {number} userId + * @param {function} setState Optionally pass in a hook or callback to set the state + */ +export const fetchUser = async (userId, setState) => { + try { + let { body } = await supabase.from('users').eq('id', userId).select(`*`) + let user = body[0] + if (setState) setState(user) + return user + } catch (error) { + console.log('error', error) + } +} + +/** + * Fetch all messages and their authors + * @param {number} channelId + * @param {function} setState Optionally pass in a hook or callback to set the state + */ +export const fetchMessages = async (channelId, setState) => { + try { + let { body } = await supabase + .from('messages') + .eq('channel_id', channelId) + .select(`*, author:user_id(*)`) + .order('inserted_at', true) + if (setState) setState(body) + return body + } catch (error) { + console.log('error', error) + } +} + +/** + * Insert a new channel into the DB + * @param {string} slug The channel name + */ +export const addChannel = async (slug) => { + try { + let { body } = await supabase.from('channels').insert([{ slug }]) + return body + } catch (error) { + console.log('error', error) + } +} + +/** + * Insert a new message into the DB + * @param {string} message The message text + * @param {number} channel_id + * @param {number} user_id The author + */ +export const addMessage = async (message, channel_id, user_id) => { + try { + let { body } = await supabase + .from('messages') + .insert([{ message, channel_id, user_id }]) + return body + } catch (error) { + console.log('error', error) + } +} diff --git a/examples/with-supabase-auth-realtime-db/lib/UserContext.js b/examples/with-supabase-auth-realtime-db/lib/UserContext.js new file mode 100644 index 0000000000000..ee09678f5089b --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/lib/UserContext.js @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +const UserContext = createContext() + +export default UserContext diff --git a/examples/with-supabase-auth-realtime-db/package.json b/examples/with-supabase-auth-realtime-db/package.json new file mode 100644 index 0000000000000..5b33e3198609d --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/package.json @@ -0,0 +1,18 @@ +{ + "name": "realtime-chat-app", + "version": "0.1.0", + "license": "MIT", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@supabase/supabase-js": "^0.35.9", + "next": "latest", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "sass": "^1.26.2", + "tailwindcss": "^1.1.4" + } +} diff --git a/examples/with-supabase-auth-realtime-db/pages/_app.js b/examples/with-supabase-auth-realtime-db/pages/_app.js new file mode 100644 index 0000000000000..8bd38f78bcc7a --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/pages/_app.js @@ -0,0 +1,71 @@ +import '~/styles/style.scss' +import React from 'react' +import App from 'next/app' +import Router from 'next/router' +import UserContext from 'lib/UserContext' +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_KEY +) + +export default class SupabaseSlackClone extends App { + state = { + authLoaded: false, + user: null, + } + + componentDidMount = () => { + const user = localStorage.getItem('supabase-slack-clone') + if (user) this.setState({ user, authLoaded: true }) + else Router.push('/') + } + + signIn = async (id, username) => { + try { + let { body } = await supabase + .from('users') + .match({ username }) + .select('id, username') + const existing = body[0] + const { body: user } = existing?.id + ? await supabase + .from('users') + .update({ id, username }) + .match({ id }) + .single() + : await supabase.from('users').insert([{ id, username }]).single() + + localStorage.setItem('supabase-slack-clone', user.id) + this.setState({ user: user.id }, () => { + Router.push('/channels/[id]', '/channels/1') + }) + } catch (error) { + console.log('error', error) + } + } + + signOut = () => { + supabase.auth.logout() + localStorage.removeItem('supabase-slack-clone') + this.setState({ user: null }) + Router.push('/') + } + + render() { + const { Component, pageProps } = this.props + return ( + + + + ) + } +} diff --git a/examples/with-supabase-auth-realtime-db/pages/channels/[id].js b/examples/with-supabase-auth-realtime-db/pages/channels/[id].js new file mode 100644 index 0000000000000..7e9bd5f808ebd --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/pages/channels/[id].js @@ -0,0 +1,53 @@ +import Layout from '~/components/Layout' +import Message from '~/components/Message' +import MessageInput from '~/components/MessageInput' +import { useRouter } from 'next/router' +import { useStore, addMessage } from '~/lib/Store' +import { useContext, useEffect, useRef } from 'react' +import UserContext from '~/lib/UserContext' + +const ChannelsPage = (props) => { + const router = useRouter() + const { user, authLoaded, signOut } = useContext(UserContext) + const messagesEndRef = useRef(null) + + // Redirect if not signed in. + useEffect(() => { + if (authLoaded && !user) signOut() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, router]) + + // Else load up the page + const { id: channelId } = router.query + const { messages, channels } = useStore({ channelId }) + + useEffect(() => { + messagesEndRef.current.scrollIntoView({ + block: 'start', + behavior: 'smooth', + }) + }, [messages]) + + // Render the channels and messages + return ( + +
    +
    +
    + {messages.map((x) => ( + + ))} +
    +
    +
    +
    + addMessage(text, channelId, user)} + /> +
    +
    + + ) +} + +export default ChannelsPage diff --git a/examples/with-supabase-auth-realtime-db/pages/index.js b/examples/with-supabase-auth-realtime-db/pages/index.js new file mode 100644 index 0000000000000..7585c0be47dd7 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/pages/index.js @@ -0,0 +1,87 @@ +import { useState, useContext } from 'react' +import UserContext from 'lib/UserContext' +import { createClient } from '@supabase/supabase-js' + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_KEY +) + +const Home = () => { + const { signIn } = useContext(UserContext) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + + const handleLogin = async (type, username, password) => { + try { + const { + body: { user }, + } = + type === 'LOGIN' + ? await supabase.auth.login(username, password) + : await supabase.auth.signup(username, password) + if (!!user) signIn(user.id, user.email) + } catch (error) { + console.log('error', error) + alert(error.error_description || error) + } + } + + return ( + + ) +} + +export default Home diff --git a/examples/with-supabase-auth-realtime-db/styles/style.scss b/examples/with-supabase-auth-realtime-db/styles/style.scss new file mode 100644 index 0000000000000..e6f0385abc078 --- /dev/null +++ b/examples/with-supabase-auth-realtime-db/styles/style.scss @@ -0,0 +1,28 @@ +@import '~tailwindcss/dist/base.css'; +@import '~tailwindcss/dist/components.css'; +@import '~tailwindcss/dist/utilities.css'; + +html, +body, +#__next, +.main { + max-height: 100vh; + height: 100vh; + margin: 0; + padding: 0; + overflow: hidden; +} +.channel-list { + li a:before { + content: '# '; + opacity: 0.5; + } + li a:hover { + opacity: 0.9; + } +} +.Messages { + overflow: auto; + display: flex; + flex-direction: column-reverse; +} diff --git a/examples/with-typescript/package.json b/examples/with-typescript/package.json index fc269dba461ba..e871704c247cf 100644 --- a/examples/with-typescript/package.json +++ b/examples/with-typescript/package.json @@ -16,7 +16,7 @@ "@types/node": "^12.12.21", "@types/react": "^16.9.16", "@types/react-dom": "^16.9.4", - "typescript": "3.7.3" + "typescript": "3.9.7" }, "license": "ISC" } diff --git a/examples/with-unsplash/.env.local.example b/examples/with-unsplash/.env.local.example new file mode 100644 index 0000000000000..360db377525ea --- /dev/null +++ b/examples/with-unsplash/.env.local.example @@ -0,0 +1,2 @@ +UNSPLASH_ACCESS_KEY= +UNSPLASH_USER= \ No newline at end of file diff --git a/examples/with-unsplash/.gitignore b/examples/with-unsplash/.gitignore new file mode 100644 index 0000000000000..1437c53f70bc2 --- /dev/null +++ b/examples/with-unsplash/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/examples/with-unsplash/README.md b/examples/with-unsplash/README.md new file mode 100644 index 0000000000000..3f4c5b3e0e332 --- /dev/null +++ b/examples/with-unsplash/README.md @@ -0,0 +1,86 @@ +# Using Next.js with Unsplash API + +This is an example of how [Unsplash](https://unsplash.com/) can be used with `Next.js` + +## Deploy your own + +Once you have access to [the environment variables you'll need](#step-2-set-up-environment-variables), deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/git?c=1&s=https://github.com/vercel/next.js/tree/canary/examples/with-unsplash&env=UNSPLASH_ACCESS_KEY,UNSPLASH_USER&envDescription=Required%20to%20connect%20the%20app%20with%20Unsplash&envLink=https://github.com/vercel/next.js/tree/canary/examples/with-unsplash%23step-2-set-up-environment-variables) + +## How to use + +### Using `create-next-app` + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-unsplash with-unsplash-app +# or +yarn create next-app --example with-unsplash with-unsplash-app +``` + +## Configuration + +First, you'll need to [create an account on Unsplash](https://unsplash.com/) if you don't have one already. Once that's done, follow the steps below. + +### Step 1. Create an app on Unsplash + +To create a new application on Unsplash, click [here](https://unsplash.com/oauth/applications/new) or go to https://unsplash.com/oauth/applications/new. + +Before creating an app you'll have to accept the terms for API use: + +![Accept Unsplash app terms](./docs/app-terms.png) + +Then, fill the form with the app name and description, and click on on **Create application** to finish the creation of your app: + +![Form to fill app name and description](./docs/app-form.png) + +### Step 2. Set up environment variables + +After creating the app, you should be able to see the API keys in the settings page of your app: + +![API Keys of Unsplash app](./docs/api-keys.png) + +We'll need those API keys to connect the example with your Unsplash app. + +First, copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git): + +```bash +cp .env.local.example .env.local +``` + +Then set each variable on `.env.local`: + +- `UNSPLASH_ACCESS_KEY` should be the **Access Key** of your Unsplash app +- `UNSPLASH_USER` should be any valid Unsplash username. The example will use the photos of the user selected here. + +### Step 3. Run Next.js in development mode + +```bash +npm install +npm run dev + +# or + +yarn install +yarn dev +``` + +Your app should be up and running on [http://localhost:3000](http://localhost:3000)! If it doesn't work, post on [GitHub discussions](https://github.com/vercel/next.js/discussions). + +### Step 4. Deploy on Vercel + +You can deploy this app to the cloud with [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). + +#### Deploy Your Local Project + +To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/import/git?utm_source=github&utm_medium=readme&utm_campaign=next-example). + +**Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file. + +#### Deploy from Our Template + +Alternatively, you can deploy using our template by clicking on the Deploy button below. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/git?c=1&s=https://github.com/vercel/next.js/tree/canary/examples/with-unsplash&env=UNSPLASH_ACCESS_KEY,UNSPLASH_USER&envDescription=Required%20to%20connect%20the%20app%20with%20Unsplash&envLink=https://github.com/vercel/next.js/tree/canary/examples/with-unsplash%23step-2-set-up-environment-variables) diff --git a/examples/with-unsplash/components/Collections/Collections.module.css b/examples/with-unsplash/components/Collections/Collections.module.css new file mode 100644 index 0000000000000..e9af646954776 --- /dev/null +++ b/examples/with-unsplash/components/Collections/Collections.module.css @@ -0,0 +1,46 @@ +.chips { + padding: 12px 0; + text-align: center; +} + +a.chip { + display: inline-block; + background: #e0e0e0; + color: #333; + text-decoration: none; + padding: 0 12px; + border-radius: 10px; + font-size: 13px; + margin: 0 5px 3px 0; +} + +a.chip:hover { + background: #ccc; +} + +.chip_remove { + display: inline-block; + background: #aaa; + border: 0; + height: 15px; + width: 15px; + border-radius: 50%; + padding: 0; + margin: 0 -10px 0 5px; + cursor: pointer; + font: inherit; + line-height: 15px; +} + +.chip_remove:after { + color: #333; + content: 'x'; +} + +.chip_remove:hover { + background: #999; +} + +.chip_remove:active { + background: #777; +} diff --git a/examples/with-unsplash/components/Collections/index.tsx b/examples/with-unsplash/components/Collections/index.tsx new file mode 100644 index 0000000000000..b14996c6dce34 --- /dev/null +++ b/examples/with-unsplash/components/Collections/index.tsx @@ -0,0 +1,51 @@ +import useSWR from 'swr' +import fetcher from 'libs/fetcher' +import Link from 'next/link' +import styles from './Collections.module.css' + +interface CollectionProps { + id_collection?: number +} + +const Collections = ({ id_collection }: CollectionProps) => { + const { data, error } = useSWR( + '/api/collection' + (id_collection ? `/${id_collection}` : ''), + fetcher + ) + + if (error) return
    failed to load
    + + if (!data) return
    loading...
    + + return ( +
    + {data.map(({ id, title, slug }) => + id_collection ? ( + + + {title} + + + + + + + ) : ( + + {title} + + ) + )} +
    + ) +} + +export default Collections diff --git a/examples/with-unsplash/components/Gallery/Gallery.module.css b/examples/with-unsplash/components/Gallery/Gallery.module.css new file mode 100644 index 0000000000000..b420a8812553d --- /dev/null +++ b/examples/with-unsplash/components/Gallery/Gallery.module.css @@ -0,0 +1,18 @@ +.gallery_container { + font-size: 1.2rem; + line-height: 1.5; + column-count: 3; + column-gap: 20px; +} + +@media (max-width: 990px) { + .gallery_container { + column-count: 2; + } +} + +@media (max-width: 790px) { + .gallery_container { + column-count: 1; + } +} diff --git a/examples/with-unsplash/components/Gallery/index.tsx b/examples/with-unsplash/components/Gallery/index.tsx new file mode 100644 index 0000000000000..65a02b5c32cd8 --- /dev/null +++ b/examples/with-unsplash/components/Gallery/index.tsx @@ -0,0 +1,34 @@ +import useSWR from 'swr' +import fetcher from 'libs/fetcher' +import styles from './Gallery.module.css' +import UImage from 'components/UImage' + +interface GalleryProps { + id_collection?: number +} + +const Gallery = ({ id_collection }: GalleryProps) => { + const { data, error } = useSWR( + '/api/photo' + (id_collection ? `/${id_collection}` : ''), + fetcher + ) + + if (error) return
    failed to load
    + + if (!data) return
    loading...
    + + return ( +
    + {data.map(({ id, urls, alt_description, description }) => ( + + ))} +
    + ) +} + +export default Gallery diff --git a/examples/with-unsplash/components/Layout/index.tsx b/examples/with-unsplash/components/Layout/index.tsx new file mode 100644 index 0000000000000..2eb348ce9af36 --- /dev/null +++ b/examples/with-unsplash/components/Layout/index.tsx @@ -0,0 +1,27 @@ +import Head from 'next/head' +import User from 'components/User' + +import styles from './layout.module.css' + +export const siteTitle = 'Unsplash Profile with Nextjs' + +const Layout = ({ children }) => { + return ( +
    + + + + {/* */} + + + + + + + +
    {children}
    +
    + ) +} + +export default Layout diff --git a/examples/with-unsplash/components/Layout/layout.module.css b/examples/with-unsplash/components/Layout/layout.module.css new file mode 100644 index 0000000000000..c774b9d97fcc8 --- /dev/null +++ b/examples/with-unsplash/components/Layout/layout.module.css @@ -0,0 +1,13 @@ +.container { + padding: 0 1rem; + margin: 3rem auto 6rem; +} + +.headerHomeImage { + width: 8rem; + height: 8rem; +} + +.backToHome { + margin: 3rem 0 0; +} diff --git a/examples/with-unsplash/components/Social/Social.module.css b/examples/with-unsplash/components/Social/Social.module.css new file mode 100644 index 0000000000000..04865adcc7425 --- /dev/null +++ b/examples/with-unsplash/components/Social/Social.module.css @@ -0,0 +1,3 @@ +.social_container { + height: 60px; +} diff --git a/examples/with-unsplash/components/Social/index.tsx b/examples/with-unsplash/components/Social/index.tsx new file mode 100644 index 0000000000000..acf779c6bbe41 --- /dev/null +++ b/examples/with-unsplash/components/Social/index.tsx @@ -0,0 +1,29 @@ +import styles from './Social.module.css' +import UIcon from 'components/UIcon' + +const Social = ({ user }) => { + return ( +
    + {user.twitter_username && ( + + )} + {user.instagram_username && ( + + )} + {user.username && ( + + )} +
    + ) +} + +export default Social diff --git a/examples/with-unsplash/components/Stats/Stats.module.css b/examples/with-unsplash/components/Stats/Stats.module.css new file mode 100644 index 0000000000000..4bc2d80cf3658 --- /dev/null +++ b/examples/with-unsplash/components/Stats/Stats.module.css @@ -0,0 +1,4 @@ +.stats_container { + padding: 10px 0px; + font-size: 12px; +} diff --git a/examples/with-unsplash/components/Stats/index.tsx b/examples/with-unsplash/components/Stats/index.tsx new file mode 100644 index 0000000000000..f05ae597c1667 --- /dev/null +++ b/examples/with-unsplash/components/Stats/index.tsx @@ -0,0 +1,20 @@ +import useSWR from 'swr' +import fetcher from 'libs/fetcher' +import styles from './Stats.module.css' + +const Stats = () => { + const { data, error } = useSWR('/api/stats', fetcher) + + if (error) return
    failed to load
    + + return ( +
    + Stats + downloads: {data ? data.downloads.total : '...'} | views:{' '} + {data ? data.views.total : '...'} | likes:{' '} + {data ? data.likes.total : '...'} +
    + ) +} + +export default Stats diff --git a/examples/with-unsplash/components/UIcon/UIcon.module.css b/examples/with-unsplash/components/UIcon/UIcon.module.css new file mode 100644 index 0000000000000..5c30406254288 --- /dev/null +++ b/examples/with-unsplash/components/UIcon/UIcon.module.css @@ -0,0 +1,8 @@ +.icon { + padding: 20px; + float: left; +} + +.icon_svg { + width: 20px; +} diff --git a/examples/with-unsplash/components/UIcon/index.tsx b/examples/with-unsplash/components/UIcon/index.tsx new file mode 100644 index 0000000000000..8baab9ee1e484 --- /dev/null +++ b/examples/with-unsplash/components/UIcon/index.tsx @@ -0,0 +1,20 @@ +import styles from './UIcon.module.css' + +const UIcon = ({ url, name }) => { + return ( + + {`${name} + + ) +} + +export default UIcon diff --git a/examples/with-unsplash/components/UImage/UImage.module.css b/examples/with-unsplash/components/UImage/UImage.module.css new file mode 100644 index 0000000000000..10df45b1e9441 --- /dev/null +++ b/examples/with-unsplash/components/UImage/UImage.module.css @@ -0,0 +1,23 @@ +.img { + display: inline-block; + width: 100%; + margin: 5px 0; +} + +.actions { + position: relative; + bottom: 55px; + right: 10px; + width: 100%; + height: 0px; + text-align: right; + float: left; +} + +.actions a { + background-color: #fff; + padding: 5px; + border-radius: 5px; + margin: 5px; + float: right; +} diff --git a/examples/with-unsplash/components/UImage/index.tsx b/examples/with-unsplash/components/UImage/index.tsx new file mode 100644 index 0000000000000..47b0b04d84a49 --- /dev/null +++ b/examples/with-unsplash/components/UImage/index.tsx @@ -0,0 +1,15 @@ +import styles from './UImage.module.css' +import UIcon from 'components/UIcon' + +const Uimage = ({ id, urls, altDescription }) => { + return ( +
    + {altDescription} +
    + +
    +
    + ) +} + +export default Uimage diff --git a/examples/with-unsplash/components/User/User.module.css b/examples/with-unsplash/components/User/User.module.css new file mode 100644 index 0000000000000..afe6329047488 --- /dev/null +++ b/examples/with-unsplash/components/User/User.module.css @@ -0,0 +1,25 @@ +.header { + display: flex; + flex-direction: column; + align-items: center; +} + +.headerImage { + width: 6rem; + height: 6rem; +} + +.headerHomeImage { + width: 8rem; + height: 8rem; +} + +.borderCircle { + border-radius: 9999px; +} + +.headingLg { + font-size: 1.5rem; + line-height: 1.4; + margin: 1rem 0; +} diff --git a/examples/with-unsplash/components/User/index.tsx b/examples/with-unsplash/components/User/index.tsx new file mode 100644 index 0000000000000..d8044ec8d1145 --- /dev/null +++ b/examples/with-unsplash/components/User/index.tsx @@ -0,0 +1,38 @@ +import useSWR from 'swr' +import fetcher from 'libs/fetcher' +import Link from 'next/link' +import styles from './User.module.css' +import Social from 'components/Social' + +const User = () => { + const { data, error } = useSWR('/api/user', fetcher) + + if (error) return
    failed to load
    + + return ( +
    + + + {data && ( + {data.name} + )} + + +

    + + {data ? data.name : ''} + +

    + + {data ? : ''} + +

    {data ? data.bio : ''}

    +
    + ) +} + +export default User diff --git a/examples/with-unsplash/docs/api-keys.png b/examples/with-unsplash/docs/api-keys.png new file mode 100644 index 0000000000000..5dd390c5ceea6 Binary files /dev/null and b/examples/with-unsplash/docs/api-keys.png differ diff --git a/examples/with-unsplash/docs/app-form.png b/examples/with-unsplash/docs/app-form.png new file mode 100644 index 0000000000000..4d2a918be3ecd Binary files /dev/null and b/examples/with-unsplash/docs/app-form.png differ diff --git a/examples/with-unsplash/docs/app-terms.png b/examples/with-unsplash/docs/app-terms.png new file mode 100644 index 0000000000000..529abf35c75bc Binary files /dev/null and b/examples/with-unsplash/docs/app-terms.png differ diff --git a/examples/with-unsplash/libs/fetcher.tsx b/examples/with-unsplash/libs/fetcher.tsx new file mode 100644 index 0000000000000..2d263cfca4a79 --- /dev/null +++ b/examples/with-unsplash/libs/fetcher.tsx @@ -0,0 +1,7 @@ +export default async function f( + input: RequestInfo, + init?: RequestInit +): Promise { + const res = await fetch(input, init) + return res.json() +} diff --git a/examples/with-unsplash/libs/slug.tsx b/examples/with-unsplash/libs/slug.tsx new file mode 100644 index 0000000000000..c49b2deb693af --- /dev/null +++ b/examples/with-unsplash/libs/slug.tsx @@ -0,0 +1,9 @@ +const slug = (str: string) => { + return str + .toLowerCase() + .replace(/\s/g, '-') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') +} + +export default slug diff --git a/examples/with-unsplash/next-env.d.ts b/examples/with-unsplash/next-env.d.ts new file mode 100644 index 0000000000000..7b7aa2c7727d8 --- /dev/null +++ b/examples/with-unsplash/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/with-unsplash/package.json b/examples/with-unsplash/package.json new file mode 100644 index 0000000000000..ddd5d6bb3a6b1 --- /dev/null +++ b/examples/with-unsplash/package.json @@ -0,0 +1,26 @@ +{ + "name": "with-unsplash", + "version": "1.0.0", + "license": "MIT", + "description": "Using Next.js with Unsplash API", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^9.5.1", + "react": "16.13.1", + "react-dom": "16.13.1", + "swr": "^0.3.0", + "unsplash-js": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^14.0.27", + "@types/react": "^16.9.46", + "@types/request": "^2.48.5", + "@types/unsplash-js": "^6.0.1", + "request": "^2.88.2", + "typescript": "^3.9.7" + } +} diff --git a/examples/with-unsplash/pages/_app.js b/examples/with-unsplash/pages/_app.js new file mode 100644 index 0000000000000..f2f8898273a60 --- /dev/null +++ b/examples/with-unsplash/pages/_app.js @@ -0,0 +1,5 @@ +import 'styles/global.css' + +export default function App({ Component, pageProps }) { + return +} diff --git a/examples/with-unsplash/pages/_document.tsx b/examples/with-unsplash/pages/_document.tsx new file mode 100644 index 0000000000000..3c9ab6e57a71b --- /dev/null +++ b/examples/with-unsplash/pages/_document.tsx @@ -0,0 +1,22 @@ +import Document, { Html, Head, Main, NextScript } from 'next/document' + +class MyDocument extends Document { + static async getInitialProps(ctx) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps } + } + + render() { + return ( + + + +
    + + + + ) + } +} + +export default MyDocument diff --git a/examples/with-unsplash/pages/api/collection/[id].tsx b/examples/with-unsplash/pages/api/collection/[id].tsx new file mode 100644 index 0000000000000..9a6084112a5ef --- /dev/null +++ b/examples/with-unsplash/pages/api/collection/[id].tsx @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' + +export default function getCollection( + req: NextApiRequest, + res: NextApiResponse +) { + const { + query: { id }, + } = req + + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.collections + .getCollection(parseInt(id.toString())) + .then(toJson) + .then((json) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify([json])) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/collection/index.tsx b/examples/with-unsplash/pages/api/collection/index.tsx new file mode 100644 index 0000000000000..6edcd21eeaf92 --- /dev/null +++ b/examples/with-unsplash/pages/api/collection/index.tsx @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' +import slug from 'libs/slug' + +export default function getCollections( + req: NextApiRequest, + res: NextApiResponse +) { + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.users + .collections(process.env.UNSPLASH_USER, 1, 15, 'updated') + .then(toJson) + .then((json) => { + json.map((c) => (c.slug = slug(c.title))) + + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify(json)) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/photo/[id].tsx b/examples/with-unsplash/pages/api/photo/[id].tsx new file mode 100644 index 0000000000000..5a9c499b0d36c --- /dev/null +++ b/examples/with-unsplash/pages/api/photo/[id].tsx @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' + +export default function getCollectionPhotos( + req: NextApiRequest, + res: NextApiResponse +) { + const { + query: { id }, + } = req + + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.collections + .getCollectionPhotos(parseInt(id.toString())) + .then(toJson) + .then((json) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify(json)) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/photo/download/[id].tsx b/examples/with-unsplash/pages/api/photo/download/[id].tsx new file mode 100644 index 0000000000000..d0ed45c4c636f --- /dev/null +++ b/examples/with-unsplash/pages/api/photo/download/[id].tsx @@ -0,0 +1,43 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' +import fetch from 'node-fetch' +global.fetch = fetch + +export default function download(req: NextApiRequest, res: NextApiResponse) { + const { + query: { id }, + } = req + + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + return new Promise((resolve) => { + u.photos + .getPhoto(id.toString()) + .then(toJson) + .then((json) => { + u.photos.downloadPhoto(json) + + const filePath = json.links.download + const fileName = id + '.jpg' + + res.setHeader('content-disposition', 'attachment; filename=' + fileName) + + fetch(filePath) + .then((r) => r.buffer()) + .then((buff) => { + res.end(buff) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/photo/index.tsx b/examples/with-unsplash/pages/api/photo/index.tsx new file mode 100644 index 0000000000000..2b91a212a2665 --- /dev/null +++ b/examples/with-unsplash/pages/api/photo/index.tsx @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' + +export default function getPhotos(req: NextApiRequest, res: NextApiResponse) { + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.users + .photos(process.env.UNSPLASH_USER, 1, 50, 'latest') + .then(toJson) + .then((json: string) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify(json)) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/stats/index.tsx b/examples/with-unsplash/pages/api/stats/index.tsx new file mode 100644 index 0000000000000..8ded041655161 --- /dev/null +++ b/examples/with-unsplash/pages/api/stats/index.tsx @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' + +export default function getStats(req: NextApiRequest, res: NextApiResponse) { + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.users + .statistics(process.env.UNSPLASH_USER, 'days', 30) + .then(toJson) + .then((json) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify(json)) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/api/user/index.tsx b/examples/with-unsplash/pages/api/user/index.tsx new file mode 100644 index 0000000000000..5b8d58715b4e8 --- /dev/null +++ b/examples/with-unsplash/pages/api/user/index.tsx @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import Unsplash, { toJson } from 'unsplash-js' + +export default function getUser(req: NextApiRequest, res: NextApiResponse) { + return new Promise((resolve) => { + const u = new Unsplash({ accessKey: process.env.UNSPLASH_ACCESS_KEY }) + + u.users + .profile(process.env.UNSPLASH_USER) + .then(toJson) + .then((json) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'max-age=180000') + res.end(JSON.stringify(json)) + resolve() + }) + .catch((error) => { + res.json(error) + res.status(405).end() + resolve() + }) + }) +} diff --git a/examples/with-unsplash/pages/collection/[slug].tsx b/examples/with-unsplash/pages/collection/[slug].tsx new file mode 100644 index 0000000000000..6fc0dea514d29 --- /dev/null +++ b/examples/with-unsplash/pages/collection/[slug].tsx @@ -0,0 +1,25 @@ +import Head from 'next/head' +import Layout, { siteTitle } from 'components/Layout' +import Gallery from 'components/Gallery' +import Collections from 'components/Collections' +import { useRouter } from 'next/router' + +const Collection = () => { + const router = useRouter() + const collection_id = router.query.id + ? parseInt(router.query.id.toString()) + : null + return ( + + + {siteTitle} + + + + + + + ) +} + +export default Collection diff --git a/examples/with-unsplash/pages/index.tsx b/examples/with-unsplash/pages/index.tsx new file mode 100644 index 0000000000000..47ddddb1cf5cf --- /dev/null +++ b/examples/with-unsplash/pages/index.tsx @@ -0,0 +1,23 @@ +import Head from 'next/head' +import Layout, { siteTitle } from 'components/Layout' +import Gallery from 'components/Gallery' +import Stats from 'components/Stats' +import Collections from 'components/Collections' + +const Home = () => { + return ( + + + {siteTitle} + + + + + + + + + ) +} + +export default Home diff --git a/examples/with-unsplash/public/favicon.ico b/examples/with-unsplash/public/favicon.ico new file mode 100644 index 0000000000000..4965832f2c9b0 Binary files /dev/null and b/examples/with-unsplash/public/favicon.ico differ diff --git a/examples/with-unsplash/public/images/download.svg b/examples/with-unsplash/public/images/download.svg new file mode 100644 index 0000000000000..27fae28cd3f9e --- /dev/null +++ b/examples/with-unsplash/public/images/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/with-unsplash/public/images/instagram.svg b/examples/with-unsplash/public/images/instagram.svg new file mode 100644 index 0000000000000..e0b8ffd7e21ff --- /dev/null +++ b/examples/with-unsplash/public/images/instagram.svg @@ -0,0 +1 @@ +Instagram icon \ No newline at end of file diff --git a/examples/with-unsplash/public/images/twitter.svg b/examples/with-unsplash/public/images/twitter.svg new file mode 100644 index 0000000000000..af5b7c341989f --- /dev/null +++ b/examples/with-unsplash/public/images/twitter.svg @@ -0,0 +1 @@ +Twitter icon \ No newline at end of file diff --git a/examples/with-unsplash/public/images/unsplash.svg b/examples/with-unsplash/public/images/unsplash.svg new file mode 100644 index 0000000000000..c0c9e55227757 --- /dev/null +++ b/examples/with-unsplash/public/images/unsplash.svg @@ -0,0 +1 @@ +Unsplash icon \ No newline at end of file diff --git a/examples/with-unsplash/styles/global.css b/examples/with-unsplash/styles/global.css new file mode 100644 index 0000000000000..d42f3581006e3 --- /dev/null +++ b/examples/with-unsplash/styles/global.css @@ -0,0 +1,30 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.6; + font-size: 18px; + background: #fff; +} + +* { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +img { + max-width: 100%; + display: block; +} diff --git a/examples/with-storybook-typescript/tsconfig.json b/examples/with-unsplash/tsconfig.json similarity index 96% rename from examples/with-storybook-typescript/tsconfig.json rename to examples/with-unsplash/tsconfig.json index c5d53d8983d19..d266484f876de 100644 --- a/examples/with-storybook-typescript/tsconfig.json +++ b/examples/with-unsplash/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": ".", "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/lerna.json b/lerna.json index 45c4fb81eae3b..e88cb15d77dfa 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "9.5.2-canary.10" + "version": "9.5.3-canary.20" } diff --git a/package.json b/package.json index 8f8e504e3c95b..46828ae51ac5d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "testsafari": "cross-env BROWSER_NAME=safari yarn testonly", "testfirefox": "cross-env BROWSER_NAME=firefox yarn testonly", "testie": "cross-env BROWSER_NAME=\"internet explorer\" yarn testonly", - "testall": "yarn run testonly -- --ci --forceExit", + "testall": "yarn run testonly -- --ci --forceExit && lerna run --scope @next/codemod test", "genstats": "cross-env LOCAL_STATS=true node .github/actions/next-stats-action/src/index.js", "pretest": "yarn run lint", "git-reset": "git reset --hard HEAD", @@ -112,6 +112,7 @@ "release": "6.3.0", "request-promise-core": "1.1.2", "rimraf": "2.6.3", + "seedrandom": "3.0.5", "selenium-standalone": "6.18.0", "selenium-webdriver": "4.0.0-alpha.7", "shell-quote": "1.7.2", diff --git a/packages/create-next-app/create-app.ts b/packages/create-next-app/create-app.ts index a180c9f631ec5..2db10c89c0756 100644 --- a/packages/create-next-app/create-app.ts +++ b/packages/create-next-app/create-app.ts @@ -19,6 +19,7 @@ import { install } from './helpers/install' import { isFolderEmpty } from './helpers/is-folder-empty' import { getOnline } from './helpers/is-online' import { shouldUseYarn } from './helpers/should-use-yarn' +import { isWriteable } from './helpers/is-writeable' export class DownloadError extends Error {} @@ -93,6 +94,17 @@ export async function createApp({ } const root = path.resolve(appPath) + + if (!(await isWriteable(path.dirname(root)))) { + console.error( + 'The application path is not writable, please check folder permissions and try again.' + ) + console.error( + 'It is likely you do not have write permissions for this folder.' + ) + process.exit(1) + } + const appName = path.basename(root) await makeDir(root) diff --git a/packages/create-next-app/helpers/is-writeable.ts b/packages/create-next-app/helpers/is-writeable.ts new file mode 100644 index 0000000000000..0b9e9abb4f0fe --- /dev/null +++ b/packages/create-next-app/helpers/is-writeable.ts @@ -0,0 +1,10 @@ +import fs from 'fs' + +export async function isWriteable(directory: string): Promise { + try { + await fs.promises.access(directory, (fs.constants || fs).W_OK) + return true + } catch (err) { + return false + } +} diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index ccd48dba0a6e3..a79c458755614 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/lib/index.js b/packages/eslint-plugin-next/lib/index.js index 876aa2e91630e..07501954c2ef8 100644 --- a/packages/eslint-plugin-next/lib/index.js +++ b/packages/eslint-plugin-next/lib/index.js @@ -4,6 +4,7 @@ module.exports = { 'no-sync-scripts': require('./rules/no-sync-scripts'), 'no-html-link-for-pages': require('./rules/no-html-link-for-pages'), 'no-unwanted-polyfillio': require('./rules/no-unwanted-polyfillio'), + 'missing-preload': require('./rules/missing-preload'), }, configs: { recommended: { @@ -13,6 +14,7 @@ module.exports = { '@next/next/no-sync-scripts': 1, '@next/next/no-html-link-for-pages': 1, '@next/next/no-unwanted-polyfillio': 1, + '@next/next/missing-preload': 1, }, }, }, diff --git a/packages/eslint-plugin-next/lib/rules/missing-preload.js b/packages/eslint-plugin-next/lib/rules/missing-preload.js new file mode 100644 index 0000000000000..55c58f63f3641 --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/missing-preload.js @@ -0,0 +1,57 @@ +module.exports = { + meta: { + type: 'suggestion', + fixable: 'code', + docs: { + description: 'Ensure stylesheets are preloaded', + category: 'Optimizations', + recommended: true, + }, + }, + create: function (context) { + const preloads = new Set() + const links = new Map() + + return { + 'Program:exit': function (node) { + for (let [href, linkNode] of links.entries()) { + if (!preloads.has(href)) { + context.report({ + node: linkNode, + message: + 'Stylesheet does not have an associated preload tag. This could potentially impact First paint.', + fix: function (fixer) { + return fixer.insertTextBefore( + linkNode, + `` + ) + }, + }) + } + } + + links.clear() + preloads.clear() + }, + 'JSXOpeningElement[name.name=link][attributes.length>0]': function ( + node + ) { + const attributes = node.attributes.filter( + (attr) => attr.type === 'JSXAttribute' + ) + const rel = attributes.find((attr) => attr.name.name === 'rel') + const relValue = rel && rel.value.value + const href = attributes.find((attr) => attr.name.name === 'href') + const hrefValue = href && href.value.value + const media = attributes.find((attr) => attr.name.name === 'media') + const mediaValue = media && media.value.value + + if (relValue === 'preload') { + preloads.add(hrefValue) + } else if (relValue === 'stylesheet' && mediaValue !== 'print') { + links.set(hrefValue, node) + } + }, + } + }, +} diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 0bab67c320680..b2b7f7c76dcff 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "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 9a7bcffd7008d..62d53ada27dc3 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-bundle-analyzer/readme.md b/packages/next-bundle-analyzer/readme.md index 5ddf2b8f08275..1436cb23c8030 100644 --- a/packages/next-bundle-analyzer/readme.md +++ b/packages/next-bundle-analyzer/readme.md @@ -41,3 +41,18 @@ ANALYZE=true yarn build ``` When enabled two HTML files (client.html and server.html) will be outputted to `/analyze/`. One will be for the server bundle, one for the browser bundle. + +### Usage with next-compose-plugins + +From version 2.0.0 of next-compose-plugins you need to call bundle-analyzer in this way to work + +```js +const withPlugins = require('next-compose-plugins') +const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: process.env.ANALYZE === 'true', +}) +module.exports = withPlugins([ + [withBundleAnalyzer({})], + // your other plugins here +]) +``` diff --git a/packages/next-codemod/.gitignore b/packages/next-codemod/.gitignore new file mode 100644 index 0000000000000..5eadbe5734c45 --- /dev/null +++ b/packages/next-codemod/.gitignore @@ -0,0 +1,5 @@ +*.d.ts +*.js +*.js.map +!transforms/__tests__/**/*.js +!transforms/__testfixtures__/**/*.js \ No newline at end of file diff --git a/packages/next-codemod/README.md b/packages/next-codemod/README.md new file mode 100644 index 0000000000000..ce458da7e90c7 --- /dev/null +++ b/packages/next-codemod/README.md @@ -0,0 +1,9 @@ +# Next.js Codemods + +Next.js provides Codemod transformations to help upgrade your Next.js codebase when a feature is deprecated. + +Codemods are transformations that run on your codebase programmatically. This allows for a large amount of changes to be applied without having to manually go through every file. + +## Documentation + +Visit [nextjs.org/docs/advanced-features/codemods](https://nextjs.org/docs/advanced-features/codemods) to view the documentation for this package. diff --git a/packages/next-codemod/bin/cli.ts b/packages/next-codemod/bin/cli.ts new file mode 100644 index 0000000000000..c3552bc79740e --- /dev/null +++ b/packages/next-codemod/bin/cli.ts @@ -0,0 +1,208 @@ +/** + * Copyright 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/cli.js +// @next/codemod optional-name-of-transform optional/path/to/src [...options] + +const globby = require('globby') +const inquirer = require('inquirer') +const meow = require('meow') +const path = require('path') +const execa = require('execa') +const chalk = require('chalk') +const isGitClean = require('is-git-clean') + +const transformerDirectory = path.join(__dirname, '../', 'transforms') +const jscodeshiftExecutable = require.resolve('.bin/jscodeshift') + +function checkGitStatus(force) { + let clean = false + let errorMessage = 'Unable to determine if git directory is clean' + try { + clean = isGitClean.sync(process.cwd()) + errorMessage = 'Git directory is not clean' + } catch (err) { + if (err && err.stderr && err.stderr.indexOf('Not a git repository') >= 0) { + clean = true + } + } + + if (!clean) { + if (force) { + console.log(`WARNING: ${errorMessage}. Forcibly continuing.`) + } else { + console.log('Thank you for using @next/codemod!') + console.log( + chalk.yellow( + '\nBut before we continue, please stash or commit your git changes.' + ) + ) + console.log( + '\nYou may use the --force flag to override this safety check.' + ) + process.exit(1) + } + } +} + +function runTransform({ files, flags, transformer }) { + const transformerPath = path.join(transformerDirectory, `${transformer}.js`) + + let args = [] + + const { dry, print } = flags + + if (dry) { + args.push('--dry') + } + if (print) { + args.push('--print') + } + + args.push('--verbose=2') + + args.push('--ignore-pattern=**/node_modules/**') + args.push('--ignore-pattern=**/.next/**') + + args.push('--extensions=tsx,ts,jsx,js') + args.push('--parser=tsx') + + args = args.concat(['--transform', transformerPath]) + + if (flags.jscodeshift) { + args = args.concat(flags.jscodeshift) + } + + args = args.concat(files) + + console.log(`Executing command: jscodeshift ${args.join(' ')}`) + + const result = execa.sync(jscodeshiftExecutable, args, { + stdio: 'inherit', + stripEof: false, + }) + + if (result.error) { + throw result.error + } +} + +const TRANSFORMER_INQUIRER_CHOICES = [ + { + name: + 'name-default-component: Transforms anonymous components into named components to make sure they work with Fast Refresh', + value: 'name-default-component', + }, + { + name: + 'withamp-to-config: Transforms the withAmp HOC into Next.js 9 page configuration', + value: 'withamp-to-config', + }, + { + name: + 'url-to-withrouter: Transforms the deprecated automatically injected url property on top level pages to using withRouter', + value: 'url-to-withrouter', + }, +] + +function expandFilePathsIfNeeded(filesBeforeExpansion) { + const shouldExpandFiles = filesBeforeExpansion.some((file) => + file.includes('*') + ) + return shouldExpandFiles + ? globby.sync(filesBeforeExpansion) + : filesBeforeExpansion +} + +function run() { + const cli = meow( + { + description: 'Codemods for updating Next.js apps.', + help: ` + Usage + $ npx @next/codemod <...options> + transform One of the choices from https://github.com/vercel/next.js/tree/canary/packages/next-codemod + path Files or directory to transform. Can be a glob like pages/**.js + Options + --force Bypass Git safety checks and forcibly run codemods + --dry Dry run (no changes are made to files) + --print Print transformed files to your terminal + --jscodeshift (Advanced) Pass options directly to jscodeshift + `, + }, + { + boolean: ['force', 'dry', 'print', 'help'], + string: ['_'], + alias: { + h: 'help', + }, + } + ) + + if (!cli.flags.dry) { + checkGitStatus(cli.flags.force) + } + + if ( + cli.input[0] && + !TRANSFORMER_INQUIRER_CHOICES.find((x) => x.value === cli.input[0]) + ) { + console.error('Invalid transform choice, pick one of:') + console.error( + TRANSFORMER_INQUIRER_CHOICES.map((x) => '- ' + x.value).join('\n') + ) + process.exit(1) + } + + inquirer + .prompt([ + { + type: 'input', + name: 'files', + message: 'On which files or directory should the codemods be applied?', + when: !cli.input[1], + default: '.', + // validate: () => + filter: (files) => files.trim(), + }, + { + type: 'list', + name: 'transformer', + message: 'Which transform would you like to apply?', + when: !cli.input[0], + pageSize: TRANSFORMER_INQUIRER_CHOICES.length, + choices: TRANSFORMER_INQUIRER_CHOICES, + }, + ]) + .then((answers) => { + const { files, transformer } = answers + + const filesBeforeExpansion = cli.input[1] || files + const filesExpanded = expandFilePathsIfNeeded([filesBeforeExpansion]) + + const selectedTransformer = cli.input[0] || transformer + + if (!filesExpanded.length) { + console.log(`No files found matching ${filesBeforeExpansion.join(' ')}`) + return null + } + + return runTransform({ + files: filesExpanded, + flags: cli.flags, + transformer: selectedTransformer, + }) + }) +} + +module.exports = { + run: run, + runTransform: runTransform, + checkGitStatus: checkGitStatus, + jscodeshiftExecutable: jscodeshiftExecutable, + transformerDirectory: transformerDirectory, +} diff --git a/packages/next-codemod/bin/next-codemod.ts b/packages/next-codemod/bin/next-codemod.ts new file mode 100644 index 0000000000000..8268a4de769b0 --- /dev/null +++ b/packages/next-codemod/bin/next-codemod.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +/** + * Copyright 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/react-codemod.js +// next-codemod optional-name-of-transform optional/path/to/src [...options] + +require('./cli').run() diff --git a/packages/next-codemod/license.md b/packages/next-codemod/license.md new file mode 100644 index 0000000000000..fa5d39b6213f8 --- /dev/null +++ b/packages/next-codemod/license.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json new file mode 100644 index 0000000000000..5c7df344fbd14 --- /dev/null +++ b/packages/next-codemod/package.json @@ -0,0 +1,24 @@ +{ + "name": "@next/codemod", + "version": "9.5.3-canary.20", + "license": "MIT", + "dependencies": { + "chalk": "4.1.0", + "execa": "4.0.3", + "globby": "11.0.1", + "inquirer": "7.3.3", + "is-git-clean": "1.1.0", + "jscodeshift": "^0.6.4", + "meow": "7.0.1" + }, + "files": [ + "transforms/*.js", + "bin/*.js" + ], + "scripts": { + "prepublish": "yarn tsc -d -p tsconfig.json", + "build": "yarn tsc -d -w -p tsconfig.json", + "test": "jest" + }, + "bin": "./bin/next-codemod.js" +} diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/1-starts-with-number.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/1-starts-with-number.input.js new file mode 100644 index 0000000000000..026e0bc4e2420 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/1-starts-with-number.input.js @@ -0,0 +1 @@ +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/1-starts-with-number.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/1-starts-with-number.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.input.js new file mode 100644 index 0000000000000..c09b1109d0dda --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.input.js @@ -0,0 +1,11 @@ +class ExistingName2Input { + render() {} +} + +class nested { + render() { + const ExistingName2InputComponent = null; + } +} + +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.output.js new file mode 100644 index 0000000000000..84ddb2d3ca6a4 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-2.output.js @@ -0,0 +1,13 @@ +class ExistingName2Input { + render() {} +} + +class nested { + render() { + const ExistingName2InputComponent = null; + } +} + +const ExistingName2InputComponent = () =>
    Anonymous function
    ; + +export default ExistingName2InputComponent; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.input.js new file mode 100644 index 0000000000000..4c4f34eff64b6 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.input.js @@ -0,0 +1,7 @@ +function ExistingName3Input() {} + +function nested() { + const ExistingName3InputComponent = null; +} + +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.output.js new file mode 100644 index 0000000000000..4fb06fa35d34b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-3.output.js @@ -0,0 +1,9 @@ +function ExistingName3Input() {} + +function nested() { + const ExistingName3InputComponent = null; +} + +const ExistingName3InputComponent = () =>
    Anonymous function
    ; + +export default ExistingName3InputComponent; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-ignore.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-ignore.input.js new file mode 100644 index 0000000000000..6a3ad640958ee --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-ignore.input.js @@ -0,0 +1,4 @@ +const ExistingNameIgnoreInput = null; +const ExistingNameIgnoreInputComponent = null; + +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-ignore.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name-ignore.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.input.js new file mode 100644 index 0000000000000..53c4840edbc15 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.input.js @@ -0,0 +1,7 @@ +const ExistingNameInput = null; + +function nested() { + const ExistingNameInputComponent = null; +} + +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.output.js new file mode 100644 index 0000000000000..3725f53ad203c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/existing-name.output.js @@ -0,0 +1,9 @@ +const ExistingNameInput = null; + +function nested() { + const ExistingNameInputComponent = null; +} + +const ExistingNameInputComponent = () =>
    Anonymous function
    ; + +export default ExistingNameInputComponent; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.input.js new file mode 100644 index 0000000000000..026e0bc4e2420 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.input.js @@ -0,0 +1 @@ +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.output.js new file mode 100644 index 0000000000000..b5ba56fdce3b5 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-2.output.js @@ -0,0 +1,2 @@ +const FunctionComponent2Input = () =>
    Anonymous function
    ; +export default FunctionComponent2Input; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-ignore.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-ignore.input.js new file mode 100644 index 0000000000000..32ea5ec533710 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-ignore.input.js @@ -0,0 +1,7 @@ +export default () => { + const x = 'y'; + if (true) { + return ''; + } + return null; +}; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-ignore.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component-ignore.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.input.js new file mode 100644 index 0000000000000..35898a0fab67b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.input.js @@ -0,0 +1,7 @@ +export default () => { + const x = 'y'; + if (true) { + return
    Anonymous function
    ; + } + return null; +}; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.output.js new file mode 100644 index 0000000000000..58fc251967425 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-component.output.js @@ -0,0 +1,9 @@ +const FunctionComponentInput = () => { + const x = 'y'; + if (true) { + return
    Anonymous function
    ; + } + return null; +}; + +export default FunctionComponentInput; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression-ignore.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression-ignore.input.js new file mode 100644 index 0000000000000..abad3a2b3d868 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression-ignore.input.js @@ -0,0 +1,7 @@ +export default function Name() { + const x = 'y'; + if (true) { + return
    Anonymous function
    ; + } + return null; +} diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression-ignore.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression-ignore.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.input.js new file mode 100644 index 0000000000000..dfccf3900c48f --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.input.js @@ -0,0 +1,7 @@ +export default function () { + const x = 'y'; + if (true) { + return
    Anonymous function
    ; + } + return null; +} diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.output.js new file mode 100644 index 0000000000000..6a6e7381e71af --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/function-expression.output.js @@ -0,0 +1,7 @@ +export default function FunctionExpressionInput() { + const x = 'y'; + if (true) { + return
    Anonymous function
    ; + } + return null; +} diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/special-ch@racter.input.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/special-ch@racter.input.js new file mode 100644 index 0000000000000..026e0bc4e2420 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/name-default-component/special-ch@racter.input.js @@ -0,0 +1 @@ +export default () =>
    Anonymous function
    ; diff --git a/packages/next-codemod/transforms/__testfixtures__/name-default-component/special-ch@racter.output.js b/packages/next-codemod/transforms/__testfixtures__/name-default-component/special-ch@racter.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.input.js new file mode 100644 index 0000000000000..7f50651bece2c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.input.js @@ -0,0 +1,7 @@ +import {withRouter} from 'next/router' + +export default withRouter(class extends React.Component { + render() { + const test = this.props.url + } +}) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.output.js new file mode 100644 index 0000000000000..9d40a584db745 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/already-using-withrouter.output.js @@ -0,0 +1,7 @@ +import {withRouter} from 'next/router' + +export default withRouter(class extends React.Component { + render() { + const test = this.props.router + } +}) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.input.js new file mode 100644 index 0000000000000..271218c108ea5 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.input.js @@ -0,0 +1,3 @@ +export default withAppContainer(withAuth(props => { + const test = props.url +})) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.output.js new file mode 100644 index 0000000000000..4fb38acd7ca12 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/arrow-function-component.output.js @@ -0,0 +1,5 @@ +import { withRouter } from "next/router"; + +export default withRouter(withAppContainer(withAuth(props => { + const test = props.router +}))); \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.input.js new file mode 100644 index 0000000000000..16ec4c102d8c6 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.input.js @@ -0,0 +1,7 @@ +export default class extends React.Component { + componentDidUpdate(prevProps) { + if (prevProps.url.query.f !== this.props.router.query.f) { + const test = this.props.url + } + } +} diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.output.js new file mode 100644 index 0000000000000..44ab887c0c698 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentdidupdate.output.js @@ -0,0 +1,9 @@ +import { withRouter } from "next/router"; + +export default withRouter(class extends React.Component { + componentDidUpdate(prevProps) { + if (prevProps.router.query.f !== this.props.router.query.f) { + const test = this.props.router + } + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.input.js new file mode 100644 index 0000000000000..f54c76f76db47 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.input.js @@ -0,0 +1,7 @@ +export default class extends React.Component { + componentWillReceiveProps(nextProps) { + if (this.props.url.query !== nextProps.url.query) { + const test = this.props.url + } + } +} diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.output.js new file mode 100644 index 0000000000000..d01741d953103 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/componentwillreceiveprops.output.js @@ -0,0 +1,9 @@ +import { withRouter } from "next/router"; + +export default withRouter(class extends React.Component { + componentWillReceiveProps(nextProps) { + if (this.props.router.query !== nextProps.router.query) { + const test = this.props.router + } + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.input.js new file mode 100644 index 0000000000000..bf1827d95fb60 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.input.js @@ -0,0 +1,7 @@ +export default class Something extends React.Component { + render() { + const {props, stats} = this + + const test = props.url + } +} diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.output.js new file mode 100644 index 0000000000000..c305ef33ce007 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-class.output.js @@ -0,0 +1,9 @@ +import { withRouter } from "next/router"; + +export default withRouter(class Something extends React.Component { + render() { + const {props, stats} = this + + const test = props.router + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.input.js new file mode 100644 index 0000000000000..bddad56b38538 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.input.js @@ -0,0 +1,23 @@ +export default withAppContainer( + withAuth( + class BuyDomains extends React.Component { + render() { + const { url } = this.props + + return ( + +
    { + onUser(null) + url.push('/login') + }} + onLogoRightClick={() => url.push('/logos')} + /> + + ) + } + } + ) +) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.output.js new file mode 100644 index 0000000000000..8ffab0b1e6f23 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props-nested.output.js @@ -0,0 +1,23 @@ +import { withRouter } from "next/router"; + +export default withRouter(withAppContainer(withAuth( + class BuyDomains extends React.Component { + render() { + const { router } = this.props + + return ( + +
    { + onUser(null) + router.push('/login') + }} + onLogoRightClick={() => router.push('/logos')} + /> + + ); + } + } +))); \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.input.js new file mode 100644 index 0000000000000..576a9065126cb --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.input.js @@ -0,0 +1,26 @@ +class AddonsPage extends React.Component { + render() { + const { + url + } = this.props + return ( + +
    onUser(null)} + onLogoRightClick={() => Router.push('/logos')} + /> + + + ) + } +} + +export default withAppContainer(withAuthRequired(withError(AddonsPage))) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.output.js new file mode 100644 index 0000000000000..2bc886769b95c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this-props.output.js @@ -0,0 +1,27 @@ +import { withRouter } from "next/router"; +class AddonsPage extends React.Component { + render() { + const { + router + } = this.props + return ( + +
    onUser(null)} + onLogoRightClick={() => Router.push('/logos')} + /> + + + ); + } +} + +export default withRouter(withAppContainer(withAuthRequired(withError(AddonsPage)))); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.input.js new file mode 100644 index 0000000000000..08f635ac0cccb --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.input.js @@ -0,0 +1,7 @@ +export default withApp(withAuth(class Something extends React.Component { + render() { + const {props, stats} = this + + const test = props.url + } +})) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.output.js new file mode 100644 index 0000000000000..85c4a0204534c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/destructuring-this.output.js @@ -0,0 +1,9 @@ +import { withRouter } from "next/router"; + +export default withRouter(withApp(withAuth(class Something extends React.Component { + render() { + const {props, stats} = this + + const test = props.router + } +}))); \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.input.js new file mode 100644 index 0000000000000..e424bc4bffe7c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.input.js @@ -0,0 +1,7 @@ +class Test extends React.Component { + render() { + const test = this.props.url + } +} + +export default wrappingFunction(Test) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.output.js new file mode 100644 index 0000000000000..77922797d2cfb --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable-wrapping.output.js @@ -0,0 +1,8 @@ +import { withRouter } from "next/router"; +class Test extends React.Component { + render() { + const test = this.props.router + } +} + +export default withRouter(wrappingFunction(Test)); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.input.js new file mode 100644 index 0000000000000..9d1dda768ac2f --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.input.js @@ -0,0 +1,7 @@ +class Test extends React.Component { + render() { + const test = this.props.url + } +} + +export default Test diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.output.js new file mode 100644 index 0000000000000..6a358340e677b --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/export-default-variable.output.js @@ -0,0 +1,8 @@ +import { withRouter } from "next/router"; +class Test extends React.Component { + render() { + const test = this.props.router + } +} + +export default withRouter(Test); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.input.js new file mode 100644 index 0000000000000..7dedddc88994a --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.input.js @@ -0,0 +1,26 @@ +class Plan extends React.Component { + render() { + const { url} = this.props + + return ( + +
    onUser(null)} + onLogoRightClick={() => Router.push('/logos')} + /> + + + + ) + } +} + +export default withAppContainer(withAuthRequired(Plan, 'signup')) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.output.js new file mode 100644 index 0000000000000..5ebb27eb27338 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/first-parameter-hoc.output.js @@ -0,0 +1,27 @@ +import { withRouter } from "next/router"; +class Plan extends React.Component { + render() { + const { router} = this.props + + return ( + +
    onUser(null)} + onLogoRightClick={() => Router.push('/logos')} + /> + + + + ); + } +} + +export default withRouter(withAppContainer(withAuthRequired(Plan, 'signup'))); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.input.js new file mode 100644 index 0000000000000..f7fba3acef681 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.input.js @@ -0,0 +1,9 @@ +export default withAppContainer( + withAuth( + class BuyDomains extends React.Component { + something = ({url}) => { + + } + } + ) +) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.output.js new file mode 100644 index 0000000000000..f7fba3acef681 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform-method.output.js @@ -0,0 +1,9 @@ +export default withAppContainer( + withAuth( + class BuyDomains extends React.Component { + something = ({url}) => { + + } + } + ) +) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.input.js new file mode 100644 index 0000000000000..5bd292441d893 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.input.js @@ -0,0 +1 @@ +export default class extends React.Component {} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.output.js new file mode 100644 index 0000000000000..5bd292441d893 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/no-transform.output.js @@ -0,0 +1 @@ +export default class extends React.Component {} \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.input.js new file mode 100644 index 0000000000000..96e7d77e7970e --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.input.js @@ -0,0 +1,11 @@ +const examples = [{ name: 'ex1', url: 'https://google.fr/' }] + +export default () => ( +
    + {examples.map(example => ( +
    + {example.name} - {example.url} +
    + ))} +
    +) \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.output.js new file mode 100644 index 0000000000000..96e7d77e7970e --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/url-property-not-part-of-this-props.output.js @@ -0,0 +1,11 @@ +const examples = [{ name: 'ex1', url: 'https://google.fr/' }] + +export default () => ( +
    + {examples.map(example => ( +
    + {example.name} - {example.url} +
    + ))} +
    +) \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.input.js new file mode 100644 index 0000000000000..a401330d0c62a --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.input.js @@ -0,0 +1,5 @@ +export default (class extends React.Component { + render() { + const test = this.props.url + } +}) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.output.js new file mode 100644 index 0000000000000..e5887145d0eb4 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/using-inline-class.output.js @@ -0,0 +1,7 @@ +import { withRouter } from "next/router"; + +export default withRouter(class extends React.Component { + render() { + const test = this.props.router + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.input.js new file mode 100644 index 0000000000000..7f3d171adb91d --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.input.js @@ -0,0 +1,7 @@ +const Test = class extends React.Component { + render() { + const test = this.props.url + } +} + +export default abc(wrappingFunction(Test)) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.output.js new file mode 100644 index 0000000000000..9d5f37a1ede69 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/variable-export.output.js @@ -0,0 +1,8 @@ +import { withRouter } from "next/router"; +const Test = class extends React.Component { + render() { + const test = this.props.router + } +} + +export default withRouter(abc(wrappingFunction(Test))); \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.input.js new file mode 100644 index 0000000000000..14b733830c37d --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.input.js @@ -0,0 +1,22 @@ +export default withAppContainer( + withAuth( + class Blog extends React.Component { + render() { + const { props, state } = this + + return ( +
    { + props.onUser(null) + props.url.push('/login') + }} + onLogoRightClick={() => props.url.push('/logos')} + /> + ) + } + } + ) +) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.output.js new file mode 100644 index 0000000000000..577c51ff7db2d --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-nested-arrow-function.output.js @@ -0,0 +1,22 @@ +import { withRouter } from "next/router"; + +export default withRouter(withAppContainer(withAuth( + class Blog extends React.Component { + render() { + const { props, state } = this + + return ( +
    { + props.onUser(null) + props.router.push('/login') + }} + onLogoRightClick={() => props.router.push('/logos')} + /> + ); + } + } +))); \ No newline at end of file diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.input.js new file mode 100644 index 0000000000000..dca33d63c0614 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.input.js @@ -0,0 +1,7 @@ +import Router from 'next/router' + +export default class extends React.Component { + render() { + const test = this.props.url + } +} diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.output.js new file mode 100644 index 0000000000000..8dc44f19e8319 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/with-router-import.output.js @@ -0,0 +1,7 @@ +import Router, { withRouter } from 'next/router'; + +export default withRouter(class extends React.Component { + render() { + const test = this.props.router + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.input.js new file mode 100644 index 0000000000000..2886da41279ba --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.input.js @@ -0,0 +1,5 @@ +export default class extends React.Component { + render() { + const test = this.props.url + } +} diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.output.js new file mode 100644 index 0000000000000..e5887145d0eb4 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/without-import.output.js @@ -0,0 +1,7 @@ +import { withRouter } from "next/router"; + +export default withRouter(class extends React.Component { + render() { + const test = this.props.router + } +}); diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.input.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.input.js new file mode 100644 index 0000000000000..c4ee2580b4874 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.input.js @@ -0,0 +1,5 @@ +export default withSomethingElse(class extends React.Component { + render() { + const test = this.props.url + } +}) diff --git a/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.output.js b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.output.js new file mode 100644 index 0000000000000..c20747a0ddb58 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/url-to-withrouter/wrapping-export.output.js @@ -0,0 +1,7 @@ +import { withRouter } from "next/router"; + +export default withRouter(withSomethingElse(class extends React.Component { + render() { + const test = this.props.router + } +})); diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.input.js new file mode 100644 index 0000000000000..8ee8286d0550a --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.input.js @@ -0,0 +1,5 @@ +import { withAmp, withAmp as alternative } from 'next/amp' + +export default alternative(function Home() { + return

    My AMP Page

    +}) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.output.js new file mode 100644 index 0000000000000..1cabea829471f --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-inline.output.js @@ -0,0 +1,7 @@ +export default function Home() { + return

    My AMP Page

    +}; + +export const config = { + amp: true +}; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.input.js new file mode 100644 index 0000000000000..4c5eaf2ed30b5 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.input.js @@ -0,0 +1,12 @@ +import { withAmp } from 'next/amp' + +function Home() { + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', + amp: false +} + +export default withAmp(Home) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.output.js new file mode 100644 index 0000000000000..27b478ca8ea02 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-dupe.output.js @@ -0,0 +1,10 @@ +function Home() { + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', + amp: true +} + +export default Home; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.input.js new file mode 100644 index 0000000000000..a421ce85c10f0 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.input.js @@ -0,0 +1,14 @@ +import { withAmp } from 'next/amp' + +function Home() { + const config = {} + return

    My AMP Page

    +} + +const config = { + foo: 'bar', +} + +export default withAmp(Home) + +export { config } diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.output.js new file mode 100644 index 0000000000000..d80bfcfc7ffd2 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config-var.output.js @@ -0,0 +1,13 @@ +function Home() { + const config = {} + return

    My AMP Page

    +} + +const config = { + foo: 'bar', + amp: true +} + +export default Home; + +export { config } diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.input.js new file mode 100644 index 0000000000000..d98d63a4e2147 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.input.js @@ -0,0 +1,12 @@ +import { withAmp, useAmp } from 'next/amp' + +function Home() { + const config = {} + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', +} + +export default withAmp(Home) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.output.js new file mode 100644 index 0000000000000..1cf6895e61e61 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp-with-config.output.js @@ -0,0 +1,13 @@ +import { useAmp } from 'next/amp'; + +function Home() { + const config = {} + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', + amp: true +} + +export default Home; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.input.js new file mode 100644 index 0000000000000..a9b21f59ba39c --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.input.js @@ -0,0 +1,8 @@ +import { withAmp } from 'next/amp' + +function Home() { + const config = {} + return

    My AMP Page

    +} + +export default withAmp(Home) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.output.js new file mode 100644 index 0000000000000..cbd7c4690fb48 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/full-amp.output.js @@ -0,0 +1,10 @@ +function Home() { + const config = {} + return

    My AMP Page

    +} + +export default Home; + +export const config = { + amp: true +}; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.input.js new file mode 100644 index 0000000000000..e28aad5f862c7 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.input.js @@ -0,0 +1,12 @@ +import { withAmp } from 'next/amp' + +function Home() { + const config = {} + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', +} + +export default withAmp(Home, { hybrid: true }) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.output.js new file mode 100644 index 0000000000000..901ed4d09a5ef --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp-with-config.output.js @@ -0,0 +1,11 @@ +function Home() { + const config = {} + return

    My AMP Page

    +} + +export const config = { + foo: 'bar', + amp: "hybrid" +} + +export default Home; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.input.js new file mode 100644 index 0000000000000..6b70454c97a25 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.input.js @@ -0,0 +1,8 @@ +import { withAmp } from 'next/amp' + +function Home() { + const config = {} + return

    My AMP Page

    +} + +export default withAmp(Home, { hybrid: true }) diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.output.js new file mode 100644 index 0000000000000..85e2d7e53c8b9 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/hybrid-amp.output.js @@ -0,0 +1,10 @@ +function Home() { + const config = {} + return

    My AMP Page

    +} + +export default Home; + +export const config = { + amp: "hybrid" +}; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-renamed.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-renamed.input.js new file mode 100644 index 0000000000000..36df14997eb86 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-renamed.input.js @@ -0,0 +1 @@ +import { withAmp as apples } from 'next/amp' diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-renamed.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-renamed.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.input.js new file mode 100644 index 0000000000000..99c6e3837baeb --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.input.js @@ -0,0 +1 @@ +import { withAmp, useAmp } from 'next/amp' diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.output.js new file mode 100644 index 0000000000000..1a971e2d0feb8 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import-single.output.js @@ -0,0 +1 @@ +import { useAmp } from 'next/amp'; diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import.input.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import.input.js new file mode 100644 index 0000000000000..687189ee59f73 --- /dev/null +++ b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import.input.js @@ -0,0 +1 @@ +import { withAmp } from 'next/amp' diff --git a/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import.output.js b/packages/next-codemod/transforms/__testfixtures__/withamp-to-config/remove-import.output.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/next-codemod/transforms/__tests__/name-default-component-test.js b/packages/next-codemod/transforms/__tests__/name-default-component-test.js new file mode 100644 index 0000000000000..07c32fc636239 --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/name-default-component-test.js @@ -0,0 +1,27 @@ +/* global jest */ +jest.autoMockOff() + +const defineTest = require('jscodeshift/dist/testUtils').defineTest + +const fixtures = [ + 'function-component', + 'function-component-2', + 'function-component-ignore', + 'function-expression', + 'function-expression-ignore', + 'existing-name', + 'existing-name-2', + 'existing-name-3', + 'existing-name-ignore', + '1-starts-with-number', + 'special-ch@racter', +] + +fixtures.forEach((test) => + defineTest( + __dirname, + 'name-default-component', + null, + `name-default-component/${test}` + ) +) diff --git a/packages/next-codemod/transforms/__tests__/url-to-withrouter.test.js b/packages/next-codemod/transforms/__tests__/url-to-withrouter.test.js new file mode 100644 index 0000000000000..afd2cc8271d64 --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/url-to-withrouter.test.js @@ -0,0 +1,35 @@ +/* global jest */ +jest.autoMockOff() +const defineTest = require('jscodeshift/dist/testUtils').defineTest + +const fixtures = [ + 'with-router-import', + 'without-import', + 'already-using-withrouter', + 'using-inline-class', + 'export-default-variable', + 'export-default-variable-wrapping', + 'no-transform', + 'no-transform-method', + 'wrapping-export', + 'variable-export', + 'arrow-function-component', + 'destructuring-this', + 'destructuring-this-class', + 'destructuring-this-props', + 'destructuring-this-props-nested', + 'with-nested-arrow-function', + 'componentdidupdate', + 'componentwillreceiveprops', + 'first-parameter-hoc', + 'url-property-not-part-of-this-props', +] + +for (const fixture of fixtures) { + defineTest( + __dirname, + 'url-to-withrouter', + null, + `url-to-withrouter/${fixture}` + ) +} diff --git a/packages/next-codemod/transforms/__tests__/withamp-to-config.test.js b/packages/next-codemod/transforms/__tests__/withamp-to-config.test.js new file mode 100644 index 0000000000000..5229b56dfd46b --- /dev/null +++ b/packages/next-codemod/transforms/__tests__/withamp-to-config.test.js @@ -0,0 +1,25 @@ +/* global jest */ +jest.autoMockOff() +const defineTest = require('jscodeshift/dist/testUtils').defineTest + +const fixtures = [ + 'remove-import', + 'remove-import-renamed', + 'remove-import-single', + 'full-amp', + 'full-amp-inline', + 'full-amp-with-config', + 'full-amp-with-config-dupe', + 'full-amp-with-config-var', + 'hybrid-amp', + 'hybrid-amp-with-config', +] + +for (const fixture of fixtures) { + defineTest( + __dirname, + 'withamp-to-config', + null, + `withamp-to-config/${fixture}` + ) +} diff --git a/packages/next-codemod/transforms/name-default-component.ts b/packages/next-codemod/transforms/name-default-component.ts new file mode 100644 index 0000000000000..745de4aaf4f10 --- /dev/null +++ b/packages/next-codemod/transforms/name-default-component.ts @@ -0,0 +1,89 @@ +import { basename, extname } from 'path' + +const camelCase = (value) => { + const val = value.replace(/[-_\s.]+(.)?/g, (_match, chr) => + chr ? chr.toUpperCase() : '' + ) + return val.substr(0, 1).toUpperCase() + val.substr(1) +} + +const isValidIdentifier = (value) => /^[a-zA-ZÀ-ÿ][0-9a-zA-ZÀ-ÿ]+$/.test(value) + +export default function transformer(file, api, options) { + const j = api.jscodeshift + const root = j(file.source) + + let hasModifications + + const returnsJSX = (node) => + node.type === 'JSXElement' || + (node.type === 'BlockStatement' && + j(node) + .find(j.ReturnStatement) + .some( + (path) => + path.value.argument && path.value.argument.type === 'JSXElement' + )) + + const hasRootAsParent = (path) => { + const program = path.parentPath.parentPath.parentPath.parentPath.parentPath + return ( + !program || (program && program.value && program.value.type === 'Program') + ) + } + + const nameFunctionComponent = (path) => { + const node = path.value + + if (!node.declaration) { + return + } + + const isArrowFunction = + node.declaration.type === 'ArrowFunctionExpression' && + returnsJSX(node.declaration.body) + const isAnonymousFunction = + node.declaration.type === 'FunctionDeclaration' && !node.declaration.id + + if (!(isArrowFunction || isAnonymousFunction)) { + return + } + + const fileName = basename(file.path, extname(file.path)) + let name = camelCase(fileName) + + // If the generated name looks off, don't add a name + if (!isValidIdentifier(name)) { + return + } + + // Add `Component` to the end of the name if an identifier with the + // same name already exists + while (root.find(j.Identifier, { name }).some(hasRootAsParent)) { + // If the name is still duplicated then don't add a name + if (name.endsWith('Component')) { + return + } + name += 'Component' + } + + hasModifications = true + + if (isArrowFunction) { + path.insertBefore( + j.variableDeclaration('const', [ + j.variableDeclarator(j.identifier(name), node.declaration), + ]) + ) + + node.declaration = j.identifier(name) + } else { + // Anonymous Function + node.declaration.id = j.identifier(name) + } + } + + root.find(j.ExportDefaultDeclaration).forEach(nameFunctionComponent) + + return hasModifications ? root.toSource(options) : null +} diff --git a/packages/next-codemod/transforms/url-to-withrouter.ts b/packages/next-codemod/transforms/url-to-withrouter.ts new file mode 100644 index 0000000000000..27f6c702a02bf --- /dev/null +++ b/packages/next-codemod/transforms/url-to-withrouter.ts @@ -0,0 +1,393 @@ +// One-time usage file. You can delete me after running the codemod! + +function addWithRouterImport(j, root) { + // We create an import specifier, this is the value of an import, eg: + // import {withRouter} from 'next/router + // The specifier would be `withRouter` + const withRouterSpecifier = j.importSpecifier(j.identifier('withRouter')) + + // Check if this file is already import `next/router` + // so that we can just attach `withRouter` instead of creating a new `import` node + const originalRouterImport = root.find(j.ImportDeclaration, { + source: { + value: 'next/router', + }, + }) + if (originalRouterImport.length > 0) { + // Check if `withRouter` is already imported. In that case we don't have to do anything + if ( + originalRouterImport.find(j.ImportSpecifier, { + imported: { name: 'withRouter' }, + }).length > 0 + ) { + return + } + + // Attach `withRouter` to the existing `next/router` import node + originalRouterImport.forEach((node) => { + node.value.specifiers.push(withRouterSpecifier) + }) + return + } + + // Create import node + // import {withRouter} from 'next/router' + const withRouterImport = j.importDeclaration( + [withRouterSpecifier], + j.stringLiteral('next/router') + ) + + // Find the Program, this is the top level AST node + const Program = root.find(j.Program) + // Attach the import at the top of the body + Program.forEach((node) => { + node.value.body.unshift(withRouterImport) + }) +} + +function getThisPropsUrlNodes(j, tree) { + return tree.find(j.MemberExpression, { + object: { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: { name: 'props' }, + }, + property: { name: 'url' }, + }) +} + +function getPropsUrlNodes(j, tree, name) { + return tree.find(j.MemberExpression, { + object: { name }, + property: { name: 'url' }, + }) +} + +// Wraps the provided node in a function call +// For example if `functionName` is `withRouter` it will wrap the provided node in `withRouter(NODE_CONTENT)` +function wrapNodeInFunction(j, functionName, args) { + const mappedArgs = args.map((node) => { + // If the node is a ClassDeclaration we have to turn it into a ClassExpression + // since ClassDeclarations can't be wrapped in a function + if (node.type === 'ClassDeclaration') { + node.type = 'ClassExpression' + } + + return node + }) + return j.callExpression(j.identifier(functionName), mappedArgs) +} + +function turnUrlIntoRouter(j, tree) { + tree.find(j.Identifier, { name: 'url' }).replaceWith(j.identifier('router')) +} + +export default function transformer(file, api) { + // j is just a shorthand for the jscodeshift api + const j = api.jscodeshift + // this is the AST root on which we can call methods like `.find` + const root = j(file.source) + + // We search for `export default` + const defaultExports = root.find(j.ExportDefaultDeclaration) + + // We loop over the `export default` instances + // This is just how jscodeshift works, there can only be one export default instance + defaultExports.forEach((rule) => { + // rule.value is an AST node + const { value: node } = rule + // declaration holds the AST node for what comes after `export default` + const { declaration } = node + + function wrapDefaultExportInWithRouter() { + if ( + j(rule).find(j.CallExpression, { callee: { name: 'withRouter' } }) + .length > 0 + ) { + return + } + j(rule).replaceWith( + j.exportDefaultDeclaration( + wrapNodeInFunction(j, 'withRouter', [declaration]) + ) + ) + } + + // The `Identifier` type is given in this case: + // export default Test + // where `Test` is the identifier + if (declaration.type === 'Identifier') { + // the variable name + const { name } = declaration + + // find the implementation of the variable, can be a class, function, etc + let implementation = root.find(j.Declaration, { id: { name } }) + if (implementation.length === 0) { + implementation = root.find(j.VariableDeclarator, { id: { name } }) + } + + implementation + .find(j.Property, { key: { name: 'url' } }) + .forEach((propertyRule) => { + const isThisPropsDestructure = j(propertyRule).closest( + j.VariableDeclarator, + { + init: { + object: { + type: 'ThisExpression', + }, + property: { name: 'props' }, + }, + } + ) + if (isThisPropsDestructure.length === 0) { + return + } + const originalKeyValue = propertyRule.value.value.name + propertyRule.value.key.name = 'router' + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + // If the property is reassigned to another variable we don't have to transform it + if (originalKeyValue !== 'url') { + return + } + + propertyRule.value.value.name = 'router' + j(propertyRule) + .closest(j.BlockStatement) + .find(j.Identifier, (identifierNode) => { + if (identifierNode.type === 'JSXIdentifier') { + return false + } + + if (identifierNode.name !== 'url') { + return false + } + + return true + }) + .replaceWith(j.identifier('router')) + }) + + // Find usage of `this.props.url` + const thisPropsUrlUsage = getThisPropsUrlNodes(j, implementation) + + if (thisPropsUrlUsage.length === 0) { + return + } + + // rename `url` to `router` + turnUrlIntoRouter(j, thisPropsUrlUsage) + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + return + } + + const arrowFunctions = j(rule).find(j.ArrowFunctionExpression) + ;(() => { + if (arrowFunctions.length === 0) { + return + } + + arrowFunctions.forEach((r) => { + // This makes sure we don't match nested functions, only the top one + if (j(r).closest(j.Expression).length !== 0) { + return + } + + if (!r.value.params || !r.value.params[0]) { + return + } + + const name = r.value.params[0].name + const propsUrlUsage = getPropsUrlNodes(j, j(r), name) + if (propsUrlUsage.length === 0) { + return + } + + turnUrlIntoRouter(j, propsUrlUsage) + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + }) + return + })() + + if (declaration.type === 'CallExpression') { + j(rule) + .find(j.CallExpression, (haystack) => { + const firstArgument = haystack.arguments[0] || {} + if (firstArgument.type === 'Identifier') { + return true + } + + return false + }) + .forEach((callRule) => { + const { name } = callRule.value.arguments[0] + + // find the implementation of the variable, can be a class, function, etc + let implementation = root.find(j.Declaration, { id: { name } }) + if (implementation.length === 0) { + implementation = root.find(j.VariableDeclarator, { id: { name } }) + } + // Find usage of `this.props.url` + const thisPropsUrlUsage = getThisPropsUrlNodes(j, implementation) + + implementation + .find(j.Property, { key: { name: 'url' } }) + .forEach((propertyRule) => { + const isThisPropsDestructure = j(propertyRule).closest( + j.VariableDeclarator, + { + init: { + object: { + type: 'ThisExpression', + }, + property: { name: 'props' }, + }, + } + ) + if (isThisPropsDestructure.length === 0) { + return + } + const originalKeyValue = propertyRule.value.value.name + propertyRule.value.key.name = 'router' + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + // If the property is reassigned to another variable we don't have to transform it + if (originalKeyValue !== 'url') { + return + } + + propertyRule.value.value.name = 'router' + j(propertyRule) + .closest(j.BlockStatement) + .find(j.Identifier, (identifierNode) => { + if (identifierNode.type === 'JSXIdentifier') { + return false + } + + if (identifierNode.name !== 'url') { + return false + } + + return true + }) + .replaceWith(j.identifier('router')) + }) + + if (thisPropsUrlUsage.length === 0) { + return + } + + // rename `url` to `router` + turnUrlIntoRouter(j, thisPropsUrlUsage) + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + return + }) + } + + j(rule) + .find(j.Property, { key: { name: 'url' } }) + .forEach((propertyRule) => { + const isThisPropsDestructure = j(propertyRule).closest( + j.VariableDeclarator, + { + init: { + object: { + type: 'ThisExpression', + }, + property: { name: 'props' }, + }, + } + ) + if (isThisPropsDestructure.length === 0) { + return + } + const originalKeyValue = propertyRule.value.value.name + propertyRule.value.key.name = 'router' + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + // If the property is reassigned to another variable we don't have to transform it + if (originalKeyValue !== 'url') { + return + } + + propertyRule.value.value.name = 'router' + j(propertyRule) + .closest(j.BlockStatement) + .find(j.Identifier, (identifierNode) => { + if (identifierNode.type === 'JSXIdentifier') { + return false + } + + if (identifierNode.name !== 'url') { + return false + } + + return true + }) + .replaceWith(j.identifier('router')) + }) + + j(rule) + .find(j.MethodDefinition, { key: { name: 'componentWillReceiveProps' } }) + .forEach((methodRule) => { + const func = methodRule.value.value + if (!func.params[0]) { + return + } + const firstArgumentName = func.params[0].name + const propsUrlUsage = getPropsUrlNodes( + j, + j(methodRule), + firstArgumentName + ) + turnUrlIntoRouter(j, propsUrlUsage) + if (propsUrlUsage.length === 0) { + return + } + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + }) + + j(rule) + .find(j.MethodDefinition, { key: { name: 'componentDidUpdate' } }) + .forEach((methodRule) => { + const func = methodRule.value.value + if (!func.params[0]) { + return + } + const firstArgumentName = func.params[0].name + const propsUrlUsage = getPropsUrlNodes( + j, + j(methodRule), + firstArgumentName + ) + turnUrlIntoRouter(j, propsUrlUsage) + if (propsUrlUsage.length === 0) { + return + } + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + }) + + const thisPropsUrlUsage = getThisPropsUrlNodes(j, j(rule)) + const propsUrlUsage = getPropsUrlNodes(j, j(rule), 'props') + + // rename `url` to `router` + turnUrlIntoRouter(j, thisPropsUrlUsage) + turnUrlIntoRouter(j, propsUrlUsage) + + if (thisPropsUrlUsage.length === 0 && propsUrlUsage.length === 0) { + return + } + + wrapDefaultExportInWithRouter() + addWithRouterImport(j, root) + return + }) + + return root.toSource() +} diff --git a/packages/next-codemod/transforms/withamp-to-config.ts b/packages/next-codemod/transforms/withamp-to-config.ts new file mode 100644 index 0000000000000..9bd6f3ea3883d --- /dev/null +++ b/packages/next-codemod/transforms/withamp-to-config.ts @@ -0,0 +1,175 @@ +// One-time usage file. You can delete me after running the codemod! + +function injectAmp(j, o, desiredAmpValue) { + const init = o.node.init + + switch (init.type) { + case 'ObjectExpression': { + const overwroteAmpKey = init.properties.some((prop) => { + switch (prop.type) { + case 'Property': + case 'ObjectProperty': + if (!(prop.key.type === 'Identifier' && prop.key.name === 'amp')) { + return false + } + + prop.value = desiredAmpValue + return true + default: + return false + } + }) + + if (!overwroteAmpKey) { + init.properties.push( + j.objectProperty(j.identifier('amp'), desiredAmpValue) + ) + } + + return true + } + default: { + return false + } + } +} + +export default function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + const done = () => root.toSource() + + const imports = root.find(j.ImportDeclaration, { + source: { value: 'next/amp' }, + }) + + if (imports.length < 1) { + return + } + + let hadWithAmp = false + const ampImportNames = [] + + imports.forEach((ampImport) => { + const ampImportShift = j(ampImport) + + const withAmpImport = ampImportShift.find(j.ImportSpecifier, { + imported: { name: 'withAmp' }, + }) + + if (withAmpImport.length < 1) { + return + } + + hadWithAmp = true + withAmpImport.forEach((element) => { + ampImportNames.push(element.value.local.name) + j(element).remove() + }) + + if (ampImport.value.specifiers.length === 0) { + ampImportShift.remove() + } + }) + + if (!hadWithAmp) { + return done() + } + + const defaultExportsShift = root.find(j.ExportDefaultDeclaration) + if (defaultExportsShift.length < 1) { + return done() + } + + let desiredAmpValue = j.booleanLiteral(true) + + const defaultExport = defaultExportsShift.nodes()[0] + const removedWrapper = ampImportNames.some((ampImportName) => { + const ampWrapping = j(defaultExport).find(j.CallExpression, { + callee: { name: ampImportName }, + }) + + if (ampWrapping.length < 1) { + return false + } + + ampWrapping.forEach((e) => { + if (e.value.arguments.length < 1) { + j(e).remove() + } else { + const withAmpOptions = e.value.arguments[1] + if (withAmpOptions && withAmpOptions.type === 'ObjectExpression') { + const isHybrid = withAmpOptions.properties.some((prop) => { + if (!(prop.type === 'Property' || prop.type === 'ObjectProperty')) { + return false + } + + if (!(prop.key && prop.key.name === 'hybrid')) { + return false + } + + return ( + (prop.value.type === 'Literal' || + prop.value.type === 'BooleanLiteral') && + prop.value.value === true + ) + }) + + if (isHybrid) { + desiredAmpValue = j.stringLiteral('hybrid') + } + } + + j(e).replaceWith(e.value.arguments[0]) + } + }) + return true + }) + + if (!removedWrapper) { + return done() + } + + const namedExportsShift = root.find(j.ExportNamedDeclaration) + const hadExistingConfig = namedExportsShift.some((namedExport) => { + const configExportedObject = j(namedExport).find(j.VariableDeclarator, { + id: { name: 'config' }, + }) + if (configExportedObject.length > 0) { + return configExportedObject.some((exportedObject) => + injectAmp(j, exportedObject, desiredAmpValue) + ) + } + + const configReexported = j(namedExport).find(j.ExportSpecifier, { + local: { name: 'config' }, + }) + if (configReexported.length > 0) { + const configObjects = root + .findVariableDeclarators('config') + .filter((el) => el.scope.isGlobal) + return configObjects.some((configObject) => + injectAmp(j, configObject, desiredAmpValue) + ) + } + + return false + }) + + if (!hadExistingConfig) { + defaultExportsShift.insertAfter( + j.exportNamedDeclaration( + j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier('config'), + j.objectExpression([ + j.objectProperty(j.identifier('amp'), desiredAmpValue), + ]) + ), + ]) + ) + ) + } + + return done() +} diff --git a/packages/next-codemod/tsconfig.json b/packages/next-codemod/tsconfig.json new file mode 100644 index 0000000000000..bad5755406a6a --- /dev/null +++ b/packages/next-codemod/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "sourceMap": true, + "esModuleInterop": true, + "target": "es2015", + "downlevelIteration": true, + "preserveWatchOutput": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 33c9e65534273..875e4ae23e593 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "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 cf32a9a872c81..c35ad8a73d85c 100644 --- a/packages/next-plugin-google-analytics/package.json +++ b/packages/next-plugin-google-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-google-analytics", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "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 1527d37248c2f..63c9b9e29de71 100644 --- a/packages/next-plugin-sentry/package.json +++ b/packages/next-plugin-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-sentry", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "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 1768fc95bd897..f9f9921d9f126 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 6eff94405030a..4b05c023bb0a2 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next/bin/next.ts b/packages/next/bin/next.ts index 6e8ee0429b4bd..d8ff56286a5f4 100755 --- a/packages/next/bin/next.ts +++ b/packages/next/bin/next.ts @@ -7,7 +7,6 @@ import { NON_STANDARD_NODE_ENV } from '../lib/constants' // When 'npm link' is used it checks the clone location. Not the project. require.resolve(dependency) } catch (err) { - // tslint:disable-next-line console.warn( `The module '${dependency}' was not found. Next.js requires that you include it in 'dependencies' of your 'package.json'. To add it, run 'npm install ${dependency}'` ) @@ -44,7 +43,6 @@ const args = arg( // Version is inlined into the file using taskr build pipeline if (args['--version']) { - // tslint:disable-next-line console.log(`Next.js v${process.env.__NEXT_VERSION}`) process.exit(0) } @@ -52,10 +50,10 @@ if (args['--version']) { // Check if we are running `next ` or `next` const foundCommand = Boolean(commands[args._[0]]) -// Makes sure the `next --help` case is covered +// Makes sure the `next --help` case is covered // This help message is only showed for `next --help` +// `next --help` falls through to be handled later if (!foundCommand && args['--help']) { - // tslint:disable-next-line console.log(` Usage $ next @@ -113,7 +111,6 @@ if (command === 'dev') { const { watchFile } = require('fs') watchFile(`${process.cwd()}/${CONFIG_FILE}`, (cur: any, prev: any) => { if (cur.size > 0 || prev.size > 0) { - // tslint:disable-next-line console.log( `\n> Found a change in ${CONFIG_FILE}. Restart the server to see the changes in effect.` ) diff --git a/packages/next/build/babel/plugins/next-page-config.ts b/packages/next/build/babel/plugins/next-page-config.ts index adf724d854913..1e836f23dddb3 100644 --- a/packages/next/build/babel/plugins/next-page-config.ts +++ b/packages/next/build/babel/plugins/next-page-config.ts @@ -2,6 +2,8 @@ import { NodePath, PluginObj, types as BabelTypes } from '@babel/core' import { PageConfig } from 'next/types' import { STRING_LITERAL_DROP_BUNDLE } from '../../../next-server/lib/constants' +const CONFIG_KEY = 'config' + // replace program path with just a variable with the drop identifier function replaceBundle(path: any, t: typeof BabelTypes): void { path.parentPath.replaceWith( @@ -41,27 +43,59 @@ export default function nextPageConfig({ enter(path, state: ConfigState) { path.traverse( { + ExportDeclaration(exportPath, exportState) { + if ( + BabelTypes.isExportNamedDeclaration(exportPath) && + (exportPath.node as BabelTypes.ExportNamedDeclaration).specifiers?.some( + (specifier) => { + return specifier.exported.name === CONFIG_KEY + } + ) && + BabelTypes.isStringLiteral( + (exportPath.node as BabelTypes.ExportNamedDeclaration) + .source + ) + ) { + throw new Error( + errorMessage( + exportState, + 'Expected object but got export from' + ) + ) + } + }, ExportNamedDeclaration( exportPath: NodePath, exportState: any ) { - if (exportState.bundleDropped || !exportPath.node.declaration) { - return - } - if ( - !BabelTypes.isVariableDeclaration(exportPath.node.declaration) + exportState.bundleDropped || + (!exportPath.node.declaration && + exportPath.node.specifiers.length === 0) ) { return } - const { declarations } = exportPath.node.declaration const config: PageConfig = {} + const declarations = [ + ...(exportPath.node.declaration?.declarations || []), + exportPath.scope.getBinding(CONFIG_KEY)?.path.node, + ].filter(Boolean) for (const declaration of declarations) { if ( - !BabelTypes.isIdentifier(declaration.id, { name: 'config' }) + !BabelTypes.isIdentifier(declaration.id, { + name: CONFIG_KEY, + }) ) { + if (BabelTypes.isImportSpecifier(declaration)) { + throw new Error( + errorMessage( + exportState, + `Expected object but got import` + ) + ) + } continue } diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index abb020b18f66f..93ca9cf52f9d3 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -102,7 +102,8 @@ export type PrerenderManifest = { export default async function build( dir: string, conf = null, - reactProductionProfiling = false + reactProductionProfiling = false, + debugOutput = false ): Promise { if (!(await isWriteable(dir))) { throw new Error( @@ -286,7 +287,26 @@ export default async function build( } const routesManifestPath = path.join(distDir, ROUTES_MANIFEST) - const routesManifest: any = { + const routesManifest: { + version: number + pages404: boolean + basePath: string + redirects: Array> + rewrites: Array> + headers: Array> + dynamicRoutes: Array<{ + page: string + regex: string + namedRegex?: string + routeKeys?: { [key: string]: string } + }> + dataRoutes: Array<{ + page: string + routeKeys?: { [key: string]: string } + dataRouteRegex: string + namedDataRouteRegex?: string + }> + } = { version: 3, pages404: true, basePath: config.basePath, @@ -304,6 +324,7 @@ export default async function build( namedRegex: routeRegex.namedRegex, } }), + dataRoutes: [], } await promises.mkdir(distDir, { recursive: true }) @@ -325,6 +346,7 @@ export default async function build( target, pagesDir, entrypoints: entrypoints.client, + rewrites, }), getBaseWebpackConfig(dir, { tracer, @@ -335,6 +357,7 @@ export default async function build( target, pagesDir, entrypoints: entrypoints.server, + rewrites, }), ]) @@ -654,6 +677,7 @@ export default async function build( 'utf8' ) } + // Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps // Only export the static 404 when there is no /_error present const useStatic404 = @@ -1001,7 +1025,10 @@ export default async function build( isModern: config.experimental.modern, } ) - printCustomRoutes({ redirects, rewrites, headers }) + + if (debugOutput) { + printCustomRoutes({ redirects, rewrites, headers }) + } if (tracer) { const parsedResults = await tracer.profiler.stopProfiling() @@ -1064,6 +1091,8 @@ export default async function build( await telemetry.flush() } +export type ClientSsgManifest = Set + function generateClientSsgManifest( prerenderManifest: PrerenderManifest, { @@ -1072,7 +1101,7 @@ function generateClientSsgManifest( isModern, }: { buildId: string; distDir: string; isModern: boolean } ) { - const ssgPages: Set = new Set([ + const ssgPages: ClientSsgManifest = new Set([ ...Object.entries(prerenderManifest.routes) // Filter out dynamic routes .filter(([, { srcRoute }]) => srcRoute == null) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 6355ca150028e..acfece465c8b5 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -54,6 +54,7 @@ import WebpackConformancePlugin, { ReactSyncScriptsConformanceCheck, } from './webpack/plugins/webpack-conformance-plugin' import { WellKnownErrorsPlugin } from './webpack/plugins/wellknown-errors-plugin' +import { Rewrite } from '../lib/load-custom-routes' type ExcludesFalse = (x: T | false) => x is T const isWebpack5 = parseInt(webpack.version!) === 5 @@ -190,6 +191,7 @@ export default async function getBaseWebpackConfig( target = 'server', reactProductionProfiling = false, entrypoints, + rewrites, }: { buildId: string config: any @@ -200,6 +202,7 @@ export default async function getBaseWebpackConfig( tracer?: any reactProductionProfiling?: boolean entrypoints: WebpackEntrypoints + rewrites: Rewrite[] } ): Promise { const productionBrowserSourceMaps = @@ -207,6 +210,8 @@ export default async function getBaseWebpackConfig( let plugins: PluginMetaData[] = [] let babelPresetPlugins: { dir: string; config: any }[] = [] + const hasRewrites = rewrites.length > 0 || dev + if (config.experimental.plugins) { plugins = await collectPlugins(dir, config.env, config.plugins) pluginLoaderOptions.plugins = plugins @@ -232,7 +237,8 @@ export default async function getBaseWebpackConfig( distDir, pagesDir, cwd: dir, - cache: true, + // Webpack 5 has a built-in loader cache + cache: !config.experimental.unstable_webpack5cache, babelPresetPlugins, hasModern: !!config.experimental.modern, development: dev, @@ -326,6 +332,10 @@ export default async function getBaseWebpackConfig( } } + const clientResolveRewrites = require.resolve( + 'next/dist/next-server/lib/router/utils/resolve-rewrites' + ) + const resolveConfig = { // Disable .mjs for node_modules bundling extensions: isServer @@ -356,20 +366,23 @@ export default async function getBaseWebpackConfig( 'next/router': 'next/dist/client/router.js', 'next/config': 'next/dist/next-server/lib/runtime-config.js', 'next/dynamic': 'next/dist/next-server/lib/dynamic.js', - ...(isServer - ? {} - : { - stream: 'stream-browserify', - path: 'path-browserify', - crypto: 'crypto-browserify', - buffer: 'buffer', - vm: 'vm-browserify', - next: NEXT_PROJECT_ROOT, - }), + next: NEXT_PROJECT_ROOT, + ...(isWebpack5 && !isServer + ? { + stream: require.resolve('stream-browserify'), + path: require.resolve('path-browserify'), + crypto: require.resolve('crypto-browserify'), + buffer: require.resolve('buffer'), + vm: require.resolve('vm-browserify'), + } + : undefined), [PAGES_DIR_ALIAS]: pagesDir, [DOT_NEXT_ALIAS]: distDir, ...getOptimizedAliases(isServer), ...getReactProfilingInProduction(), + [clientResolveRewrites]: hasRewrites + ? clientResolveRewrites + : require.resolve('next/dist/client/dev/noop.js'), }, mainFields: isServer ? ['main', 'module'] : ['browser', 'module', 'main'], plugins: isWebpack5 @@ -929,7 +942,7 @@ export default async function getBaseWebpackConfig( config.experimental.reactMode ), 'process.env.__NEXT_OPTIMIZE_FONTS': JSON.stringify( - config.experimental.optimizeFonts + config.experimental.optimizeFonts && !dev ), 'process.env.__NEXT_OPTIMIZE_IMAGES': JSON.stringify( config.experimental.optimizeImages @@ -938,6 +951,7 @@ export default async function getBaseWebpackConfig( config.experimental.scrollRestoration ), 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath), + 'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) @@ -1010,6 +1024,7 @@ export default async function getBaseWebpackConfig( !isServer && new BuildManifestPlugin({ buildId, + rewrites, modern: config.experimental.modern, }), tracer && @@ -1103,6 +1118,14 @@ export default async function getBaseWebpackConfig( } webpackConfig.optimization.usedExports = false } + + // Enable webpack 5 caching + if (config.experimental.unstable_webpack5cache) { + webpackConfig.cache = { + type: 'filesystem', + cacheDirectory: path.join(dir, '.next', 'cache', 'webpack'), + } + } } webpackConfig = await buildConfiguration(webpackConfig, { diff --git a/packages/next/build/webpack/config/blocks/css/loaders/client.ts b/packages/next/build/webpack/config/blocks/css/loaders/client.ts index 9f5324638644d..3e260ce069c8d 100644 --- a/packages/next/build/webpack/config/blocks/css/loaders/client.ts +++ b/packages/next/build/webpack/config/blocks/css/loaders/client.ts @@ -33,6 +33,7 @@ export function getClientStyleLoader({ }, } : { + // @ts-ignore: TODO: remove when webpack 5 is stable loader: MiniCssExtractPlugin.loader, options: { publicPath: `${assetPrefix}/_next/` }, } diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index da5a2182c6e7c..2b0ffc7301e00 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -72,9 +72,9 @@ const nextServerlessLoader: loader.Loader = function () { ` : '' - const collectDynamicRouteParams = pageIsDynamicRoute + const normalizeDynamicRouteParams = pageIsDynamicRoute ? ` - function collectDynamicRouteParams(query) { + function normalizeDynamicRouteParams(query) { return Object.keys(defaultRouteRegex.groups) .reduce((prev, key) => { let value = query[key] @@ -84,7 +84,14 @@ const nextServerlessLoader: loader.Loader = function () { // non-provided optional values should be undefined so normalize // them to undefined } - if(defaultRouteRegex.groups[key].optional && !value) { + if( + defaultRouteRegex.groups[key].optional && + (!value || ( + Array.isArray(value) && + value.length === 1 && + value[0] === 'index' + )) + ) { value = undefined delete query[key] } @@ -142,12 +149,12 @@ const nextServerlessLoader: loader.Loader = function () { const rewriteImports = ` const { rewrites } = require('${routesManifest}') - const { pathToRegexp, default: pathMatch } = require('next/dist/next-server/server/lib/path-match') + const { pathToRegexp, default: pathMatch } = require('next/dist/next-server/lib/router/utils/path-match') ` const handleRewrites = ` const getCustomRouteMatcher = pathMatch(true) - const {prepareDestination} = require('next/dist/next-server/server/router') + const prepareDestination = require('next/dist/next-server/lib/router/utils/prepare-destination').default function handleRewrites(parsedUrl) { for (const rewrite of rewrites) { @@ -163,7 +170,7 @@ const nextServerlessLoader: loader.Loader = function () { "${basePath}" ) - Object.assign(parsedUrl.query, parsedDestination.query, params) + Object.assign(parsedUrl.query, parsedDestination.query) delete parsedDestination.query Object.assign(parsedUrl, parsedDestination) @@ -223,7 +230,7 @@ const nextServerlessLoader: loader.Loader = function () { ${defaultRouteRegex} - ${collectDynamicRouteParams} + ${normalizeDynamicRouteParams} ${handleRewrites} @@ -241,9 +248,11 @@ const nextServerlessLoader: loader.Loader = function () { const params = ${ pageIsDynamicRoute ? ` - trustQuery - ? collectDynamicRouteParams(parsedUrl.query) - : dynamicRouteMatcher(parsedUrl.pathname) + normalizeDynamicRouteParams( + trustQuery + ? parsedUrl.query + : dynamicRouteMatcher(parsedUrl.pathname) + ) ` : `{}` } @@ -316,7 +325,7 @@ const nextServerlessLoader: loader.Loader = function () { ${dynamicRouteMatcher} ${defaultRouteRegex} - ${collectDynamicRouteParams} + ${normalizeDynamicRouteParams} ${handleRewrites} export const config = ComponentInfo['confi' + 'g'] || {} @@ -394,9 +403,11 @@ const nextServerlessLoader: loader.Loader = function () { !getStaticProps && !getServerSideProps ) ? {} - : trustQuery - ? collectDynamicRouteParams(parsedUrl.query) - : dynamicRouteMatcher(parsedUrl.pathname) || {}; + : normalizeDynamicRouteParams( + trustQuery + ? parsedUrl.query + : dynamicRouteMatcher(parsedUrl.pathname) + ) ` : `const params = {};` } @@ -433,6 +444,7 @@ const nextServerlessLoader: loader.Loader = function () { ` : `const nowParams = null;` } + // make sure to set renderOpts to the correct params e.g. _params // if provided from worker or params if we're parsing them here renderOpts.params = _params || params @@ -455,6 +467,29 @@ const nextServerlessLoader: loader.Loader = function () { : '' } + // normalize request URL/asPath for fallback pages since the proxy + // sets the request URL to the output's path for fallback pages + ${ + pageIsDynamicRoute + ? ` + if (nowParams) { + const _parsedUrl = parseUrl(req.url) + + for (const param of Object.keys(defaultRouteRegex.groups)) { + const paramIdx = _parsedUrl.pathname.indexOf(\`[\${param}]\`) + + if (paramIdx > -1) { + _parsedUrl.pathname = _parsedUrl.pathname.substr(0, paramIdx) + + encodeURI(nowParams[param]) + + _parsedUrl.pathname.substr(paramIdx + param.length + 2) + } + } + req.url = formatUrl(_parsedUrl) + } + ` + : `` + } + const isFallback = parsedUrl.query.__nextFallback const previewData = tryGetPreviewData(req, res, options.previewProps) diff --git a/packages/next/build/webpack/plugins/build-manifest-plugin.ts b/packages/next/build/webpack/plugins/build-manifest-plugin.ts index c530823acca42..01cacf64ad57d 100644 --- a/packages/next/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/build-manifest-plugin.ts @@ -1,6 +1,6 @@ import devalue from 'next/dist/compiled/devalue' import webpack, { Compiler, compilation as CompilationType } from 'webpack' -import { RawSource } from 'webpack-sources' +import sources from 'webpack-sources' import { BUILD_MANIFEST, CLIENT_STATIC_FILES_PATH, @@ -12,19 +12,35 @@ import { import { BuildManifest } from '../../../next-server/server/get-page-files' import getRouteFromEntrypoint from '../../../next-server/server/get-route-from-entrypoint' import { ampFirstEntryNamesMap } from './next-drop-client-page-plugin' +import { Rewrite } from '../../../lib/load-custom-routes' +import { getSortedRoutes } from '../../../next-server/lib/router/utils' + +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource } = webpack.sources || sources const isWebpack5 = parseInt(webpack.version!) === 5 +type DeepMutable = { -readonly [P in keyof T]: DeepMutable } + +export type ClientBuildManifest = Record + // This function takes the asset map generated in BuildManifestPlugin and creates a // reduced version to send to the client. function generateClientManifest( assetMap: BuildManifest, - isModern: boolean + isModern: boolean, + rewrites: Rewrite[] ): string { - const clientManifest: { [s: string]: string[] } = {} + const clientManifest: ClientBuildManifest = { + // TODO: update manifest type to include rewrites + __rewrites: rewrites as any, + } const appDependencies = new Set(assetMap.pages['/_app']) + const sortedPageKeys = getSortedRoutes(Object.keys(assetMap.pages)) + + sortedPageKeys.forEach((page) => { + const dependencies = assetMap.pages[page] - Object.entries(assetMap.pages).forEach(([page, dependencies]) => { if (page === '/_app') return // Filter out dependencies in the _app entry, because those will have already // been loaded by the client prior to a navigation event @@ -39,6 +55,10 @@ function generateClientManifest( clientManifest[page] = filteredDeps } }) + // provide the sorted pages as an array so we don't rely on the object's keys + // being in order and we don't slow down look-up time for page assets + clientManifest.sortedPages = sortedPageKeys + return devalue(clientManifest) } @@ -63,16 +83,31 @@ function getFilesArray(files: any) { export default class BuildManifestPlugin { private buildId: string private modern: boolean + private rewrites: Rewrite[] - constructor(options: { buildId: string; modern: boolean }) { + constructor(options: { + buildId: string + modern: boolean + rewrites: Rewrite[] + }) { this.buildId = options.buildId this.modern = options.modern + this.rewrites = options.rewrites.map((r) => { + const rewrite = { ...r } + + // omit external rewrite destinations since these aren't + // handled client-side + if (!rewrite.destination.startsWith('/')) { + delete rewrite.destination + } + return rewrite + }) } createAssets(compilation: any, assets: any) { const namedChunks: Map = compilation.namedChunks - const assetMap: BuildManifest = { + const assetMap: DeepMutable = { polyfillFiles: [], devFiles: [], ampDevFiles: [], @@ -183,7 +218,8 @@ export default class BuildManifestPlugin { assets[clientManifestPath] = new RawSource( `self.__BUILD_MANIFEST = ${generateClientManifest( assetMap, - false + false, + this.rewrites )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` ) @@ -193,7 +229,8 @@ export default class BuildManifestPlugin { assets[modernClientManifestPath] = new RawSource( `self.__BUILD_MANIFEST = ${generateClientManifest( assetMap, - true + true, + this.rewrites )};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()` ) } diff --git a/packages/next/build/webpack/plugins/css-minimizer-plugin.ts b/packages/next/build/webpack/plugins/css-minimizer-plugin.ts index 1cd2c75fa287a..34ad34fbfe63d 100644 --- a/packages/next/build/webpack/plugins/css-minimizer-plugin.ts +++ b/packages/next/build/webpack/plugins/css-minimizer-plugin.ts @@ -1,6 +1,9 @@ import { process as minify } from 'cssnano-simple' import webpack from 'webpack' -import { RawSource, SourceMapSource } from 'webpack-sources' +import sources from 'webpack-sources' + +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource, SourceMapSource } = webpack.sources || sources // https://github.com/NMFR/optimize-css-assets-webpack-plugin/blob/0a410a9bf28c7b0e81a3470a13748e68ca2f50aa/src/index.js#L20 const CSS_REGEX = /\.css(\?.*)?$/i @@ -11,6 +14,8 @@ type CssMinimizerPluginOptions = { } } +const isWebpack5 = parseInt(webpack.version!) === 5 + export class CssMinimizerPlugin { __next_css_remove = true @@ -20,8 +25,66 @@ export class CssMinimizerPlugin { this.options = options } + optimizeAsset(file: string, asset: any) { + const postcssOptions = { + ...this.options.postcssOptions, + to: file, + from: file, + } + + let input: string + if (postcssOptions.map && asset.sourceAndMap) { + const { source, map } = asset.sourceAndMap() + input = source + postcssOptions.map.prev = map ? map : false + } else { + input = asset.source() + } + + return minify(input, postcssOptions).then((res) => { + if (res.map) { + return new SourceMapSource(res.css, file, res.map.toJSON()) + } else { + return new RawSource(res.css) + } + }) + } + apply(compiler: webpack.Compiler) { compiler.hooks.compilation.tap('CssMinimizerPlugin', (compilation: any) => { + if (isWebpack5) { + const cache = compilation.getCache('CssMinimizerPlugin') + compilation.hooks.processAssets.tapPromise( + { + name: 'CssMinimizerPlugin', + // @ts-ignore TODO: Remove ignore when webpack 5 is stable + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, + }, + async (assets: any) => { + const files = Object.keys(assets) + await Promise.all( + files + .filter((file) => CSS_REGEX.test(file)) + .map(async (file) => { + const asset = assets[file] + + const etag = cache.getLazyHashedEtag(asset) + + const cachedResult = await cache.getPromise(file, etag) + if (cachedResult) { + assets[file] = cachedResult + return + } + + const result = await this.optimizeAsset(file, asset) + await cache.storePromise(file, etag, result) + assets[file] = result + }) + ) + } + ) + return + } compilation.hooks.optimizeChunkAssets.tapPromise( 'CssMinimizerPlugin', (chunks: webpack.compilation.Chunk[]) => @@ -32,35 +95,10 @@ export class CssMinimizerPlugin { [] as string[] ) .filter((entry) => CSS_REGEX.test(entry)) - .map((file) => { - const postcssOptions = { - ...this.options.postcssOptions, - to: file, - from: file, - } - + .map(async (file) => { const asset = compilation.assets[file] - let input: string - if (postcssOptions.map && asset.sourceAndMap) { - const { source, map } = asset.sourceAndMap() - input = source - postcssOptions.map.prev = map ? map : false - } else { - input = asset.source() - } - - return minify(input, postcssOptions).then((res) => { - if (res.map) { - compilation.assets[file] = new SourceMapSource( - res.css, - file, - res.map.toJSON() - ) - } else { - compilation.assets[file] = new RawSource(res.css) - } - }) + compilation.assets[file] = await this.optimizeAsset(file, asset) }) ) ) diff --git a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts index eaa224852d156..5b8517cf0cff2 100644 --- a/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts +++ b/packages/next/build/webpack/plugins/font-stylesheet-gathering-plugin.ts @@ -1,18 +1,35 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { NodePath } from 'ast-types/lib/node-path' -import { compilation as CompilationType, Compiler } from 'webpack' +import webpack, { compilation as CompilationType, Compiler } from 'webpack' import { namedTypes } from 'ast-types' -import { RawSource } from 'webpack-sources' +import sources from 'webpack-sources' import { getFontDefinitionFromNetwork, FontManifest, } from '../../../next-server/server/font-utils' // @ts-ignore import BasicEvaluatedExpression from 'webpack/lib/BasicEvaluatedExpression' +import postcss from 'postcss' +import minifier from 'cssnano-simple' import { OPTIMIZED_FONT_PROVIDERS } from '../../../next-server/lib/constants' -interface VisitorMap { - [key: string]: (path: NodePath) => void +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource } = webpack.sources || sources + +const isWebpack5 = parseInt(webpack.version!) === 5 + +async function minifyCss(css: string): Promise { + return new Promise((resolve) => + postcss([ + minifier({ + excludeAll: true, + discardComments: true, + normalizeWhitespace: { exclude: false }, + }), + ]) + .process(css, { from: undefined }) + .then((res) => { + resolve(res.css) + }) + ) } export class FontStylesheetGatheringPlugin { @@ -132,19 +149,41 @@ export class FontStylesheetGatheringPlugin { this.manifestContent = [] for (let promiseIndex in fontDefinitionPromises) { + const css = await fontDefinitionPromises[promiseIndex] + const content = await minifyCss(css) this.manifestContent.push({ url: this.gatheredStylesheets[promiseIndex], - content: await fontDefinitionPromises[promiseIndex], + content, }) } - compilation.assets['font-manifest.json'] = new RawSource( - JSON.stringify(this.manifestContent, null, ' ') - ) + if (!isWebpack5) { + compilation.assets['font-manifest.json'] = new RawSource( + JSON.stringify(this.manifestContent, null, ' ') + ) + } modulesFinished() } ) cb() }) + + if (isWebpack5) { + compiler.hooks.make.tap(this.constructor.name, (compilation) => { + // @ts-ignore TODO: Remove ignore when webpack 5 is stable + compilation.hooks.processAssets.tap( + { + name: this.constructor.name, + // @ts-ignore TODO: Remove ignore when webpack 5 is stable + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + (assets: any) => { + assets['font-manifest.json'] = new RawSource( + JSON.stringify(this.manifestContent, null, ' ') + ) + } + ) + }) + } } } diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin.ts b/packages/next/build/webpack/plugins/mini-css-extract-plugin.ts index b8938a127cad3..907adceed5cda 100644 --- a/packages/next/build/webpack/plugins/mini-css-extract-plugin.ts +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin.ts @@ -1,4 +1,5 @@ -import MiniCssExtractPlugin from 'mini-css-extract-plugin' +// @ts-ignore: TODO: remove when webpack 5 is stable +import MiniCssExtractPlugin from './mini-css-extract-plugin/src' export default class NextMiniCssExtractPlugin extends MiniCssExtractPlugin { __next_css_remove = true diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin/LICENSE b/packages/next/build/webpack/plugins/mini-css-extract-plugin/LICENSE new file mode 100644 index 0000000000000..8c11fc7289b75 --- /dev/null +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin/LICENSE @@ -0,0 +1,20 @@ +Copyright JS Foundation and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssDependency.js b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssDependency.js new file mode 100644 index 0000000000000..3969bacf7d348 --- /dev/null +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssDependency.js @@ -0,0 +1,59 @@ +import webpack from 'webpack' + +class CssDependency extends webpack.Dependency { + constructor( + { identifier, content, media, sourceMap }, + context, + identifierIndex + ) { + super() + + this.identifier = identifier + this.identifierIndex = identifierIndex + this.content = content + this.media = media + this.sourceMap = sourceMap + this.context = context + } + + getResourceIdentifier() { + return `css-module-${this.identifier}-${this.identifierIndex}` + } +} + +const isWebpack5 = parseInt(webpack.version) === 5 + +if (isWebpack5) { + // @ts-ignore TODO: remove ts-ignore when webpack 5 is stable + webpack.util.serialization.register( + CssDependency, + 'next/dist/build/webpack/plugins/mini-css-extract-plugin/src/CssDependency', + null, + { + serialize(obj, { write }) { + write(obj.identifier) + write(obj.content) + write(obj.media) + write(obj.sourceMap) + write(obj.context) + write(obj.identifierIndex) + }, + deserialize({ read }) { + const obj = new CssDependency( + { + identifier: read(), + content: read(), + media: read(), + sourceMap: read(), + }, + read(), + read() + ) + + return obj + }, + } + ) +} + +export default CssDependency diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssModule.js b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssModule.js new file mode 100644 index 0000000000000..45ab2a1f0c78f --- /dev/null +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/CssModule.js @@ -0,0 +1,97 @@ +import webpack from 'webpack' + +class CssModule extends webpack.Module { + constructor(dependency) { + super('css/mini-extract', dependency.context) + this.id = '' + this._identifier = dependency.identifier + this._identifierIndex = dependency.identifierIndex + this.content = dependency.content + this.media = dependency.media + this.sourceMap = dependency.sourceMap + } // no source() so webpack doesn't do add stuff to the bundle + + size() { + return this.content.length + } + + identifier() { + return `css ${this._identifier} ${this._identifierIndex}` + } + + readableIdentifier(requestShortener) { + return `css ${requestShortener.shorten(this._identifier)}${ + this._identifierIndex ? ` (${this._identifierIndex})` : '' + }` + } + + nameForCondition() { + const resource = this._identifier.split('!').pop() + + const idx = resource.indexOf('?') + + if (idx >= 0) { + return resource.substring(0, idx) + } + + return resource + } + + updateCacheModule(module) { + this.content = module.content + this.media = module.media + this.sourceMap = module.sourceMap + } + + needRebuild() { + return true + } + + build(options, compilation, resolver, fileSystem, callback) { + this.buildInfo = {} + this.buildMeta = {} + callback() + } + + updateHash(hash, context) { + super.updateHash(hash, context) + hash.update(this.content) + hash.update(this.media || '') + hash.update(this.sourceMap ? JSON.stringify(this.sourceMap) : '') + } +} + +const isWebpack5 = parseInt(webpack.version) === 5 + +if (isWebpack5) { + // @ts-ignore TODO: remove ts-ignore when webpack 5 is stable + webpack.util.serialization.register( + CssModule, + 'next/dist/build/webpack/plugins/mini-css-extract-plugin/src/CssModule', + null, + { + serialize(obj, { write }) { + write(obj.context) + write(obj._identifier) + write(obj._identifierIndex) + write(obj.content) + write(obj.media) + write(obj.sourceMap) + }, + deserialize({ read }) { + const obj = new CssModule({ + context: read(), + identifier: read(), + identifierIndex: read(), + content: read(), + media: read(), + sourceMap: read(), + }) + + return obj + }, + } + ) +} + +export default CssModule diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/index.js b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/index.js new file mode 100644 index 0000000000000..4f0ece4826a26 --- /dev/null +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/index.js @@ -0,0 +1,551 @@ +/* eslint-disable class-methods-use-this */ + +import webpack from 'webpack' +import sources from 'webpack-sources' + +import CssDependency from './CssDependency' +import CssModule from './CssModule' + +const { ConcatSource, SourceMapSource, OriginalSource } = + webpack.sources || sources +const { + Template, + util: { createHash }, +} = webpack + +const isWebpack5 = parseInt(webpack.version) === 5 +const MODULE_TYPE = 'css/mini-extract' + +const pluginName = 'mini-css-extract-plugin' + +const REGEXP_CHUNKHASH = /\[chunkhash(?::(\d+))?\]/i +const REGEXP_CONTENTHASH = /\[contenthash(?::(\d+))?\]/i +const REGEXP_NAME = /\[name\]/i +const REGEXP_PLACEHOLDERS = /\[(name|id|chunkhash)\]/g +const DEFAULT_FILENAME = '[name].css' + +function getModulesIterable(compilation, chunk) { + if (isWebpack5) { + return compilation.chunkGraph.getChunkModulesIterable(chunk) + } + + return chunk.modulesIterable +} + +class CssDependencyTemplate { + apply() {} +} + +class CssModuleFactory { + create({ dependencies: [dependency] }, callback) { + callback(null, new CssModule(dependency)) + } +} + +class MiniCssExtractPlugin { + constructor(options = {}) { + this.options = Object.assign( + { + filename: DEFAULT_FILENAME, + moduleFilename: () => this.options.filename || DEFAULT_FILENAME, + ignoreOrder: false, + }, + options + ) + + if (!this.options.chunkFilename) { + const { filename } = this.options + + // Anything changing depending on chunk is fine + if (filename.match(REGEXP_PLACEHOLDERS)) { + this.options.chunkFilename = filename + } else { + // Elsewise prefix '[id].' in front of the basename to make it changing + this.options.chunkFilename = filename.replace( + /(^|\/)([^/]*(?:\?|$))/, + '$1[id].$2' + ) + } + } + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap(pluginName, (compilation) => { + compilation.dependencyFactories.set(CssDependency, new CssModuleFactory()) + + compilation.dependencyTemplates.set( + CssDependency, + new CssDependencyTemplate() + ) + + const renderManifestFn = (result, { chunk }) => { + const renderedModules = Array.from( + getModulesIterable(compilation, chunk) + ).filter((module) => module.type === MODULE_TYPE) + + if (renderedModules.length > 0) { + result.push({ + render: () => + this.renderContentAsset( + compilation, + chunk, + renderedModules, + compilation.runtimeTemplate.requestShortener + ), + filenameTemplate: ({ chunk: chunkData }) => + this.options.moduleFilename(chunkData), + pathOptions: { + chunk, + contentHashType: MODULE_TYPE, + }, + identifier: `${pluginName}.${chunk.id}`, + hash: chunk.contentHash[MODULE_TYPE], + }) + } + } + + if (isWebpack5) { + compilation.hooks.renderManifest.tap(pluginName, renderManifestFn) + } else { + // In webpack 5 the 2 separate hooks are now one hook: `compilation.hooks.renderManifest` + // So we no longer have to double-apply the same function + compilation.mainTemplate.hooks.renderManifest.tap( + pluginName, + renderManifestFn + ) + compilation.chunkTemplate.hooks.renderManifest.tap( + pluginName, + renderManifestFn + ) + } + + const handleHashForChunk = (hash, chunk) => { + const { chunkFilename } = this.options + + if (REGEXP_CHUNKHASH.test(chunkFilename)) { + hash.update(JSON.stringify(chunk.getChunkMaps(true).hash)) + } + + if (REGEXP_CONTENTHASH.test(chunkFilename)) { + hash.update( + JSON.stringify( + chunk.getChunkMaps(true).contentHash[MODULE_TYPE] || {} + ) + ) + } + + if (REGEXP_NAME.test(chunkFilename)) { + hash.update(JSON.stringify(chunk.getChunkMaps(true).name)) + } + } + if (isWebpack5) { + const JSModulesHooks = webpack.javascript.JavascriptModulesPlugin.getCompilationHooks( + compilation + ) + JSModulesHooks.chunkHash.tap(pluginName, (chunk, hash) => { + if (!chunk.hasRuntime()) return + return handleHashForChunk(hash, chunk) + }) + } else { + compilation.mainTemplate.hooks.hashForChunk.tap( + pluginName, + handleHashForChunk + ) + } + + compilation.hooks.contentHash.tap(pluginName, (chunk) => { + const { outputOptions } = compilation + const { hashFunction, hashDigest, hashDigestLength } = outputOptions + const hash = createHash(hashFunction) + + const modules = getModulesIterable(compilation, chunk) + + if (modules) { + if (isWebpack5) { + const xor = new (require('webpack/lib/util/StringXor'))() + for (const m of modules) { + if (m.type === MODULE_TYPE) { + xor.add(compilation.chunkGraph.getModuleHash(m, chunk.runtime)) + } + } + xor.updateHash(hash) + } else { + for (const m of modules) { + if (m.type === MODULE_TYPE) { + m.updateHash(hash) + } + } + } + } + + const { contentHash } = chunk + + contentHash[MODULE_TYPE] = hash + .digest(hashDigest) + .substring(0, hashDigestLength) + }) + + const { mainTemplate } = compilation + + mainTemplate.hooks.localVars.tap(pluginName, (source, chunk) => { + const chunkMap = this.getCssChunkObject(compilation, chunk) + + if (Object.keys(chunkMap).length > 0) { + return Template.asString([ + source, + '', + '// object to store loaded CSS chunks', + 'var installedCssChunks = {', + Template.indent( + chunk.ids.map((id) => `${JSON.stringify(id)}: 0`).join(',\n') + ), + '};', + ]) + } + + return source + }) + + mainTemplate.hooks.requireEnsure.tap( + pluginName, + (source, chunk, hash) => { + const chunkMap = this.getCssChunkObject(compilation, chunk) + + if (Object.keys(chunkMap).length > 0) { + const chunkMaps = chunk.getChunkMaps() + const { crossOriginLoading } = mainTemplate.outputOptions + const linkHrefPath = mainTemplate.getAssetPath( + JSON.stringify(this.options.chunkFilename), + { + hash: `" + ${mainTemplate.renderCurrentHashCode(hash)} + "`, + hashWithLength: (length) => + `" + ${mainTemplate.renderCurrentHashCode(hash, length)} + "`, + chunk: { + id: '" + chunkId + "', + hash: `" + ${JSON.stringify(chunkMaps.hash)}[chunkId] + "`, + hashWithLength(length) { + const shortChunkHashMap = Object.create(null) + + for (const chunkId of Object.keys(chunkMaps.hash)) { + if (typeof chunkMaps.hash[chunkId] === 'string') { + shortChunkHashMap[chunkId] = chunkMaps.hash[ + chunkId + ].substring(0, length) + } + } + + return `" + ${JSON.stringify( + shortChunkHashMap + )}[chunkId] + "` + }, + contentHash: { + [MODULE_TYPE]: `" + ${JSON.stringify( + chunkMaps.contentHash[MODULE_TYPE] + )}[chunkId] + "`, + }, + contentHashWithLength: { + [MODULE_TYPE]: (length) => { + const shortContentHashMap = {} + const contentHash = chunkMaps.contentHash[MODULE_TYPE] + + for (const chunkId of Object.keys(contentHash)) { + if (typeof contentHash[chunkId] === 'string') { + shortContentHashMap[chunkId] = contentHash[ + chunkId + ].substring(0, length) + } + } + + return `" + ${JSON.stringify( + shortContentHashMap + )}[chunkId] + "` + }, + }, + name: `" + (${JSON.stringify( + chunkMaps.name + )}[chunkId]||chunkId) + "`, + }, + contentHashType: MODULE_TYPE, + } + ) + + return Template.asString([ + source, + '', + `// ${pluginName} CSS loading`, + `var cssChunks = ${JSON.stringify(chunkMap)};`, + 'if(installedCssChunks[chunkId]) promises.push(installedCssChunks[chunkId]);', + 'else if(installedCssChunks[chunkId] !== 0 && cssChunks[chunkId]) {', + Template.indent([ + 'promises.push(installedCssChunks[chunkId] = new Promise(function(resolve, reject) {', + Template.indent([ + `var href = ${linkHrefPath};`, + `var fullhref = ${mainTemplate.requireFn}.p + href;`, + 'var existingLinkTags = document.getElementsByTagName("link");', + 'for(var i = 0; i < existingLinkTags.length; i++) {', + Template.indent([ + 'var tag = existingLinkTags[i];', + 'var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href");', + 'if(tag.rel === "stylesheet" && (dataHref === href || dataHref === fullhref)) return resolve();', + ]), + '}', + 'var existingStyleTags = document.getElementsByTagName("style");', + 'for(var i = 0; i < existingStyleTags.length; i++) {', + Template.indent([ + 'var tag = existingStyleTags[i];', + 'var dataHref = tag.getAttribute("data-href");', + 'if(dataHref === href || dataHref === fullhref) return resolve();', + ]), + '}', + 'var linkTag = document.createElement("link");', + 'linkTag.rel = "stylesheet";', + 'linkTag.type = "text/css";', + 'linkTag.onload = resolve;', + 'linkTag.onerror = function(event) {', + Template.indent([ + 'var request = event && event.target && event.target.src || fullhref;', + 'var err = new Error("Loading CSS chunk " + chunkId + " failed.\\n(" + request + ")");', + 'err.code = "CSS_CHUNK_LOAD_FAILED";', + 'err.request = request;', + 'delete installedCssChunks[chunkId]', + 'linkTag.parentNode.removeChild(linkTag)', + 'reject(err);', + ]), + '};', + 'linkTag.href = fullhref;', + crossOriginLoading + ? Template.asString([ + `if (linkTag.href.indexOf(window.location.origin + '/') !== 0) {`, + Template.indent( + `linkTag.crossOrigin = ${JSON.stringify( + crossOriginLoading + )};` + ), + '}', + ]) + : '', + 'var head = document.getElementsByTagName("head")[0];', + 'head.appendChild(linkTag);', + ]), + '}).then(function() {', + Template.indent(['installedCssChunks[chunkId] = 0;']), + '}));', + ]), + '}', + ]) + } + + return source + } + ) + }) + } + + getCssChunkObject(compilation, mainChunk) { + const obj = {} + + for (const chunk of mainChunk.getAllAsyncChunks()) { + for (const module of getModulesIterable(compilation, chunk)) { + if (module.type === MODULE_TYPE) { + obj[chunk.id] = 1 + break + } + } + } + + return obj + } + + renderContentAsset(compilation, chunk, modules, requestShortener) { + let usedModules + + const [chunkGroup] = chunk.groupsIterable + + const getModulePostOrderIndex = + chunkGroup.getModulePostOrderIndex || chunkGroup.getModuleIndex2 + if (typeof getModulePostOrderIndex === 'function') { + // Store dependencies for modules + const moduleDependencies = new Map(modules.map((m) => [m, new Set()])) + const moduleDependenciesReasons = new Map( + modules.map((m) => [m, new Map()]) + ) + + // Get ordered list of modules per chunk group + // This loop also gathers dependencies from the ordered lists + // Lists are in reverse order to allow to use Array.pop() + const modulesByChunkGroup = Array.from(chunk.groupsIterable, (cg) => { + const sortedModules = modules + .map((m) => { + return { + module: m, + index: isWebpack5 + ? cg.getModulePostOrderIndex(m) + : cg.getModuleIndex2(m), + } + }) + // eslint-disable-next-line no-undefined + .filter((item) => item.index !== undefined) + .sort((a, b) => b.index - a.index) + .map((item) => item.module) + + for (let i = 0; i < sortedModules.length; i++) { + const set = moduleDependencies.get(sortedModules[i]) + const reasons = moduleDependenciesReasons.get(sortedModules[i]) + + for (let j = i + 1; j < sortedModules.length; j++) { + const module = sortedModules[j] + set.add(module) + const reason = reasons.get(module) || new Set() + reason.add(cg) + reasons.set(module, reason) + } + } + + return sortedModules + }) + + // set with already included modules in correct order + usedModules = new Set() + + const unusedModulesFilter = (m) => !usedModules.has(m) + + while (usedModules.size < modules.length) { + let success = false + let bestMatch + let bestMatchDeps + + // get first module where dependencies are fulfilled + for (const list of modulesByChunkGroup) { + // skip and remove already added modules + while (list.length > 0 && usedModules.has(list[list.length - 1])) { + list.pop() + } + + // skip empty lists + if (list.length !== 0) { + const module = list[list.length - 1] + const deps = moduleDependencies.get(module) + // determine dependencies that are not yet included + const failedDeps = Array.from(deps).filter(unusedModulesFilter) + + // store best match for fallback behavior + if (!bestMatchDeps || bestMatchDeps.length > failedDeps.length) { + bestMatch = list + bestMatchDeps = failedDeps + } + + if (failedDeps.length === 0) { + // use this module and remove it from list + usedModules.add(list.pop()) + success = true + break + } + } + } + + if (!success) { + // no module found => there is a conflict + // use list with fewest failed deps + // and emit a warning + const fallbackModule = bestMatch.pop() + + if (!this.options.ignoreOrder) { + const reasons = moduleDependenciesReasons.get(fallbackModule) + compilation.warnings.push( + new Error( + [ + `chunk ${chunk.name || chunk.id} [${pluginName}]`, + 'Conflicting order. Following module has been added:', + ` * ${fallbackModule.readableIdentifier(requestShortener)}`, + 'despite it was not able to fulfill desired ordering with these modules:', + ...bestMatchDeps.map((m) => { + const goodReasonsMap = moduleDependenciesReasons.get(m) + const goodReasons = + goodReasonsMap && goodReasonsMap.get(fallbackModule) + const failedChunkGroups = Array.from( + reasons.get(m), + (cg) => cg.name + ).join(', ') + const goodChunkGroups = + goodReasons && + Array.from(goodReasons, (cg) => cg.name).join(', ') + return [ + ` * ${m.readableIdentifier(requestShortener)}`, + ` - couldn't fulfill desired order of chunk group(s) ${failedChunkGroups}`, + goodChunkGroups && + ` - while fulfilling desired order of chunk group(s) ${goodChunkGroups}`, + ] + .filter(Boolean) + .join('\n') + }), + ].join('\n') + ) + ) + } + + usedModules.add(fallbackModule) + } + } + } else { + // fallback for older webpack versions + // (to avoid a breaking change) + // TODO remove this in next major version + // and increase minimum webpack version to 4.12.0 + modules.sort((a, b) => a.index2 - b.index2) + usedModules = modules + } + + const source = new ConcatSource() + const externalsSource = new ConcatSource() + + for (const m of usedModules) { + if (/^@import url/.test(m.content)) { + // HACK for IE + // http://stackoverflow.com/a/14676665/1458162 + let { content } = m + + if (m.media) { + // insert media into the @import + // this is rar + // TODO improve this and parse the CSS to support multiple medias + content = content.replace(/;|\s*$/, m.media) + } + + externalsSource.add(content) + externalsSource.add('\n') + } else { + if (m.media) { + source.add(`@media ${m.media} {\n`) + } + + if (m.sourceMap) { + source.add( + new SourceMapSource( + m.content, + m.readableIdentifier(requestShortener), + m.sourceMap + ) + ) + } else { + source.add( + new OriginalSource( + m.content, + m.readableIdentifier(requestShortener) + ) + ) + } + source.add('\n') + + if (m.media) { + source.add('}\n') + } + } + } + + return new ConcatSource(externalsSource, source) + } +} + +MiniCssExtractPlugin.loader = require.resolve('./loader') + +export default MiniCssExtractPlugin diff --git a/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/loader.js b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/loader.js new file mode 100644 index 0000000000000..104497739266e --- /dev/null +++ b/packages/next/build/webpack/plugins/mini-css-extract-plugin/src/loader.js @@ -0,0 +1,235 @@ +import NativeModule from 'module' + +import loaderUtils from 'loader-utils' +import webpack from 'webpack' +import NodeTargetPlugin from 'webpack/lib/node/NodeTargetPlugin' + +import CssDependency from './CssDependency' + +const isWebpack5 = parseInt(webpack.version) === 5 +const pluginName = 'mini-css-extract-plugin' + +function evalModuleCode(loaderContext, code, filename) { + const module = new NativeModule(filename, loaderContext) + + module.paths = NativeModule._nodeModulePaths(loaderContext.context) // eslint-disable-line no-underscore-dangle + module.filename = filename + module._compile(code, filename) // eslint-disable-line no-underscore-dangle + + return module.exports +} + +function getModuleId(compilation, module) { + if (isWebpack5) { + return compilation.chunkGraph.getModuleId(module) + } + + return module.id +} + +function findModuleById(compilation, id) { + for (const module of compilation.modules) { + if (getModuleId(compilation, module) === id) { + return module + } + } + + return null +} + +export function pitch(request) { + const options = loaderUtils.getOptions(this) || {} + + const loaders = this.loaders.slice(this.loaderIndex + 1) + + this.addDependency(this.resourcePath) + + const childFilename = '*' + const publicPath = + typeof options.publicPath === 'string' + ? options.publicPath === '' || options.publicPath.endsWith('/') + ? options.publicPath + : `${options.publicPath}/` + : typeof options.publicPath === 'function' + ? options.publicPath(this.resourcePath, this.rootContext) + : this._compilation.outputOptions.publicPath + const outputOptions = { + filename: childFilename, + publicPath, + library: { + type: 'commonjs2', + name: null, + }, + } + const childCompiler = this._compilation.createChildCompiler( + `${pluginName} ${request}`, + outputOptions + ) + + new webpack.node.NodeTemplatePlugin(outputOptions).apply(childCompiler) + if (isWebpack5) { + new webpack.library.EnableLibraryPlugin(outputOptions.library.type).apply( + childCompiler + ) + } else { + new webpack.LibraryTemplatePlugin(null, 'commonjs2').apply(childCompiler) + } + new NodeTargetPlugin().apply(childCompiler) + new (isWebpack5 ? webpack.EntryPlugin : webpack.SingleEntryPlugin)( + this.context, + `!!${request}`, + pluginName + ).apply(childCompiler) + new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }).apply( + childCompiler + ) + + let source + + childCompiler.hooks.thisCompilation.tap( + `${pluginName} loader`, + (compilation) => { + const hook = isWebpack5 + ? webpack.NormalModule.getCompilationHooks(compilation).loader + : compilation.hooks.normalModuleLoader + hook.tap(`${pluginName} loader`, (loaderContext, module) => { + // eslint-disable-next-line no-param-reassign + loaderContext.emitFile = this.emitFile + + if (module.request === request) { + // eslint-disable-next-line no-param-reassign + module.loaders = loaders.map((loader) => { + return { + loader: loader.path, + options: loader.options, + ident: loader.ident, + } + }) + } + }) + + if (isWebpack5) { + compilation.hooks.processAssets.tap( + { + name: pluginName, + // @ts-ignore TODO: Remove ignore when webpack 5 is stable + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + (assets) => { + source = assets[childFilename] && assets[childFilename].source() + + // Remove all chunk assets + Object.keys(assets).forEach((file) => delete assets[file]) + } + ) + } + } + ) + + // webpack 5 case is covered in hooks.thisCompilation above + if (!isWebpack5) { + childCompiler.hooks.afterCompile.tap(pluginName, (compilation) => { + source = + compilation.assets[childFilename] && + compilation.assets[childFilename].source() + + // Remove all chunk assets + compilation.chunks.forEach((chunk) => { + chunk.files.forEach((file) => { + delete compilation.assets[file] // eslint-disable-line no-param-reassign + }) + }) + }) + } + + const callback = this.async() + + childCompiler.runAsChild((err, entries, compilation) => { + const addDependencies = (dependencies) => { + if (!Array.isArray(dependencies) && dependencies != null) { + throw new Error( + `Exported value was not extracted as an array: ${JSON.stringify( + dependencies + )}` + ) + } + + const identifierCountMap = new Map() + + for (const dependency of dependencies) { + const count = identifierCountMap.get(dependency.identifier) || 0 + + this._module.addDependency( + new CssDependency(dependency, dependency.context, count) + ) + identifierCountMap.set(dependency.identifier, count + 1) + } + } + + if (err) { + return callback(err) + } + + if (compilation.errors.length > 0) { + return callback(compilation.errors[0]) + } + + compilation.fileDependencies.forEach((dep) => { + this.addDependency(dep) + }, this) + + compilation.contextDependencies.forEach((dep) => { + this.addContextDependency(dep) + }, this) + + if (!source) { + return callback(new Error("Didn't get a result from child compiler")) + } + + let locals + + try { + let dependencies + let exports = evalModuleCode(this, source, request) + // eslint-disable-next-line no-underscore-dangle + exports = exports.__esModule ? exports.default : exports + locals = exports && exports.locals + if (!Array.isArray(exports)) { + dependencies = [[null, exports]] + } else { + dependencies = exports.map(([id, content, media, sourceMap]) => { + const module = findModuleById(compilation, id) + + return { + identifier: module.identifier(), + context: module.context, + content, + media, + sourceMap, + } + }) + } + addDependencies(dependencies) + } catch (e) { + return callback(e) + } + + const esModule = + typeof options.esModule !== 'undefined' ? options.esModule : false + const result = locals + ? `\n${esModule ? 'export default' : 'module.exports ='} ${JSON.stringify( + locals + )};` + : esModule + ? `\nexport {};` + : '' + + let resultSource = `// extracted by ${pluginName}` + + resultSource += result + + return callback(null, resultSource) + }) +} + +export default function () {} diff --git a/packages/next/build/webpack/plugins/nextjs-ssr-module-cache.ts b/packages/next/build/webpack/plugins/nextjs-ssr-module-cache.ts index ec00655b23beb..9953a65780a61 100644 --- a/packages/next/build/webpack/plugins/nextjs-ssr-module-cache.ts +++ b/packages/next/build/webpack/plugins/nextjs-ssr-module-cache.ts @@ -1,9 +1,12 @@ import webpack from 'webpack' -import { RawSource } from 'webpack-sources' +import sources from 'webpack-sources' import { join, relative, dirname } from 'path' import getRouteFromEntrypoint from '../../../next-server/server/get-route-from-entrypoint' const SSR_MODULE_CACHE_FILENAME = 'ssr-module-cache.js' +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource } = webpack.sources || sources + // By default webpack keeps initialized modules per-module. // This means that if you have 2 entrypoints loaded into the same app // they will *not* share the same instance diff --git a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts index 94557901c74ba..fbd5b332c4ded 100644 --- a/packages/next/build/webpack/plugins/pages-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/pages-manifest-plugin.ts @@ -1,10 +1,13 @@ import webpack, { Compiler, Plugin } from 'webpack' -import { RawSource } from 'webpack-sources' +import sources from 'webpack-sources' import { PAGES_MANIFEST } from '../../../next-server/lib/constants' import getRouteFromEntrypoint from '../../../next-server/server/get-route-from-entrypoint' export type PagesManifest = { [page: string]: string } +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource } = webpack.sources || sources + const isWebpack5 = parseInt(webpack.version!) === 5 // This plugin creates a pages-manifest.json from page entrypoints. diff --git a/packages/next/build/webpack/plugins/react-loadable-plugin.ts b/packages/next/build/webpack/plugins/react-loadable-plugin.ts index d39f78a943ed2..bb6810c094dfe 100644 --- a/packages/next/build/webpack/plugins/react-loadable-plugin.ts +++ b/packages/next/build/webpack/plugins/react-loadable-plugin.ts @@ -26,6 +26,10 @@ import webpack, { // eslint-disable-next-line @typescript-eslint/no-unused-vars compilation as CompilationType, } from 'webpack' +import sources from 'webpack-sources' + +// @ts-ignore: TODO: remove ignore when webpack 5 is stable +const { RawSource } = webpack.sources || sources const isWebpack5 = parseInt(webpack.version!) === 5 @@ -108,15 +112,8 @@ export class ReactLoadablePlugin { createAssets(compiler: any, compilation: any, assets: any) { const manifest = buildManifest(compiler, compilation) - var json = JSON.stringify(manifest, null, 2) - assets[this.filename] = { - source() { - return json - }, - size() { - return json.length - }, - } + // @ts-ignore: TODO: remove when webpack 5 is stable + assets[this.filename] = new RawSource(JSON.stringify(manifest, null, 2)) return assets } diff --git a/packages/next/cli/next-build.ts b/packages/next/cli/next-build.ts index ff8d0bfdbd593..1453ac754744b 100755 --- a/packages/next/cli/next-build.ts +++ b/packages/next/cli/next-build.ts @@ -12,8 +12,10 @@ const nextBuild: cliCommand = (argv) => { // Types '--help': Boolean, '--profile': Boolean, + '--debug': Boolean, // Aliases '-h': '--help', + '-d': '--debug', } let args: arg.Result @@ -53,7 +55,7 @@ const nextBuild: cliCommand = (argv) => { printAndExit(`> No such directory exists as the project root: ${dir}`) } - build(dir, null, args['--profile']) + build(dir, null, args['--profile'], args['--debug']) .then(() => process.exit(0)) .catch((err) => { console.error('') diff --git a/packages/next/cli/next-dev.ts b/packages/next/cli/next-dev.ts index 88bc9fbf7cb34..53fa86d444949 100755 --- a/packages/next/cli/next-dev.ts +++ b/packages/next/cli/next-dev.ts @@ -30,7 +30,6 @@ const nextDev: cliCommand = (argv) => { throw error } if (args['--help']) { - // tslint:disable-next-line console.log(` Description Starts the application in development mode (hot-code reloading, error @@ -67,7 +66,11 @@ const nextDev: cliCommand = (argv) => { cwd: dir, name: 'react', }) - if (reactVersion && semver.lt(reactVersion, '16.10.0')) { + if ( + reactVersion && + semver.lt(reactVersion, '16.10.0') && + semver.coerce(reactVersion)?.version !== '0.0.0' + ) { Log.warn( 'Fast Refresh is disabled in your application due to an outdated `react` version. Please upgrade 16.10 or newer!' + ' Read more: https://err.sh/next.js/react-version' @@ -77,13 +80,29 @@ const nextDev: cliCommand = (argv) => { cwd: dir, name: 'react-dom', }) - if (reactDomVersion && semver.lt(reactDomVersion, '16.10.0')) { + if ( + reactDomVersion && + semver.lt(reactDomVersion, '16.10.0') && + semver.coerce(reactDomVersion)?.version !== '0.0.0' + ) { Log.warn( 'Fast Refresh is disabled in your application due to an outdated `react-dom` version. Please upgrade 16.10 or newer!' + ' Read more: https://err.sh/next.js/react-version' ) } } + + const [sassVersion, nodeSassVersion] = await Promise.all([ + getPackageVersion({ cwd: dir, name: 'sass' }), + getPackageVersion({ cwd: dir, name: 'node-sass' }), + ]) + if (sassVersion && nodeSassVersion) { + Log.warn( + 'Your project has both `sass` and `node-sass` installed as dependencies, but should only use one or the other. ' + + 'Please remove the `node-sass` dependency from your project. ' + + ' Read more: https://err.sh/next.js/duplicate-sass' + ) + } } const port = args['--port'] || 3000 @@ -119,10 +138,8 @@ const nextDev: cliCommand = (argv) => { errorMessage += `\nUse \`npm run ${nextScript[0]} -- -p \`.` } } - // tslint:disable-next-line console.error(errorMessage) } else { - // tslint:disable-next-line console.error(err) } process.nextTick(() => process.exit(1)) diff --git a/packages/next/cli/next-export.ts b/packages/next/cli/next-export.ts index 55a959892ceb1..f13533bd26095 100755 --- a/packages/next/cli/next-export.ts +++ b/packages/next/cli/next-export.ts @@ -29,7 +29,6 @@ const nextExport: cliCommand = (argv) => { throw error } if (args['--help']) { - // tslint:disable-next-line console.log(` Description Exports the application for production deployment diff --git a/packages/next/cli/next-start.ts b/packages/next/cli/next-start.ts index 78a71bb95dc9f..d84e04ebada2e 100755 --- a/packages/next/cli/next-start.ts +++ b/packages/next/cli/next-start.ts @@ -29,7 +29,6 @@ const nextStart: cliCommand = (argv) => { throw error } if (args['--help']) { - // tslint:disable-next-line console.log(` Description Starts the application in production mode. @@ -53,14 +52,12 @@ const nextStart: cliCommand = (argv) => { const port = args['--port'] || 3000 startServer({ dir }, port, args['--hostname']) .then(async (app) => { - // tslint:disable-next-line Log.ready( `started server on http://${args['--hostname'] || 'localhost'}:${port}` ) await app.prepare() }) .catch((err) => { - // tslint:disable-next-line console.error(err) process.exit(1) }) diff --git a/packages/next/client/dev/error-overlay/eventsource.js b/packages/next/client/dev/error-overlay/eventsource.js index dd87e84a64064..163cffc98e0b2 100644 --- a/packages/next/client/dev/error-overlay/eventsource.js +++ b/packages/next/client/dev/error-overlay/eventsource.js @@ -33,9 +33,11 @@ function EventSourceWrapper(options) { for (var i = 0; i < listeners.length; i++) { listeners[i](event) } - if (event.data.indexOf('action') !== -1) { - eventCallbacks.forEach((cb) => cb(event)) - } + + eventCallbacks.forEach((cb) => { + if (!cb.unfiltered && event.data.indexOf('action') === -1) return + cb(event) + }) } function handleDisconnect() { diff --git a/packages/next/client/head-manager.js b/packages/next/client/head-manager.ts similarity index 69% rename from packages/next/client/head-manager.js rename to packages/next/client/head-manager.ts index eccf2fc1a4aa9..424613f75db21 100644 --- a/packages/next/client/head-manager.js +++ b/packages/next/client/head-manager.ts @@ -1,11 +1,11 @@ -const DOMAttributeNames = { +const DOMAttributeNames: Record = { acceptCharset: 'accept-charset', className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv', } -function reactElementToDOM({ type, props }) { +function reactElementToDOM({ type, props }: JSX.Element): HTMLElement { const el = document.createElement(type) for (const p in props) { if (!props.hasOwnProperty(p)) continue @@ -27,9 +27,11 @@ function reactElementToDOM({ type, props }) { return el } -function updateElements(type, components) { +function updateElements(type: string, components: JSX.Element[]) { const headEl = document.getElementsByTagName('head')[0] - const headCountEl = headEl.querySelector('meta[name=next-head-count]') + const headCountEl: HTMLMetaElement = headEl.querySelector( + 'meta[name=next-head-count]' + ) as HTMLMetaElement if (process.env.NODE_ENV !== 'production') { if (!headCountEl) { console.error( @@ -40,44 +42,46 @@ function updateElements(type, components) { } const headCount = Number(headCountEl.content) - const oldTags = [] + const oldTags: Element[] = [] for ( let i = 0, j = headCountEl.previousElementSibling; i < headCount; - i++, j = j.previousElementSibling + i++, j = j!.previousElementSibling ) { - if (j.tagName.toLowerCase() === type) { - oldTags.push(j) + if (j!.tagName.toLowerCase() === type) { + oldTags.push(j!) } } - const newTags = components.map(reactElementToDOM).filter((newTag) => { - for (let k = 0, len = oldTags.length; k < len; k++) { - const oldTag = oldTags[k] - if (oldTag.isEqualNode(newTag)) { - oldTags.splice(k, 1) - return false + const newTags = (components.map(reactElementToDOM) as HTMLElement[]).filter( + (newTag) => { + for (let k = 0, len = oldTags.length; k < len; k++) { + const oldTag = oldTags[k] + if (oldTag.isEqualNode(newTag)) { + oldTags.splice(k, 1) + return false + } } + return true } - return true - }) + ) - oldTags.forEach((t) => t.parentNode.removeChild(t)) + oldTags.forEach((t) => t.parentNode!.removeChild(t)) newTags.forEach((t) => headEl.insertBefore(t, headCountEl)) headCountEl.content = (headCount - oldTags.length + newTags.length).toString() } export default function initHeadManager() { - let updatePromise = null + let updatePromise: Promise | null = null return { mountedInstances: new Set(), - updateHead: (head) => { + updateHead: (head: JSX.Element[]) => { const promise = (updatePromise = Promise.resolve().then(() => { if (promise !== updatePromise) return updatePromise = null - const tags = {} + const tags: Record = {} head.forEach((h) => { const components = tags[h.type] || [] diff --git a/packages/next/client/index.js b/packages/next/client/index.tsx similarity index 57% rename from packages/next/client/index.js rename to packages/next/client/index.tsx index 4314dead73b79..9fcfaf39140c3 100644 --- a/packages/next/client/index.js +++ b/packages/next/client/index.tsx @@ -1,27 +1,53 @@ /* global location */ -import { createRouter, makePublicRouterInstance } from 'next/router' -import * as querystring from '../next-server/lib/router/utils/querystring' import React from 'react' import ReactDOM from 'react-dom' import { HeadManagerContext } from '../next-server/lib/head-manager-context' import mitt from '../next-server/lib/mitt' import { RouterContext } from '../next-server/lib/router-context' +import { delBasePath, hasBasePath } from '../next-server/lib/router/router' +import type Router from '../next-server/lib/router/router' +import type { + AppComponent, + AppProps, + PrivateRouteInfo, +} from '../next-server/lib/router/router' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' +import * as querystring from '../next-server/lib/router/utils/querystring' import * as envConfig from '../next-server/lib/runtime-config' import { getURL, loadGetInitialProps, ST } from '../next-server/lib/utils' -import { hasBasePath, delBasePath } from '../next-server/lib/router/router' +import type { NEXT_DATA } from '../next-server/lib/utils' import initHeadManager from './head-manager' -import PageLoader from './page-loader' +import PageLoader, { createLink } from './page-loader' import measureWebVitals from './performance-relayer' +import { createRouter, makePublicRouterInstance } from './router' /// +declare let __webpack_public_path__: string + +declare global { + interface Window { + /* test fns */ + __NEXT_HYDRATED?: boolean + __NEXT_HYDRATED_CB?: () => void + + /* prod */ + __NEXT_PRELOADREADY?: (ids?: string[]) => void + __NEXT_DATA__: NEXT_DATA + __NEXT_P: any[] + } +} + +type RenderRouteInfo = PrivateRouteInfo & { App: AppComponent } +type RenderErrorProps = Omit + if (!('finally' in Promise.prototype)) { - // eslint-disable-next-line no-extend-native - Promise.prototype.finally = require('next/dist/build/polyfills/finally-polyfill.min') + ;(Promise.prototype as PromiseConstructor['prototype']).finally = require('next/dist/build/polyfills/finally-polyfill.min') } -const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent) +const data: typeof window['__NEXT_DATA__'] = JSON.parse( + document.getElementById('__NEXT_DATA__')!.textContent! +) window.__NEXT_DATA__ = data export const version = process.env.__NEXT_VERSION @@ -56,28 +82,40 @@ if (hasBasePath(asPath)) { asPath = delBasePath(asPath) } -const pageLoader = new PageLoader(buildId, prefix, page) -const register = ([r, f]) => pageLoader.registerPage(r, f) +type RegisterFn = (input: [string, () => void]) => void + +const pageLoader = new PageLoader( + buildId, + prefix, + page, + [].slice + .call(document.querySelectorAll('link[rel=stylesheet][data-n-p]')) + .map((e: HTMLLinkElement) => e.getAttribute('href')!) +) +const register: RegisterFn = ([r, f]) => pageLoader.registerPage(r, f) if (window.__NEXT_P) { // Defer page registration for another tick. This will increase the overall // latency in hydrating the page, but reduce the total blocking time. window.__NEXT_P.map((p) => setTimeout(() => register(p), 0)) } window.__NEXT_P = [] -window.__NEXT_P.push = register +;(window.__NEXT_P as any).push = register const headManager = initHeadManager() const appElement = document.getElementById('__next') -let lastAppProps -let lastRenderReject -let webpackHMR -export let router -let CachedComponent -let CachedApp, onPerfEntry - -class Container extends React.Component { - componentDidCatch(componentErr, info) { +let lastAppProps: AppProps +let lastRenderReject: (() => void) | null +let webpackHMR: any +export let router: Router +let CachedComponent: React.ComponentType +let cachedStyleSheets: string[] +let CachedApp: AppComponent, onPerfEntry: (metric: any) => void + +class Container extends React.Component<{ + fn: (err: Error, info?: any) => void +}> { + componentDidCatch(componentErr: Error, info: any) { this.props.fn(componentErr, info) } @@ -107,6 +145,7 @@ class Container extends React.Component { ), asPath, { + // @ts-ignore // WARNING: `_h` is an internal option for handing Next.js // client-side hydration. Your app should _never_ use this property. // It may change at any time without notice. @@ -149,8 +188,7 @@ class Container extends React.Component { render() { if (process.env.NODE_ENV === 'production') { return this.props.children - } - if (process.env.NODE_ENV !== 'production') { + } else { const { ReactDevOverlay } = require('@next/react-dev-overlay/lib/client') return {this.props.children} } @@ -159,13 +197,13 @@ class Container extends React.Component { export const emitter = mitt() -export default async ({ webpackHMR: passedWebpackHMR } = {}) => { +export default async (opts: { webpackHMR?: any } = {}) => { // This makes sure this specific lines are removed in production if (process.env.NODE_ENV === 'development') { - webpackHMR = passedWebpackHMR + webpackHMR = opts.webpackHMR } const { page: app, mod } = await pageLoader.loadPage('/_app') - CachedApp = app + CachedApp = app as AppComponent if (mod && mod.reportWebVitals) { onPerfEntry = ({ @@ -203,7 +241,10 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { let initialErr = hydrateErr try { - ;({ page: CachedComponent } = await pageLoader.loadPage(page)) + ;({ + page: CachedComponent, + styleSheets: cachedStyleSheets, + } = await pageLoader.loadPage(page)) if (process.env.NODE_ENV !== 'production') { const { isValidElementType } = require('react-is') @@ -230,13 +271,13 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { // Generate a new error object. We `throw` it because some browsers // will set the `stack` when thrown, and we want to ensure ours is // not overridden when we re-throw it below. - throw new Error(initialErr.message) + throw new Error(initialErr!.message) } catch (e) { error = e } - error.name = initialErr.name - error.stack = initialErr.stack + error.name = initialErr!.name + error.stack = initialErr!.stack const node = getNodeError(error) throw node @@ -261,15 +302,17 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { pageLoader, App: CachedApp, Component: CachedComponent, + initialStyleSheets: cachedStyleSheets, wrapApp, err: initialErr, - isFallback, - subscription: ({ Component, props, err }, App) => - render({ App, Component, props, err }), + isFallback: Boolean(isFallback), + subscription: ({ Component, styleSheets, props, err }, App) => + render({ App, Component, styleSheets, props, err }), }) // call init-client middleware if (process.env.__NEXT_PLUGINS) { + // @ts-ignore // eslint-disable-next-line import('next-plugin-loader?middleware=on-init-client!') .then((initClientModule) => { @@ -283,6 +326,7 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { const renderCtx = { App: CachedApp, Component: CachedComponent, + styleSheets: cachedStyleSheets, props: hydrateProps, err: initialErr, } @@ -290,14 +334,12 @@ export default async ({ webpackHMR: passedWebpackHMR } = {}) => { if (process.env.NODE_ENV === 'production') { render(renderCtx) return emitter - } - - if (process.env.NODE_ENV !== 'production') { + } else { return { emitter, render, renderCtx } } } -export async function render(renderingProps) { +export async function render(renderingProps: RenderRouteInfo) { if (renderingProps.err) { await renderError(renderingProps) return @@ -306,6 +348,11 @@ export async function render(renderingProps) { try { await doRender(renderingProps) } catch (renderErr) { + // bubble up cancelation errors + if (renderErr.cancelled) { + throw renderErr + } + if (process.env.NODE_ENV === 'development') { // Ensure this error is displayed in the overlay in development setTimeout(() => { @@ -319,7 +366,7 @@ export async function render(renderingProps) { // This method handles all runtime and debug errors. // 404 and 500 errors are special kind of errors // and they are still handle via the main render method. -export function renderError(renderErrorProps) { +export function renderError(renderErrorProps: RenderErrorProps) { const { App, err } = renderErrorProps // In development runtime errors are caught by our overlay @@ -335,10 +382,11 @@ export function renderError(renderErrorProps) { App: () => null, props: {}, Component: () => null, - err: null, + styleSheets: [], }) } if (process.env.__NEXT_PLUGINS) { + // @ts-ignore // eslint-disable-next-line import('next-plugin-loader?middleware=on-error-client!') .then((onClientErrorModule) => { @@ -354,43 +402,46 @@ export function renderError(renderErrorProps) { // Make sure we log the error to the console, otherwise users can't track down issues. console.error(err) - return pageLoader.loadPage('/_error').then(({ page: ErrorComponent }) => { - // In production we do a normal render with the `ErrorComponent` as component. - // If we've gotten here upon initial render, we can use the props from the server. - // Otherwise, we need to call `getInitialProps` on `App` before mounting. - const AppTree = wrapApp(App) - const appCtx = { - Component: ErrorComponent, - AppTree, - router, - ctx: { err, pathname: page, query, asPath, AppTree }, - } - return Promise.resolve( - renderErrorProps.props - ? renderErrorProps.props - : loadGetInitialProps(App, appCtx) - ).then((initProps) => - doRender({ - ...renderErrorProps, - err, + return pageLoader + .loadPage('/_error') + .then(({ page: ErrorComponent, styleSheets }) => { + // In production we do a normal render with the `ErrorComponent` as component. + // If we've gotten here upon initial render, we can use the props from the server. + // Otherwise, we need to call `getInitialProps` on `App` before mounting. + const AppTree = wrapApp(App) + const appCtx = { Component: ErrorComponent, - props: initProps, - }) - ) - }) + AppTree, + router, + ctx: { err, pathname: page, query, asPath, AppTree }, + } + return Promise.resolve( + renderErrorProps.props + ? renderErrorProps.props + : loadGetInitialProps(App, appCtx) + ).then((initProps) => + doRender({ + ...renderErrorProps, + err, + Component: ErrorComponent, + styleSheets, + props: initProps, + }) + ) + }) } // If hydrate does not exist, eg in preact. let isInitialRender = typeof ReactDOM.hydrate === 'function' -let reactRoot = null -function renderReactElement(reactEl, domEl) { +let reactRoot: any = null +function renderReactElement(reactEl: JSX.Element, domEl: HTMLElement) { if (process.env.__NEXT_REACT_MODE !== 'legacy') { if (!reactRoot) { const opts = { hydrate: true } reactRoot = process.env.__NEXT_REACT_MODE === 'concurrent' - ? ReactDOM.unstable_createRoot(domEl, opts) - : ReactDOM.unstable_createBlockingRoot(domEl, opts) + ? (ReactDOM as any).unstable_createRoot(domEl, opts) + : (ReactDOM as any).unstable_createBlockingRoot(domEl, opts) } reactRoot.render(reactEl) } else { @@ -468,7 +519,9 @@ function clearMarks() { ].forEach((mark) => performance.clearMarks(mark)) } -function AppContainer({ children }) { +function AppContainer({ + children, +}: React.PropsWithChildren<{}>): React.ReactElement { return ( @@ -486,8 +539,10 @@ function AppContainer({ children }) { ) } -const wrapApp = (App) => (wrappedAppProps) => { - const appProps = { +const wrapApp = (App: AppComponent) => ( + wrappedAppProps: Record +) => { + const appProps: AppProps = { ...wrappedAppProps, Component: CachedComponent, err: hydrateErr, @@ -500,15 +555,27 @@ const wrapApp = (App) => (wrappedAppProps) => { ) } -async function doRender({ App, Component, props, err }) { +function doRender({ + App, + Component, + props, + err, + styleSheets, +}: RenderRouteInfo): Promise { Component = Component || lastAppProps.Component props = props || lastAppProps.props - const appProps = { ...props, Component, err, router } + const appProps: AppProps = { + ...props, + Component, + err, + router, + } // lastAppProps has to be set before ReactDom.render to account for ReactDom throwing an error. lastAppProps = appProps - let resolvePromise + let resolvePromise: () => void + let renderPromiseReject: () => void const renderPromise = new Promise((resolve, reject) => { if (lastRenderReject) { lastRenderReject() @@ -517,14 +584,115 @@ async function doRender({ App, Component, props, err }) { lastRenderReject = null resolve() } - lastRenderReject = () => { + renderPromiseReject = lastRenderReject = () => { lastRenderReject = null - reject() + + const error: any = new Error('Cancel rendering route') + error.cancelled = true + reject(error) } }) + // TODO: consider replacing this with real `` + `` ) } return result @@ -164,14 +164,23 @@ class ImageOptimizerMiddleware implements PostProcessMiddleware { break } } - _data.preloads.images = eligibleImages.map((el) => el.getAttribute('src')) + + _data.preloads.images = [] + + for (const imgEl of eligibleImages) { + const src = imgEl.getAttribute('src') + if (src) { + _data.preloads.images.push(src) + } + } } mutate = async (markup: string, _data: postProcessData) => { let result = markup let imagePreloadTags = _data.preloads.images .filter((imgHref) => !preloadTagAlreadyExists(markup, imgHref)) .reduce( - (acc, imgHref) => acc + ``, + (acc, imgHref) => + acc + ``, '' ) return result.replace( @@ -203,9 +212,14 @@ function imageIsNotTooSmall(imgElement: HTMLElement): boolean { return true } try { + const heightAttr = imgElement.getAttribute('height') + const widthAttr = imgElement.getAttribute('width') + if (!heightAttr || !widthAttr) { + return true + } + if ( - parseInt(imgElement.getAttribute('height')) * - parseInt(imgElement.getAttribute('width')) <= + parseInt(heightAttr) * parseInt(widthAttr) <= IMAGE_PRELOAD_SIZE_THRESHOLD ) { return false diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 825c85c79de7f..e0cf3552e9172 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -3,25 +3,28 @@ import { ParsedUrlQuery } from 'querystring' import { ComponentType } from 'react' import { UrlObject } from 'url' +import { + normalizePathTrailingSlash, + removePathTrailingSlash, +} from '../../../client/normalize-trailing-slash' +import { GoodPageCache } from '../../../client/page-loader' +import { denormalizePagePath } from '../../server/denormalize-page-path' import mitt, { MittEmitter } from '../mitt' import { AppContextType, formatWithValidation, + getLocationOrigin, getURL, loadGetInitialProps, NextPageContext, ST, - getLocationOrigin, } from '../utils' import { isDynamicRoute } from './utils/is-dynamic' +import { parseRelativeUrl } from './utils/parse-relative-url' +import { searchParamsToUrlQuery } from './utils/querystring' +import resolveRewrites from './utils/resolve-rewrites' import { getRouteMatcher } from './utils/route-matcher' import { getRouteRegex } from './utils/route-regex' -import { searchParamsToUrlQuery } from './utils/querystring' -import { parseRelativeUrl } from './utils/parse-relative-url' -import { - removePathTrailingSlash, - normalizePathTrailingSlash, -} from '../../../client/normalize-trailing-slash' interface TransitionOptions { shallow?: boolean @@ -98,6 +101,11 @@ export function resolveHref(currentPath: string, href: Url): string { } } +const PAGE_LOAD_ERROR = Symbol('PAGE_LOAD_ERROR') +export function markLoadingError(err: Error): Error { + return Object.defineProperty(err, PAGE_LOAD_ERROR, {}) +} + function prepareUrlAs(router: NextRouter, url: Url, as: Url) { // If url and as provided as an object representation, // we'll format them into the string version here. @@ -124,8 +132,6 @@ function tryParseRelativeUrl( } } -type ComponentRes = { page: ComponentType; mod: any } - export type BaseRouter = { route: string pathname: string @@ -151,16 +157,22 @@ export type PrefetchOptions = { priority?: boolean } -type RouteInfo = { +export type PrivateRouteInfo = { Component: ComponentType + styleSheets: string[] __N_SSG?: boolean __N_SSP?: boolean - props?: any + props?: Record err?: Error error?: any } -type Subscription = (data: RouteInfo, App?: ComponentType) => Promise +export type AppProps = Pick & { + router: Router +} & Record +export type AppComponent = ComponentType + +type Subscription = (data: PrivateRouteInfo, App: AppComponent) => Promise type BeforePopStateCallback = (state: NextHistoryState) => boolean @@ -205,7 +217,7 @@ function fetchNextData(dataHref: string, isServerRender: boolean) { // on a client-side transition. Otherwise, we'd get into an infinite // loop. if (!isServerRender) { - ;(err as any).code = 'PAGE_LOAD_ERROR' + markLoadingError(err) } throw err }) @@ -221,7 +233,7 @@ export default class Router implements BaseRouter { /** * Map of all components loaded in `Router` */ - components: { [pathname: string]: RouteInfo } + components: { [pathname: string]: PrivateRouteInfo } // Static Data Cache sdc: { [asPath: string]: object } = {} sub: Subscription @@ -229,10 +241,11 @@ export default class Router implements BaseRouter { pageLoader: any _bps: BeforePopStateCallback | undefined events: MittEmitter - _wrapApp: (App: ComponentType) => any + _wrapApp: (App: AppComponent) => any isSsr: boolean isFallback: boolean _inFlightRoute?: string + _shallow?: boolean static events: MittEmitter = mitt() @@ -246,6 +259,7 @@ export default class Router implements BaseRouter { App, wrapApp, Component, + initialStyleSheets, err, subscription, isFallback, @@ -254,8 +268,9 @@ export default class Router implements BaseRouter { initialProps: any pageLoader: any Component: ComponentType - App: ComponentType - wrapApp: (App: ComponentType) => any + initialStyleSheets: string[] + App: AppComponent + wrapApp: (App: AppComponent) => any err?: Error isFallback: boolean } @@ -271,6 +286,7 @@ export default class Router implements BaseRouter { if (pathname !== '/_error') { this.components[this.route] = { Component, + styleSheets: initialStyleSheets, props: initialProps, err, __N_SSG: initialProps && initialProps.__N_SSG, @@ -278,7 +294,12 @@ export default class Router implements BaseRouter { } } - this.components['/_app'] = { Component: App } + this.components['/_app'] = { + Component: App as ComponentType, + styleSheets: [ + /* /_app does not need its stylesheets managed */ + ], + } // Backwards compat for Router.router.events // TODO: Should be remove the following major version as it was never documented @@ -390,32 +411,14 @@ export default class Router implements BaseRouter { return } - this.change('replaceState', url, as, options) - } - - update(route: string, mod: any) { - const Component: ComponentType = mod.default || mod - const data = this.components[route] - if (!data) { - throw new Error(`Cannot update unavailable route: ${route}`) - } - - const newData = Object.assign({}, data, { - Component, - __N_SSG: mod.__N_SSG, - __N_SSP: mod.__N_SSP, - }) - this.components[route] = newData - - // pages/_app.js updated - if (route === '/_app') { - this.notify(this.components[this.route]) - return - } - - if (route === this.route) { - this.notify(newData) - } + this.change( + 'replaceState', + url, + as, + Object.assign({}, options, { + shallow: options.shallow && this._shallow, + }) + ) } reload(): void { @@ -486,6 +489,7 @@ export default class Router implements BaseRouter { if (!(options as any)._h && this.onlyAHashChange(cleanedAs)) { this.asPath = cleanedAs Router.events.emit('hashChangeStart', as) + // TODO: do we need the resolved href when only a hash change? this.changeState(method, url, as, options) this.scrollToHash(cleanedAs) this.notify(this.components[this.route]) @@ -493,11 +497,25 @@ export default class Router implements BaseRouter { return true } - const parsed = tryParseRelativeUrl(url) + // The build manifest needs to be loaded before auto-static dynamic pages + // get their query parameters to allow ensuring they can be parsed properly + // when rewritten to + const pages = await this.pageLoader.getPageList() + const { __rewrites: rewrites } = await this.pageLoader.promisedBuildManifest + + let parsed = tryParseRelativeUrl(url) if (!parsed) return false let { pathname, searchParams } = parsed + + parsed = this._resolveHref(parsed, pages) as typeof parsed + + if (parsed.pathname !== pathname) { + pathname = parsed.pathname + url = formatWithValidation(parsed) + } + const query = searchParamsToUrlQuery(searchParams) // url and as should always be prefixed with basePath by this @@ -519,8 +537,24 @@ export default class Router implements BaseRouter { const route = removePathTrailingSlash(pathname) const { shallow = false } = options + // we need to resolve the as value using rewrites for dynamic SSG + // pages to allow building the data URL correctly + let resolvedAs = as + + if (process.env.__NEXT_HAS_REWRITES) { + resolvedAs = resolveRewrites( + as, + pages, + basePath, + rewrites, + query, + (p: string) => this._resolveHref({ pathname: p }, pages).pathname! + ) + } + resolvedAs = delBasePath(resolvedAs) + if (isDynamicRoute(route)) { - const { pathname: asPathname } = parseRelativeUrl(cleanedAs) + const { pathname: asPathname } = parseRelativeUrl(resolvedAs) const routeRegex = getRouteRegex(route) const routeMatch = getRouteMatcher(routeRegex)(asPathname) if (!routeMatch) { @@ -559,7 +593,7 @@ export default class Router implements BaseRouter { as, shallow ) - const { error } = routeInfo + let { error } = routeInfo Router.events.emit('beforeHistoryChange', as) this.changeState(method, url, as, options) @@ -571,7 +605,12 @@ export default class Router implements BaseRouter { !(routeInfo.Component as any).getInitialProps } - await this.set(route, pathname!, query, cleanedAs, routeInfo) + await this.set(route, pathname!, query, cleanedAs, routeInfo).catch( + (e) => { + if (e.cancelled) error = error || e + else throw e + } + ) if (error) { Router.events.emit('routeChangeError', error, cleanedAs) @@ -613,6 +652,7 @@ export default class Router implements BaseRouter { } if (method !== 'pushState' || getURL() !== as) { + this._shallow = options.shallow window.history[method]( { url, @@ -635,13 +675,13 @@ export default class Router implements BaseRouter { query: ParsedUrlQuery, as: string, loadErrorFail?: boolean - ): Promise { + ): Promise { if (err.cancelled) { // bubble up cancellation errors throw err } - if (err.code === 'PAGE_LOAD_ERROR' || loadErrorFail) { + if (PAGE_LOAD_ERROR in err || loadErrorFail) { Router.events.emit('routeChangeError', err, as) // If we can't load the page it could be one of following reasons @@ -658,8 +698,15 @@ export default class Router implements BaseRouter { } try { - const { page: Component } = await this.fetchComponent('/_error') - const routeInfo: RouteInfo = { Component, err, error: err } + const { page: Component, styleSheets } = await this.fetchComponent( + '/_error' + ) + const routeInfo: PrivateRouteInfo = { + Component, + styleSheets, + err, + error: err, + } try { routeInfo.props = await this.getInitialProps(Component, { @@ -684,7 +731,7 @@ export default class Router implements BaseRouter { query: any, as: string, shallow: boolean = false - ): Promise { + ): Promise { try { const cachedRouteInfo = this.components[route] @@ -692,16 +739,14 @@ export default class Router implements BaseRouter { return cachedRouteInfo } - const routeInfo = cachedRouteInfo + const routeInfo: PrivateRouteInfo = cachedRouteInfo ? cachedRouteInfo - : await this.fetchComponent(route).then( - (res) => - ({ - Component: res.page, - __N_SSG: res.mod.__N_SSG, - __N_SSP: res.mod.__N_SSP, - } as RouteInfo) - ) + : await this.fetchComponent(route).then((res) => ({ + Component: res.page, + styleSheets: res.styleSheets, + __N_SSG: res.mod.__N_SSG, + __N_SSP: res.mod.__N_SSP, + })) const { Component, __N_SSG, __N_SSP } = routeInfo @@ -719,12 +764,12 @@ export default class Router implements BaseRouter { if (__N_SSG || __N_SSP) { dataHref = this.pageLoader.getDataHref( formatWithValidation({ pathname, query }), - as, + delBasePath(as), __N_SSG ) } - const props = await this._getData(() => + const props = await this._getData(() => __N_SSG ? this._getStaticData(dataHref!) : __N_SSP @@ -752,7 +797,7 @@ export default class Router implements BaseRouter { pathname: string, query: ParsedUrlQuery, as: string, - data: RouteInfo + data: PrivateRouteInfo ): Promise { this.isFallback = false @@ -819,6 +864,30 @@ export default class Router implements BaseRouter { return this.asPath !== asPath } + _resolveHref(parsedHref: UrlObject, pages: string[]) { + const { pathname } = parsedHref + const cleanPathname = denormalizePagePath(delBasePath(pathname!)) + + if (cleanPathname === '/404' || cleanPathname === '/_error') { + return parsedHref + } + + // handle resolving href for dynamic routes + if (!pages.includes(cleanPathname!)) { + // eslint-disable-next-line array-callback-return + pages.some((page) => { + if ( + isDynamicRoute(page) && + getRouteRegex(page).re.test(cleanPathname!) + ) { + parsedHref.pathname = addBasePath(page) + return true + } + }) + } + return parsedHref + } + /** * Prefetch page code, you may wait for the data during page rendering. * This feature only works in production! @@ -830,11 +899,20 @@ export default class Router implements BaseRouter { asPath: string = url, options: PrefetchOptions = {} ): Promise { - const parsed = tryParseRelativeUrl(url) + let parsed = tryParseRelativeUrl(url) if (!parsed) return - const { pathname } = parsed + let { pathname } = parsed + + const pages = await this.pageLoader.getPageList() + + parsed = this._resolveHref(parsed, pages) as typeof parsed + + if (parsed.pathname !== pathname) { + pathname = parsed.pathname + url = formatWithValidation(parsed) + } // Prefetch is not supported in development mode because it would trigger on-demand-entries if (process.env.NODE_ENV !== 'production') { @@ -848,7 +926,7 @@ export default class Router implements BaseRouter { ]) } - async fetchComponent(route: string): Promise { + async fetchComponent(route: string): Promise { let cancelled = false const cancel = (this.clc = () => { cancelled = true @@ -912,7 +990,7 @@ export default class Router implements BaseRouter { ctx: NextPageContext ): Promise { const { Component: App } = this.components['/_app'] - const AppTree = this._wrapApp(App) + const AppTree = this._wrapApp(App as AppComponent) ctx.AppTree = AppTree return loadGetInitialProps>(App, { AppTree, @@ -930,7 +1008,7 @@ export default class Router implements BaseRouter { } } - notify(data: RouteInfo): Promise { - return this.sub(data, this.components['/_app'].Component) + notify(data: PrivateRouteInfo): Promise { + return this.sub(data, this.components['/_app'].Component as AppComponent) } } diff --git a/packages/next/next-server/server/lib/path-match.ts b/packages/next/next-server/lib/router/utils/path-match.ts similarity index 76% rename from packages/next/next-server/server/lib/path-match.ts rename to packages/next/next-server/lib/router/utils/path-match.ts index 5d7f7f714ccc8..cbf785b5c6510 100644 --- a/packages/next/next-server/server/lib/path-match.ts +++ b/packages/next/next-server/lib/router/utils/path-match.ts @@ -2,16 +2,25 @@ import * as pathToRegexp from 'next/dist/compiled/path-to-regexp' export { pathToRegexp } +export const matcherOptions = { + sensitive: false, + delimiter: '/', + decode: decodeParam, +} + +export const customRouteMatcherOptions = { + ...matcherOptions, + strict: true, +} + export default (customRoute = false) => { return (path: string) => { const keys: pathToRegexp.Key[] = [] - const matcherOptions = { - sensitive: false, - delimiter: '/', - ...(customRoute ? { strict: true } : undefined), - decode: decodeParam, - } - const matcherRegex = pathToRegexp.pathToRegexp(path, keys, matcherOptions) + const matcherRegex = pathToRegexp.pathToRegexp( + path, + keys, + customRoute ? customRouteMatcherOptions : matcherOptions + ) const matcher = pathToRegexp.regexpToFunction( matcherRegex, keys, diff --git a/packages/next/next-server/lib/router/utils/prepare-destination.ts b/packages/next/next-server/lib/router/utils/prepare-destination.ts new file mode 100644 index 0000000000000..1e297a7fca89e --- /dev/null +++ b/packages/next/next-server/lib/router/utils/prepare-destination.ts @@ -0,0 +1,134 @@ +import { ParsedUrlQuery } from 'querystring' +import { searchParamsToUrlQuery } from './querystring' +import { parseRelativeUrl } from './parse-relative-url' +import * as pathToRegexp from 'next/dist/compiled/path-to-regexp' + +type Params = { [param: string]: any } + +export default function prepareDestination( + destination: string, + params: Params, + query: ParsedUrlQuery, + appendParamsToQuery: boolean, + basePath: string +) { + let parsedDestination: { + query?: ParsedUrlQuery + protocol?: string + hostname?: string + port?: string + } & ReturnType = {} as any + + if (destination.startsWith('/')) { + parsedDestination = parseRelativeUrl(destination) + } else { + const { + pathname, + searchParams, + hash, + hostname, + port, + protocol, + search, + href, + } = new URL(destination) + + parsedDestination = { + pathname, + searchParams, + hash, + protocol, + hostname, + port, + search, + href, + } + } + + parsedDestination.query = searchParamsToUrlQuery( + parsedDestination.searchParams + ) + const destQuery = parsedDestination.query + const destPath = `${parsedDestination.pathname!}${ + parsedDestination.hash || '' + }` + const destPathParamKeys: pathToRegexp.Key[] = [] + pathToRegexp.pathToRegexp(destPath, destPathParamKeys) + + const destPathParams = destPathParamKeys.map((key) => key.name) + + let destinationCompiler = pathToRegexp.compile( + destPath, + // we don't validate while compiling the destination since we should + // have already validated before we got to this point and validating + // breaks compiling destinations with named pattern params from the source + // e.g. /something:hello(.*) -> /another/:hello is broken with validation + // since compile validation is meant for reversing and not for inserting + // params from a separate path-regex into another + { validate: false } + ) + let newUrl + + // update any params in query values + for (const [key, strOrArray] of Object.entries(destQuery)) { + let value = Array.isArray(strOrArray) ? strOrArray[0] : strOrArray + if (value) { + // the value needs to start with a forward-slash to be compiled + // correctly + value = `/${value}` + const queryCompiler = pathToRegexp.compile(value, { validate: false }) + value = queryCompiler(params).substr(1) + } + destQuery[key] = value + } + + // add path params to query if it's not a redirect and not + // already defined in destination query or path + const paramKeys = Object.keys(params) + + if ( + appendParamsToQuery && + !paramKeys.some((key) => destPathParams.includes(key)) + ) { + for (const key of paramKeys) { + if (!(key in destQuery)) { + destQuery[key] = params[key] + } + } + } + + const shouldAddBasePath = destination.startsWith('/') && basePath + + try { + newUrl = `${shouldAddBasePath ? basePath : ''}${encodeURI( + destinationCompiler(params) + )}` + + const [pathname, hash] = newUrl.split('#') + parsedDestination.pathname = pathname + parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}` + delete parsedDestination.search + delete parsedDestination.searchParams + } catch (err) { + if (err.message.match(/Expected .*? to not repeat, but got an array/)) { + throw new Error( + `To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://err.sh/vercel/next.js/invalid-multi-match` + ) + } + throw err + } + + // Query merge order lowest priority to highest + // 1. initial URL query values + // 2. path segment values + // 3. destination specified query values + parsedDestination.query = { + ...query, + ...parsedDestination.query, + } + + return { + newUrl, + parsedDestination, + } +} diff --git a/packages/next/next-server/lib/router/utils/resolve-rewrites.ts b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts new file mode 100644 index 0000000000000..ad3885facb4ef --- /dev/null +++ b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts @@ -0,0 +1,52 @@ +import { ParsedUrlQuery } from 'querystring' +import pathMatch from './path-match' +import prepareDestination from './prepare-destination' +import { Rewrite } from '../../../../lib/load-custom-routes' + +const customRouteMatcher = pathMatch(true) + +export default function resolveRewrites( + asPath: string, + pages: string[], + basePath: string, + rewrites: Rewrite[], + query: ParsedUrlQuery, + resolveHref: (path: string) => string +) { + if (!pages.includes(asPath)) { + for (const rewrite of rewrites) { + const matcher = customRouteMatcher(rewrite.source) + const params = matcher(asPath) + + if (params) { + if (!rewrite.destination) { + // this is a proxied rewrite which isn't handled on the client + break + } + const destRes = prepareDestination( + rewrite.destination, + params, + query, + true, + rewrite.basePath === false ? '' : basePath + ) + asPath = destRes.parsedDestination.pathname! + Object.assign(query, destRes.parsedDestination.query) + + if (pages.includes(asPath)) { + // check if we now match a page as this means we are done + // resolving the rewrites + break + } + + // check if we match a dynamic-route, if so we break the rewrites chain + const resolvedHref = resolveHref(asPath) + + if (resolvedHref !== asPath && pages.includes(resolvedHref)) { + break + } + } + } + } + return asPath +} diff --git a/packages/next/next-server/lib/utils.ts b/packages/next/next-server/lib/utils.ts index 37e7d58a0833f..ca0f1e0d28312 100644 --- a/packages/next/next-server/lib/utils.ts +++ b/packages/next/next-server/lib/utils.ts @@ -82,7 +82,7 @@ export type BaseContext = { } export type NEXT_DATA = { - props: any + props: Record page: string query: ParsedUrlQuery buildId: string @@ -103,7 +103,6 @@ export type NEXT_DATA = { /** * `Next` context */ -// tslint:disable-next-line interface-name export interface NextPageContext { /** * Error object if encountered during rendering @@ -172,7 +171,6 @@ export type DocumentProps = DocumentInitialProps & { inAmpMode: boolean hybridAmp: boolean isDevelopment: boolean - files: string[] dynamicImports: ManifestItem[] assetPrefix?: string canonicalBase: string diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index bb75f1906de57..c9eefdca419a6 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -54,6 +54,7 @@ const defaultConfig: { [key: string]: any } = { optimizeFonts: false, optimizeImages: false, scrollRestoration: false, + unstable_webpack5cache: false, }, future: { excludeDefaultMomentLocales: false, diff --git a/packages/next/next-server/server/denormalize-page-path.ts b/packages/next/next-server/server/denormalize-page-path.ts new file mode 100644 index 0000000000000..39ba36212f699 --- /dev/null +++ b/packages/next/next-server/server/denormalize-page-path.ts @@ -0,0 +1,13 @@ +export function normalizePathSep(path: string): string { + return path.replace(/\\/g, '/') +} + +export function denormalizePagePath(page: string) { + page = normalizePathSep(page) + if (page.startsWith('/index/')) { + page = page.slice(6) + } else if (page === '/index') { + page = '/' + } + return page +} diff --git a/packages/next/next-server/server/get-page-files.ts b/packages/next/next-server/server/get-page-files.ts index 1e5543ee744da..cd7dc93e660cb 100644 --- a/packages/next/next-server/server/get-page-files.ts +++ b/packages/next/next-server/server/get-page-files.ts @@ -1,26 +1,25 @@ import { normalizePagePath, denormalizePagePath } from './normalize-page-path' export type BuildManifest = { - devFiles: string[] - ampDevFiles: string[] - polyfillFiles: string[] - lowPriorityFiles: string[] + devFiles: readonly string[] + ampDevFiles: readonly string[] + polyfillFiles: readonly string[] + lowPriorityFiles: readonly string[] pages: { - '/_app': string[] - [page: string]: string[] + '/_app': readonly string[] + [page: string]: readonly string[] } - ampFirstPages: string[] + ampFirstPages: readonly string[] } export function getPageFiles( buildManifest: BuildManifest, page: string -): string[] { +): readonly string[] { const normalizedPage = denormalizePagePath(normalizePagePath(page)) let files = buildManifest.pages[normalizedPage] if (!files) { - // tslint:disable-next-line console.warn( `Could not find files for ${normalizedPage} in .next/build-manifest.json` ) diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 8a746d249495e..195931dfd871b 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -38,7 +38,7 @@ import * as envConfig from '../lib/runtime-config' import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils' import { apiResolver, tryGetPreviewData, __ApiPreviewProps } from './api-utils' import loadConfig, { isTargetLikeServerless } from './config' -import pathMatch from './lib/path-match' +import pathMatch from '../lib/router/utils/path-match' import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { loadComponents, LoadComponentsReturnType } from './load-components' import { normalizePagePath } from './normalize-page-path' @@ -48,10 +48,10 @@ import Router, { DynamicRoutes, PageChecker, Params, - prepareDestination, route, Route, } from './router' +import prepareDestination from '../lib/router/utils/prepare-destination' import { sendPayload } from './send-payload' import { serveStatic } from './serve-static' import { IncrementalCache } from './incremental-cache' @@ -169,10 +169,11 @@ export default class Server { customServer: customServer === true ? true : undefined, ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer, basePath: this.nextConfig.basePath, - optimizeFonts: this.nextConfig.experimental.optimizeFonts, - fontManifest: this.nextConfig.experimental.optimizeFonts - ? requireFontManifest(this.distDir, this._isLikeServerless) - : null, + optimizeFonts: this.nextConfig.experimental.optimizeFonts && !dev, + fontManifest: + this.nextConfig.experimental.optimizeFonts && !dev + ? requireFontManifest(this.distDir, this._isLikeServerless) + : null, optimizeImages: this.nextConfig.experimental.optimizeImages, } @@ -252,7 +253,6 @@ export default class Server { this.onErrorMiddleware({ err }) } if (this.quiet) return - // tslint:disable-next-line console.error(err) } @@ -783,6 +783,14 @@ export default class Server { name: 'public folder catchall', fn: async (req, res, params, parsedUrl) => { const pathParts: string[] = params.path || [] + const { basePath } = this.nextConfig + + // if basePath is defined require it be present + if (basePath) { + if (pathParts[0] !== basePath.substr(1)) return { finished: false } + pathParts.shift() + } + const path = `/${pathParts.join('/')}` if (publicFiles.has(path)) { @@ -1148,7 +1156,6 @@ export default class Server { fallbackMode !== 'blocking' && ssgCacheKey && !didRespond && - !isDataReq && !isPreviewMode && isDynamicPathname && // Development should trigger fallback when the path is not in @@ -1165,27 +1172,29 @@ export default class Server { throw new NoFallbackError() } - let html: string + if (!isDataReq) { + let html: string - // Production already emitted the fallback as static HTML. - if (isProduction) { - html = await this.incrementalCache.getFallback(pathname) - } - // We need to generate the fallback on-demand for development. - else { - query.__nextFallback = 'true' - if (isLikeServerless) { - prepareServerlessUrl(req, query) + // Production already emitted the fallback as static HTML. + if (isProduction) { + html = await this.incrementalCache.getFallback(pathname) + } + // We need to generate the fallback on-demand for development. + else { + query.__nextFallback = 'true' + if (isLikeServerless) { + prepareServerlessUrl(req, query) + } + const { value: renderResult } = await doRender() + html = renderResult.html } - const { value: renderResult } = await doRender() - html = renderResult.html - } - sendPayload(req, res, html, 'html', { - generateEtags: this.renderOpts.generateEtags, - poweredByHeader: this.renderOpts.poweredByHeader, - }) - return null + sendPayload(req, res, html, 'html', { + generateEtags: this.renderOpts.generateEtags, + poweredByHeader: this.renderOpts.poweredByHeader, + }) + return null + } } const { diff --git a/packages/next/next-server/server/normalize-page-path.ts b/packages/next/next-server/server/normalize-page-path.ts index bc8da584fc046..95e8d9a596c66 100644 --- a/packages/next/next-server/server/normalize-page-path.ts +++ b/packages/next/next-server/server/normalize-page-path.ts @@ -1,8 +1,6 @@ import { posix } from 'path' -export function normalizePathSep(path: string): string { - return path.replace(/\\/g, '/') -} +export { normalizePathSep, denormalizePagePath } from './denormalize-page-path' export function normalizePagePath(page: string): string { // If the page is `/` we need to append `/index`, otherwise the returned directory root will be bundles instead of pages @@ -24,13 +22,3 @@ export function normalizePagePath(page: string): string { } return page } - -export function denormalizePagePath(page: string) { - page = normalizePathSep(page) - if (page.startsWith('/index/')) { - page = page.slice(6) - } else if (page === '/index') { - page = '/' - } - return page -} diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index af3647e664971..c473467d6b875 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -27,6 +27,7 @@ import { HeadManagerContext } from '../lib/head-manager-context' import Loadable from '../lib/loadable' import { LoadableContext } from '../lib/loadable-context' import mitt, { MittEmitter } from '../lib/mitt' +import postProcess from '../lib/post-process' import { RouterContext } from '../lib/router-context' import { NextRouter } from '../lib/router/router' import { isDynamicRoute } from '../lib/router/utils/is-dynamic' @@ -42,11 +43,11 @@ import { RenderPage, } from '../lib/utils' import { tryGetPreviewData, __ApiPreviewProps } from './api-utils' -import { getPageFiles } from './get-page-files' +import { denormalizePagePath } from './denormalize-page-path' +import { FontManifest, getFontDefinitionFromManifest } from './font-utils' import { LoadComponentsReturnType, ManifestItem } from './load-components' +import { normalizePagePath } from './normalize-page-path' import optimizeAmp from './optimize-amp' -import postProcess from '../lib/post-process' -import { FontManifest, getFontDefinitionFromManifest } from './font-utils' function noRouter() { const message = @@ -176,7 +177,6 @@ function renderDocument( ampState, inAmpMode, hybridAmp, - files, dynamicImports, headTags, gsp, @@ -198,7 +198,6 @@ function renderDocument( hybridAmp: boolean dynamicImportsIds: string[] dynamicImports: ManifestItem[] - files: string[] headTags: any isFallback?: boolean gsp?: boolean @@ -240,7 +239,6 @@ function renderDocument( inAmpMode, isDevelopment: !!dev, hybridAmp, - files, dynamicImports, assetPrefix, headTags, @@ -329,7 +327,6 @@ export async function renderToHTML( const headTags = (...args: any) => callMiddleware('headTags', args) - const didRewrite = (req as any)._nextDidRewrite const isFallback = !!query.__nextFallback delete query.__nextFallback @@ -360,23 +357,6 @@ export async function renderToHTML( } } - if ( - process.env.NODE_ENV !== 'production' && - (isAutoExport || isFallback) && - pageIsDynamic && - didRewrite - ) { - // TODO: If we decide to ship rewrites to the client we could - // solve this by running over the rewrites and getting the params. - throw new Error( - `Rewrites don't support${ - isFallback ? ' ' : ' auto-exported ' - }dynamic pages${isFallback ? ' with getStaticProps ' : ' '}yet.\n` + - `Using this will cause the page to fail to parse the params on the client\n` + - `See more info: https://err.sh/next.js/rewrite-auto-export-fallback` - ) - } - if (hasPageGetInitialProps && isSSG) { throw new Error(SSG_GET_INITIAL_PROPS_CONFLICT + ` ${pathname}`) } @@ -690,17 +670,32 @@ export async function renderToHTML( // the response might be finished on the getInitialProps call if (isResSent(res) && !isSSG) return null - // AMP First pages do not have client-side JavaScript files - const files = ampState.ampFirst - ? [] - : [ - ...new Set([ - ...getPageFiles(buildManifest, '/_app'), - ...(pathname !== '/_error' - ? getPageFiles(buildManifest, pathname) - : []), - ]), - ] + // we preload the buildManifest for auto-export dynamic pages + // to speed up hydrating query values + let filteredBuildManifest = buildManifest + if (isAutoExport && pageIsDynamic) { + const page = denormalizePagePath(normalizePagePath(pathname)) + // This code would be much cleaner using `immer` and directly pushing into + // the result from `getPageFiles`, we could maybe consider that in the + // future. + if (page in filteredBuildManifest.pages) { + filteredBuildManifest = { + ...filteredBuildManifest, + pages: { + ...filteredBuildManifest.pages, + [page]: [ + ...filteredBuildManifest.pages[page], + ...filteredBuildManifest.lowPriorityFiles.filter((f) => + f.includes('_buildManifest') + ), + ], + }, + lowPriorityFiles: filteredBuildManifest.lowPriorityFiles.filter( + (f) => !f.includes('_buildManifest') + ), + } + } + } const renderPage: RenderPage = ( options: ComponentsEnhancer = {} @@ -766,6 +761,7 @@ export async function renderToHTML( let html = renderDocument(Document, { ...renderOpts, + buildManifest: filteredBuildManifest, // Only enabled in production as development mode has features relying on HMR (style injection for example) unstable_runtimeJS: process.env.NODE_ENV === 'production' @@ -784,7 +780,6 @@ export async function renderToHTML( hybridAmp, dynamicImportsIds, dynamicImports, - files, gsp: !!getStaticProps ? true : undefined, gssp: !!getServerSideProps ? true : undefined, gip: hasPageGetInitialProps ? true : undefined, diff --git a/packages/next/next-server/server/require.ts b/packages/next/next-server/server/require.ts index 28fca96a2cbc0..50234fde9e425 100644 --- a/packages/next/next-server/server/require.ts +++ b/packages/next/next-server/server/require.ts @@ -33,7 +33,6 @@ export function getPagePath( try { page = denormalizePagePath(normalizePagePath(page)) } catch (err) { - // tslint:disable-next-line console.error(err) throw pageNotFoundError(page) } diff --git a/packages/next/next-server/server/router.ts b/packages/next/next-server/server/router.ts index f897d5720ab44..9c858ab724bc3 100644 --- a/packages/next/next-server/server/router.ts +++ b/packages/next/next-server/server/router.ts @@ -1,8 +1,8 @@ import { IncomingMessage, ServerResponse } from 'http' -import { parse as parseUrl, UrlWithParsedQuery } from 'url' -import { ParsedUrlQuery } from 'querystring' -import { compile as compilePathToRegex } from 'next/dist/compiled/path-to-regexp' -import pathMatch from './lib/path-match' +import { UrlWithParsedQuery } from 'url' + +import pathMatch from '../lib/router/utils/path-match' +import { removePathTrailingSlash } from '../../client/normalize-trailing-slash' export const route = pathMatch() @@ -37,87 +37,8 @@ export type PageChecker = (pathname: string) => Promise const customRouteTypes = new Set(['rewrite', 'redirect', 'header']) -export const prepareDestination = ( - destination: string, - params: Params, - query: ParsedUrlQuery, - appendParamsToQuery: boolean, - basePath: string -) => { - const parsedDestination = parseUrl(destination, true) - const destQuery = parsedDestination.query - let destinationCompiler = compilePathToRegex( - `${parsedDestination.pathname!}${parsedDestination.hash || ''}`, - // we don't validate while compiling the destination since we should - // have already validated before we got to this point and validating - // breaks compiling destinations with named pattern params from the source - // e.g. /something:hello(.*) -> /another/:hello is broken with validation - // since compile validation is meant for reversing and not for inserting - // params from a separate path-regex into another - { validate: false } - ) - let newUrl - - // update any params in query values - for (const [key, strOrArray] of Object.entries(destQuery)) { - let value = Array.isArray(strOrArray) ? strOrArray[0] : strOrArray - if (value) { - // the value needs to start with a forward-slash to be compiled - // correctly - value = `/${value}` - const queryCompiler = compilePathToRegex(value, { validate: false }) - value = queryCompiler(params).substr(1) - } - destQuery[key] = value - } - - // add path params to query if it's not a redirect and not - // already defined in destination query - if (appendParamsToQuery) { - for (const [name, value] of Object.entries(params)) { - if (!(name in destQuery)) { - destQuery[name] = value - } - } - } - - const shouldAddBasePath = destination.startsWith('/') && basePath - - try { - newUrl = `${shouldAddBasePath ? basePath : ''}${encodeURI( - destinationCompiler(params) - )}` - - const [pathname, hash] = newUrl.split('#') - parsedDestination.pathname = pathname - parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}` - parsedDestination.path = `${pathname}${parsedDestination.search}` - delete parsedDestination.search - } catch (err) { - if (err.message.match(/Expected .*? to not repeat, but got an array/)) { - throw new Error( - `To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://err.sh/vercel/next.js/invalid-multi-match` - ) - } - throw err - } - - // Query merge order lowest priority to highest - // 1. initial URL query values - // 2. path segment values - // 3. destination specified query values - parsedDestination.query = { - ...query, - ...parsedDestination.query, - } - - return { - newUrl, - parsedDestination, - } -} - function replaceBasePath(basePath: string, pathname: string) { + // If replace ends up replacing the full url it'll be `undefined`, meaning we have to default it to `/` return pathname!.replace(basePath, '') || '/' } @@ -212,11 +133,13 @@ export default class Router { requireBasePath: false, match: route('/:path*'), fn: async (checkerReq, checkerRes, params, parsedCheckerUrl) => { - const { pathname } = parsedCheckerUrl + let { pathname } = parsedCheckerUrl + pathname = removePathTrailingSlash(pathname || '/') if (!pathname) { return { finished: false } } + if (await memoizedPageChecker(pathname)) { return this.catchAllRoute.fn( checkerReq, @@ -247,9 +170,10 @@ export default class Router { const originalPathname = currentPathname const requireBasePath = testRoute.requireBasePath !== false const isCustomRoute = customRouteTypes.has(testRoute.type) + const isPublicFolderCatchall = testRoute.name === 'public folder catchall' + const keepBasePath = isCustomRoute || isPublicFolderCatchall - if (!isCustomRoute) { - // If replace ends up replacing the full url it'll be `undefined`, meaning we have to default it to `/` + if (!keepBasePath) { currentPathname = replaceBasePath(this.basePath, currentPathname!) } @@ -259,7 +183,7 @@ export default class Router { if (newParams) { // since we require basePath be present for non-custom-routes we // 404 here when we matched an fs route - if (!isCustomRoute) { + if (!keepBasePath) { if (!originallyHadBasePath && !(req as any)._nextDidRewrite) { if (requireBasePath) { // consider this a non-match so the 404 renders @@ -282,7 +206,7 @@ export default class Router { // since the fs route didn't match we need to re-add the basePath // to continue checking rewrites with the basePath present - if (!isCustomRoute) { + if (!keepBasePath) { parsedUrlUpdated.pathname = originalPathname } @@ -353,7 +277,6 @@ export default class Router { } } } - return false } } diff --git a/packages/next/package.json b/packages/next/package.json index 0284ab334223d..6bcf76fc4617c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "9.5.2-canary.10", + "version": "9.5.3-canary.20", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -58,7 +58,7 @@ ] }, "dependencies": { - "@ampproject/toolbox-optimizer": "2.5.14", + "@ampproject/toolbox-optimizer": "2.6.0", "@babel/code-frame": "7.8.3", "@babel/core": "7.7.7", "@babel/plugin-proposal-class-properties": "7.8.3", @@ -77,8 +77,8 @@ "@babel/preset-typescript": "7.9.0", "@babel/runtime": "7.9.6", "@babel/types": "7.9.6", - "@next/react-dev-overlay": "9.5.2-canary.10", - "@next/react-refresh-utils": "9.5.2-canary.10", + "@next/react-dev-overlay": "9.5.3-canary.20", + "@next/react-refresh-utils": "9.5.3-canary.20", "ast-types": "0.13.2", "babel-plugin-syntax-jsx": "6.18.0", "babel-plugin-transform-define": "2.0.0", @@ -86,14 +86,14 @@ "browserslist": "4.13.0", "buffer": "5.6.0", "cacache": "13.0.1", + "caniuse-lite": "^1.0.30001113", "chokidar": "2.1.8", "crypto-browserify": "3.12.0", "css-loader": "3.5.3", - "cssnano-simple": "1.0.6", + "cssnano-simple": "1.2.0", "find-cache-dir": "3.3.1", "jest-worker": "24.9.0", "loader-utils": "2.0.0", - "mini-css-extract-plugin": "0.8.0", "mkdirp": "0.5.3", "native-url": "0.3.4", "neo-async": "2.6.1", @@ -103,7 +103,6 @@ "postcss": "7.0.32", "process": "0.11.10", "prop-types": "15.7.2", - "prop-types-exact": "1.2.0", "react-is": "16.13.1", "react-refresh": "0.8.3", "resolve-url-loader": "3.1.1", @@ -124,7 +123,7 @@ "react-dom": "^16.6.0" }, "devDependencies": { - "@next/polyfill-nomodule": "9.5.2-canary.10", + "@next/polyfill-nomodule": "9.5.3-canary.20", "@taskr/clear": "1.1.0", "@taskr/esnext": "1.1.0", "@taskr/watch": "1.1.0", @@ -192,7 +191,6 @@ "is-wsl": "2.2.0", "json5": "2.1.3", "jsonwebtoken": "8.5.1", - "launch-editor": "2.2.1", "lodash.curry": "4.1.1", "lru-cache": "5.1.1", "nanoid": "2.0.3", @@ -211,8 +209,8 @@ "string-hash": "1.1.3", "strip-ansi": "6.0.0", "taskr": "1.1.0", - "terser": "4.4.2", - "terser-webpack-plugin": "3.0.8", + "terser": "5.1.0", + "terser-webpack-plugin": "4.1.0", "text-table": "0.2.0", "thread-loader": "2.1.3", "typescript": "3.8.3", diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 992bd85e8499f..96b16f7385b12 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types' -import React, { useContext, Component, ReactNode } from 'react' +import React, { Component, ReactNode, useContext } from 'react' import flush from 'styled-jsx/server' import { AMP_RENDER_TARGET, @@ -11,6 +11,10 @@ import { DocumentInitialProps, DocumentProps, } from '../next-server/lib/utils' +import { + BuildManifest, + getPageFiles, +} from '../next-server/server/get-page-files' import { cleanAmpPath } from '../next-server/server/utils' import { htmlEscapeJsonString } from '../server/htmlescape' @@ -40,6 +44,27 @@ function getOptionalModernScriptVariant(path: string): string { return path } +type DocumentFiles = { + sharedFiles: readonly string[] + pageFiles: readonly string[] + allFiles: readonly string[] +} + +function getDocumentFiles( + buildManifest: BuildManifest, + pathname: string +): DocumentFiles { + const sharedFiles: readonly string[] = getPageFiles(buildManifest, '/_app') + const pageFiles: readonly string[] = + pathname !== '/_error' ? getPageFiles(buildManifest, pathname) : [] + + return { + sharedFiles, + pageFiles, + allFiles: [...new Set([...sharedFiles, ...pageFiles])], + } +} + /** * `Document` component handles the initial `document` markup and renders only on the server side. * Commonly used for implementing server side rendering for `css-in-js` libraries. @@ -126,13 +151,15 @@ export class Head extends Component< context!: React.ContextType - getCssLinks(): JSX.Element[] | null { - const { assetPrefix, files, devOnlyCacheBusterQueryString } = this.context - const cssFiles = - files && files.length ? files.filter((f) => f.endsWith('.css')) : [] + getCssLinks(files: DocumentFiles): JSX.Element[] | null { + const { assetPrefix, devOnlyCacheBusterQueryString } = this.context + const cssFiles = files.allFiles.filter((f) => f.endsWith('.css')) + const sharedFiles = new Set(files.sharedFiles) const cssLinkElements: JSX.Element[] = [] cssFiles.forEach((file) => { + const isSharedFile = sharedFiles.has(file) + cssLinkElements.push( ) }) @@ -200,18 +229,14 @@ export class Head extends Component< ) } - getPreloadMainLinks(): JSX.Element[] | null { - const { assetPrefix, files, devOnlyCacheBusterQueryString } = this.context - - const preloadFiles = - files && files.length - ? files.filter((file: string) => { - // `dynamicImports` will contain both `.js` and `.module.js` when - // the feature is enabled. This clause will filter down to the - // modern variants only. - return file.endsWith(getOptionalModernScriptVariant('.js')) - }) - : [] + getPreloadMainLinks(files: DocumentFiles): JSX.Element[] | null { + const { assetPrefix, devOnlyCacheBusterQueryString } = this.context + const preloadFiles = files.allFiles.filter((file: string) => { + // `dynamicImports` will contain both `.js` and `.module.js` when + // the feature is enabled. This clause will filter down to the + // modern variants only. + return file.endsWith(getOptionalModernScriptVariant('.js')) + }) return !preloadFiles.length ? null @@ -291,7 +316,7 @@ export class Head extends Component< ) } - if (process.env.__NEXT_OPTIMIZE_FONTS) { + if (process.env.__NEXT_OPTIMIZE_FONTS && !inAmpMode) { children = this.makeStylesheetInert(children) } @@ -366,6 +391,10 @@ export class Head extends Component< }) } + const files: DocumentFiles = getDocumentFiles( + this.context.buildManifest, + this.context.__NEXT_DATA__.page + ) return ( {this.context.isDevelopment && ( @@ -452,10 +481,10 @@ export class Head extends Component< /> )} {process.env.__NEXT_OPTIMIZE_FONTS - ? this.makeStylesheetInert(this.getCssLinks()) - : this.getCssLinks()} + ? this.makeStylesheetInert(this.getCssLinks(files)) + : this.getCssLinks(files)} {!disableRuntimeJS && this.getPreloadDynamicChunks()} - {!disableRuntimeJS && this.getPreloadMainLinks()} + {!disableRuntimeJS && this.getPreloadMainLinks(files)} {this.context.isDevelopment && ( // this element is used to mount development styles so the // ordering matches production @@ -491,11 +520,10 @@ export class NextScript extends Component { static safariNomoduleFix = '!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();' - getDynamicChunks() { + getDynamicChunks(files: DocumentFiles) { const { dynamicImports, assetPrefix, - files, isDevelopment, devOnlyCacheBusterQueryString, } = this.context @@ -508,7 +536,7 @@ export class NextScript extends Component { : { noModule: true } } - if (!bundle.file.endsWith('.js') || files.includes(bundle.file)) + if (!bundle.file.endsWith('.js') || files.allFiles.includes(bundle.file)) return null return ( @@ -528,16 +556,15 @@ export class NextScript extends Component { }) } - getScripts() { + getScripts(files: DocumentFiles) { const { assetPrefix, - files, buildManifest, isDevelopment, devOnlyCacheBusterQueryString, } = this.context - const normalScripts = files?.filter((file) => file.endsWith('.js')) + const normalScripts = files.allFiles.filter((file) => file.endsWith('.js')) const lowPriorityScripts = buildManifest.lowPriorityFiles?.filter((file) => file.endsWith('.js') ) @@ -549,6 +576,7 @@ export class NextScript extends Component { ? { type: 'module' } : { noModule: true } } + return (