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/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/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/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/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/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-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-storybook-typescript/tsconfig.json b/examples/with-next-sitemap/tsconfig.json similarity index 82% rename from examples/with-storybook-typescript/tsconfig.json rename to examples/with-next-sitemap/tsconfig.json index c5d53d8983d19..93a83a407c40c 100644 --- a/examples/with-storybook-typescript/tsconfig.json +++ b/examples/with-next-sitemap/tsconfig.json @@ -14,6 +14,6 @@ "isolatedModules": true, "jsx": "preserve" }, - "exclude": ["node_modules"], - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] + "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/lerna.json b/lerna.json index caa39c09d031b..4f5c7ea0951a7 100644 --- a/lerna.json +++ b/lerna.json @@ -17,5 +17,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "9.5.2" + "version": "9.5.3-canary.13" } 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 ab96be9e3bbea..eaba0d3c4d013 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", + "version": "9.5.3-canary.13", "keywords": [ "react", "next", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 69c4731b12479..de2304e6568e0 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", + "version": "9.5.3-canary.13", "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 cfd66a8ae0011..570c1066fa8a2 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", + "version": "9.5.3-canary.13", "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/README.md b/packages/next-codemod/README.md index 7aa721c899503..ce458da7e90c7 100644 --- a/packages/next-codemod/README.md +++ b/packages/next-codemod/README.md @@ -1,171 +1,9 @@ -# Next.js Codemod +# Next.js Codemods -This repository contains Codemod transformations to help upgrade Next.js codebases. +Next.js provides Codemod transformations to help upgrade your Next.js codebase when a feature is deprecated. -## v9 +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. -### `name-default-component` +## Documentation -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/ -``` - -Download the codemod: - -``` -curl -L https://github.com/zeit/next-codemod/archive/master.tar.gz | tar -xz --strip=2 next-codemod-master/transforms/name-default-component.js -``` - -Run the transformation: - -``` -npx jscodeshift -t ./name-default-component.js components/**/*.js -``` - -TypeScript files can use this codemod too: - -``` -npx jscodeshift -t ./name-default-component.js --parser=tsx components/**/*.tsx -``` - -If you have components in multiple folders, change the path to `**/*.js` and add `--ignore-pattern="**/node_modules/**"`. - -After the transformation is done the `name-default-component.js` file in the root of your project can be removed. - -### `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/ -``` - -Download the codemod: - -``` -curl -L https://github.com/zeit/next-codemod/archive/master.tar.gz | tar -xz --strip=2 next-codemod-master/transforms/withamp-to-config.js -``` - -Run the transformation: - -``` -npx jscodeshift -t ./withamp-to-config.js pages/**/*.js -``` - -After the transformation is done the `withamp-to-config.js` file in the root of your project can be removed. - -## v6 - -### `url-to-withrouter` - -Tranforms 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/ -``` - -Download the codemod: - -``` -curl -L https://github.com/zeit/next-codemod/archive/master.tar.gz | tar -xz --strip=2 next-codemod-master/transforms/url-to-withrouter.js -``` - -Run the transformation: - -``` -npx jscodeshift -t ./url-to-withrouter.js pages/**/*.js -``` - -After the transformation is done the `url-to-withrouter.js` file in the root of your project can be removed. - -## Authors - -- Tim Neutkens ([@timneutkens](https://twitter.com/timneutkens)) – [ZEIT](https://zeit.co) -- Joe Haddad ([@timer150](https://twitter.com/timer150)) - [ZEIT](https://zeit.co) +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/package.json b/packages/next-codemod/package.json index dcd2814e87436..31ddef3d8b79c 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "9.5.2", + "version": "9.5.3-canary.13", "license": "MIT", "dependencies": { "chalk": "4.1.0", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 4735e836a26ca..3f9a7ab91939b 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "9.5.2", + "version": "9.5.3-canary.13", "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 c373f43f91c62..13d6030f4d3c0 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", + "version": "9.5.3-canary.13", "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 e514ccad950eb..8a6735b3f29e9 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", + "version": "9.5.3-canary.13", "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 f92f9bb549335..d1f146e096d18 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", + "version": "9.5.3-canary.13", "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 2809c592db080..bce2bda4b0d6d 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", + "version": "9.5.3-canary.13", "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..5c4cc27f6795c 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) } @@ -55,7 +53,6 @@ const foundCommand = Boolean(commands[args._[0]]) // Makes sure the `next --help` case is covered // This help message is only showed for `next --help` if (!foundCommand && args['--help']) { - // tslint:disable-next-line console.log(` Usage $ next @@ -113,7 +110,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..40b44eddadd1e 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -286,7 +286,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 +323,7 @@ export default async function build( namedRegex: routeRegex.namedRegex, } }), + dataRoutes: [], } await promises.mkdir(distDir, { recursive: true }) @@ -325,6 +345,7 @@ export default async function build( target, pagesDir, entrypoints: entrypoints.client, + rewrites, }), getBaseWebpackConfig(dir, { tracer, @@ -335,6 +356,7 @@ export default async function build( target, pagesDir, entrypoints: entrypoints.server, + rewrites, }), ]) @@ -654,6 +676,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 = @@ -1064,6 +1087,8 @@ export default async function build( await telemetry.flush() } +export type ClientSsgManifest = Set + function generateClientSsgManifest( prerenderManifest: PrerenderManifest, { @@ -1072,7 +1097,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 7daa2d689af66..7b0a95aa220c2 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 @@ -370,6 +380,9 @@ export default async function getBaseWebpackConfig( [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 @@ -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/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index da5a2182c6e7c..69885cff071c4 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -147,7 +147,7 @@ const nextServerlessLoader: loader.Loader = function () { 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 +163,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) @@ -455,6 +455,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..54401f2ea9c25 100644 --- a/packages/next/build/webpack/plugins/build-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/build-manifest-plugin.ts @@ -12,19 +12,32 @@ 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' 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 +52,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 +80,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 +215,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 +226,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..f9c0c4cea7b37 100644 --- a/packages/next/build/webpack/plugins/css-minimizer-plugin.ts +++ b/packages/next/build/webpack/plugins/css-minimizer-plugin.ts @@ -11,6 +11,8 @@ type CssMinimizerPluginOptions = { } } +const isWebpack5 = parseInt(webpack.version!) === 5 + export class CssMinimizerPlugin { __next_css_remove = true @@ -20,8 +22,55 @@ 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) { + 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 = compilation.assets[file] + + assets[file] = await this.optimizeAsset(file, asset) + }) + ) + } + ) + return + } compilation.hooks.optimizeChunkAssets.tapPromise( 'CssMinimizerPlugin', (chunks: webpack.compilation.Chunk[]) => @@ -32,35 +81,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/cli/next-dev.ts b/packages/next/cli/next-dev.ts index 88bc9fbf7cb34..ed044bbd1b521 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,7 +80,11 @@ 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' @@ -119,10 +126,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 60% rename from packages/next/client/index.js rename to packages/next/client/index.tsx index 4314dead73b79..aee6099798718 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 @@ -319,7 +361,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 +377,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 +397,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 +514,9 @@ function clearMarks() { ].forEach((mark) => performance.clearMarks(mark)) } -function AppContainer({ children }) { +function AppContainer({ + children, +}: React.PropsWithChildren<{}>): React.ReactElement { return ( @@ -486,8 +534,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 +550,26 @@ const wrapApp = (App) => (wrappedAppProps) => { ) } -async function doRender({ App, Component, props, err }) { +async function doRender({ + App, + Component, + props, + err, + styleSheets, +}: RenderRouteInfo) { 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 const renderPromise = new Promise((resolve, reject) => { if (lastRenderReject) { lastRenderReject() @@ -523,8 +584,111 @@ async function doRender({ App, Component, props, err }) { } }) + // TODO: consider replacing this with real `