From 3e2e39c55939bd156cbb4dab926aaaa126b53d1c Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 7 Nov 2022 15:13:35 +0100 Subject: [PATCH 01/15] Show inlined error if the "use client" directive is not before other statements/expressions (#42507) An example will be: ![image](https://user-images.githubusercontent.com/3676859/200087667-53548f58-4627-422f-a191-548aba194707.png) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/server/next-typescript.ts | 41 ++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/next/server/next-typescript.ts b/packages/next/server/next-typescript.ts index 3daf55ab116ca..7625bc3b60bc2 100644 --- a/packages/next/server/next-typescript.ts +++ b/packages/next/server/next-typescript.ts @@ -187,19 +187,34 @@ export function createTSPlugin(modules: { ) } - function getIsClientEntry(fileName: string) { + function getIsClientEntry( + fileName: string, + throwOnInvalidDirective?: boolean + ) { const source = info.languageService.getProgram()?.getSourceFile(fileName) if (source) { let isClientEntry = false let isDirective = true ts.forEachChild(source!, (node) => { - if (isClientEntry || !isDirective) return - - if (isDirective && ts.isExpressionStatement(node)) { - if (ts.isStringLiteral(node.expression)) { - if (node.expression.text === 'use client') { + if ( + ts.isExpressionStatement(node) && + ts.isStringLiteral(node.expression) + ) { + if (node.expression.text === 'use client') { + if (isDirective) { isClientEntry = true + } else { + if (throwOnInvalidDirective) { + const e = { + messageText: + 'The `"use client"` directive must be put at the top of the file.', + start: node.expression.getStart(), + length: + node.expression.getEnd() - node.expression.getStart(), + } + throw e + } } } } else { @@ -473,7 +488,19 @@ export function createTSPlugin(modules: { const source = info.languageService.getProgram()?.getSourceFile(fileName) if (source) { - const isClientEntry = getIsClientEntry(fileName) + let isClientEntry = false + + try { + isClientEntry = getIsClientEntry(fileName, true) + } catch (e: any) { + prior.push({ + file: source, + category: ts.DiagnosticCategory.Error, + code: 71004, + ...e, + }) + isClientEntry = false + } ts.forEachChild(source!, (node) => { if (ts.isImportDeclaration(node)) { From b1932b081a95bf535d82faef1c4d7d7ca3de2940 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 7 Nov 2022 16:02:28 +0100 Subject: [PATCH 02/15] Refactor error codes in the TS plugin (#42585) Small code refactoring. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/server/next-typescript.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/next/server/next-typescript.ts b/packages/next/server/next-typescript.ts index 7625bc3b60bc2..b4946c6eb4459 100644 --- a/packages/next/server/next-typescript.ts +++ b/packages/next/server/next-typescript.ts @@ -29,6 +29,13 @@ const DISALLOWED_SERVER_REACT_APIS: string[] = [ const ALLOWED_EXPORTS = ['config', 'generateStaticParams'] +const NEXT_TS_ERRORS = { + INVALID_SERVER_API: 71001, + INVALID_ENTRY_EXPORT: 71002, + INVALID_OPTION_VALUE: 71003, + MISPLACED_CLIENT_ENTRY: 71004, +} + const API_DOCS: Record< string, { @@ -496,7 +503,7 @@ export function createTSPlugin(modules: { prior.push({ file: source, category: ts.DiagnosticCategory.Error, - code: 71004, + code: NEXT_TS_ERRORS.MISPLACED_CLIENT_ENTRY, ...e, }) isClientEntry = false @@ -519,7 +526,7 @@ export function createTSPlugin(modules: { prior.push({ file: source, category: ts.DiagnosticCategory.Error, - code: 71001, + code: NEXT_TS_ERRORS.INVALID_SERVER_API, messageText: `"${name}" is not allowed in Server Components.`, start: element.name.getStart(), length: @@ -546,7 +553,7 @@ export function createTSPlugin(modules: { prior.push({ file: source, category: ts.DiagnosticCategory.Error, - code: 71002, + code: NEXT_TS_ERRORS.INVALID_ENTRY_EXPORT, messageText: `"${name.text}" is not a valid Next.js entry export value.`, start: name.getStart(), length: name.getEnd() - name.getStart(), @@ -620,7 +627,7 @@ export function createTSPlugin(modules: { prior.push({ file: source, category: ts.DiagnosticCategory.Error, - code: 71003, + code: NEXT_TS_ERRORS.INVALID_OPTION_VALUE, messageText: errorMessage || `"${displayedValue}" is not a valid value for the "${name.text}" option.`, From 01a1a7f1e7ae878fec6fd4b9f0c2f37917f8285f Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Mon, 7 Nov 2022 18:16:13 +0100 Subject: [PATCH 03/15] Fix server html insertion target (#42591) Fixes #42493 Make sure searching for the close head tag instead of partial of head tag ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` --- packages/next/server/node-web-streams-helper.ts | 2 +- test/.gitignore | 1 + test/e2e/app-dir/app-alias/next.config.js | 1 - test/e2e/app-dir/app-alias/src/app/layout.tsx | 6 ++++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/next/server/node-web-streams-helper.ts b/packages/next/server/node-web-streams-helper.ts index b0d52455c80de..c82ff473824f2 100644 --- a/packages/next/server/node-web-streams-helper.ts +++ b/packages/next/server/node-web-streams-helper.ts @@ -173,7 +173,7 @@ function createHeadInsertionTransformStream( freezing = true } else { const content = decodeText(chunk) - const index = content.indexOf('') if (index !== -1) { const insertedHeadContent = content.slice(0, index) + insertion + content.slice(index) diff --git a/test/.gitignore b/test/.gitignore index cf4bab9ddde9f..d6bf3bccf5124 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1 +1,2 @@ !node_modules +.vscode diff --git a/test/e2e/app-dir/app-alias/next.config.js b/test/e2e/app-dir/app-alias/next.config.js index e5b838697b38b..cfa3ac3d7aa94 100644 --- a/test/e2e/app-dir/app-alias/next.config.js +++ b/test/e2e/app-dir/app-alias/next.config.js @@ -1,6 +1,5 @@ module.exports = { experimental: { appDir: true, - transpileModules: ['ui'], }, } diff --git a/test/e2e/app-dir/app-alias/src/app/layout.tsx b/test/e2e/app-dir/app-alias/src/app/layout.tsx index 079c59e3e2581..cbdfcab5036e9 100644 --- a/test/e2e/app-dir/app-alias/src/app/layout.tsx +++ b/test/e2e/app-dir/app-alias/src/app/layout.tsx @@ -1,8 +1,10 @@ export default function Root({ children }: { children: React.ReactNode }) { return ( - - {children} + +
top bar
+ {children} + ) } From fa19c4410fdc340a11984de71e2846a99d20e7ac Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 7 Nov 2022 10:16:28 -0800 Subject: [PATCH 04/15] `next/compat/router` (#42502) After speaking with @timneutkens, this PR provides a smoother experience to users trying to migrate over to app without affecting users in pages. This PR adds a new export available from `next/compat/router` that exposes a `useRouter()` hook that can be used in both `app/` and `pages/`. It differs from `next/router` in that it does not throw an error when the pages router is not mounted, and instead has a return type of `NextRouter | null`. This allows developers to convert components to support running in both `app/` and `pages/` as they are transitioning over to `app/`. A component that before looked like this: ```tsx import { useRouter } from 'next/router'; const MyComponent = () => { const { isReady, query } = useRouter(); // ... }; ``` Will error when converted over to `next/compat/router`, as `null` cannot be destructured. Instead, developers will be able to take advantage of new hooks: ```tsx import { useEffect } from 'react'; import { useRouter } from 'next/compat/router'; import { useSearchParams } from 'next/navigation'; const MyComponent = () => { const router = useRouter() // may be null or a NextRouter instance const searchParams = useSearchParams() useEffect(() => { if (router && !router.isReady) { return } // In `app/`, searchParams will be ready immediately with the values, in // `pages/` it will be available after the router is ready. const search = searchParams.get('search') // ... }, [router, searchParams]) // ... } ``` This component will now work in both `pages/` and `app/`. When the component is no longer used in `pages/`, you can remove the references to the compat router: ```tsx import { useSearchParams } from 'next/navigation'; const MyComponent = () => { const searchParams = useSearchParams() // As this component is only used in `app/`, the compat router can be removed. const search = searchParams.get('search') // ... } ``` Note that as of Next.js 13, calling `useRouter` from `next/router` will throw an error when not mounted. This now includes an error page that can be used to assist developers. We hope to introduce a codemod that can convert instances of your `useRouter` from `next/router` to `next/compat/router` in the future. Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com> --- errors/manifest.json | 4 ++++ errors/next-router-not-mounted.md | 13 +++++++++++++ packages/next/client/compat/router.ts | 17 +++++++++++++++++ packages/next/client/route-announcer.tsx | 2 +- packages/next/client/router.ts | 10 +++++----- packages/next/compat/router.d.ts | 1 + packages/next/compat/router.js | 1 + packages/next/tsconfig.json | 1 + test/integration/typescript/pages/hello.tsx | 2 +- 9 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 errors/next-router-not-mounted.md create mode 100644 packages/next/client/compat/router.ts create mode 100644 packages/next/compat/router.d.ts create mode 100644 packages/next/compat/router.js diff --git a/errors/manifest.json b/errors/manifest.json index 5e48d7fa9d44e..9be351338750b 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -757,6 +757,10 @@ { "title": "invalid-segment-export", "path": "/errors/invalid-segment-export.md" + }, + { + "title": "next-router-not-mounted", + "path": "/errors/next-router-not-mounted.md" } ] } diff --git a/errors/next-router-not-mounted.md b/errors/next-router-not-mounted.md new file mode 100644 index 0000000000000..0bdb3f7a9ce73 --- /dev/null +++ b/errors/next-router-not-mounted.md @@ -0,0 +1,13 @@ +# NextRouter was not mounted + +#### Why This Error Occurred + +A component used `useRouter` outside a Next.js application, or was rendered outside a Next.js application. This can happen when doing unit testing on components that use the `useRouter` hook as they are not configured with Next.js' contexts. + +#### Possible Ways to Fix It + +If used in a test, mock out the router by mocking the `next/router`'s `useRouter()` hook. + +### Useful Links + +- [next-router-mock](https://www.npmjs.com/package/next-router-mock) diff --git a/packages/next/client/compat/router.ts b/packages/next/client/compat/router.ts new file mode 100644 index 0000000000000..58b1b9f02ed05 --- /dev/null +++ b/packages/next/client/compat/router.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react' +import { RouterContext } from '../../shared/lib/router-context' +import { NextRouter } from '../router' + +/** + * useRouter from `next/compat/router` is designed to assist developers + * migrating from `pages/` to `app/`. Unlike `next/router`, this hook does not + * throw when the `NextRouter` is not mounted, and instead returns `null`. The + * more concrete return type here lets developers use this hook within + * components that could be shared between both `app/` and `pages/` and handle + * to the case where the router is not mounted. + * + * @returns The `NextRouter` instance if it's available, otherwise `null`. + */ +export function useRouter(): NextRouter | null { + return useContext(RouterContext) +} diff --git a/packages/next/client/route-announcer.tsx b/packages/next/client/route-announcer.tsx index cbd26f09ea7bd..3b59fd84d96bc 100644 --- a/packages/next/client/route-announcer.tsx +++ b/packages/next/client/route-announcer.tsx @@ -17,7 +17,7 @@ const nextjsRouteAnnouncerStyles: React.CSSProperties = { } export const RouteAnnouncer = () => { - const { asPath } = useRouter(true) + const { asPath } = useRouter() const [routeAnnouncement, setRouteAnnouncement] = React.useState('') // Only announce the path change, but not for the first load because screen diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index dbcd11a9b2d62..7f4acbd51a60c 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -129,12 +129,12 @@ export default singletonRouter as SingletonRouter // Reexport the withRoute HOC export { default as withRouter } from './with-router' -export function useRouter(throwOnMissing: true): NextRouter -export function useRouter(): NextRouter -export function useRouter(throwOnMissing?: boolean) { +export function useRouter(): NextRouter { const router = React.useContext(RouterContext) - if (!router && throwOnMissing) { - throw new Error('invariant expected pages router to be mounted') + if (!router) { + throw new Error( + 'Error: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted' + ) } return router diff --git a/packages/next/compat/router.d.ts b/packages/next/compat/router.d.ts new file mode 100644 index 0000000000000..c458473721738 --- /dev/null +++ b/packages/next/compat/router.d.ts @@ -0,0 +1 @@ +export * from '../dist/client/compat/router' diff --git a/packages/next/compat/router.js b/packages/next/compat/router.js new file mode 100644 index 0000000000000..1b46d46053276 --- /dev/null +++ b/packages/next/compat/router.js @@ -0,0 +1 @@ +module.exports = require('../dist/client/compat/router') diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index abbb87416c5f2..0cbb6fd2199e9 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -14,6 +14,7 @@ "./*.d.ts", "future/*.d.ts", "image-types/global.d.ts", + "compat/*.d.ts", "legacy/*.d.ts", "types/compiled.d.ts" ] diff --git a/test/integration/typescript/pages/hello.tsx b/test/integration/typescript/pages/hello.tsx index 1e5c82d05ec5c..acfd061ee4109 100644 --- a/test/integration/typescript/pages/hello.tsx +++ b/test/integration/typescript/pages/hello.tsx @@ -31,7 +31,7 @@ class Test2 extends Test { new Test2().show() export default function HelloPage(): JSX.Element { - const router = useRouter(true) + const router = useRouter() console.log(process.browser) console.log(router.pathname) console.log(router.isReady) From 8dea5b0e225014b4962c8086b849d9da8efa22c5 Mon Sep 17 00:00:00 2001 From: Edward Thomson Date: Mon, 7 Nov 2022 19:19:17 +0000 Subject: [PATCH 05/15] [docs] Document middleware response changes (#42295) Beginning in Next.js 13, middleware can now respond directly, without needing to rewrite or redirect, when the experimental `.allowMiddlewareResponseBody` configuration setting is set. Document this behavior. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [x] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) Co-authored-by: JJ Kasper --- docs/advanced-features/middleware.md | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/advanced-features/middleware.md b/docs/advanced-features/middleware.md index b0f04aa0a5b17..5d38627289a9d 100644 --- a/docs/advanced-features/middleware.md +++ b/docs/advanced-features/middleware.md @@ -9,14 +9,14 @@ description: Learn how to use Middleware to run code before a request is complet | Version | Changes | | --------- | ------------------------------------------------------------------------------------------ | -| `v13.0.0` | Support overriding request headers. | +| `v13.0.0` | Middleware can modify request headers, response headers, and send responses | | `v12.2.0` | Middleware is stable | | `v12.0.9` | Enforce absolute URLs in Edge Runtime ([PR](https://github.com/vercel/next.js/pull/33410)) | | `v12.0.0` | Middleware (Beta) added | -Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by rewriting, redirecting, adding headers, or setting cookies. +Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly. Middleware runs _before_ cached content, so you can personalize static files and pages. Common examples of Middleware would be authentication, A/B testing, localized pages, bot protection, and more. Regarding localized pages, you can start with [i18n routing](/docs/advanced-features/i18n-routing) and implement Middleware for more advanced use cases. @@ -218,6 +218,45 @@ export function middleware(request: NextRequest) { > **Note:** Avoid setting large headers as it might cause [431 Request Header Fields Too Large](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/431) error depending on your backend web server configuration. +## Producing a Response + +You can respond to middleware directly by returning a `NextResponse` (responding from middleware is available since Next.js v13.0.0). + +To enable middleware responses, update `next.config.js`: + +```js +// next.config.js +module.exports = { + experimental: { + allowMiddlewareResponseBody: true, + }, +} +``` + +Once enabled, you can provide a response from middleware using the `Response` or `NextResponse` API: + +```ts +// middleware.ts +import { NextRequest, NextResponse } from 'next/server' +import { isAuthenticated } from '@lib/auth' + +// Limit the middleware to paths starting with `/api/` +export const config = { + matcher: '/api/:function*', +} + +export function middleware(request: NextRequest) { + // Call our authentication function to check the request + if (!isAuthenticated(request)) { + // Respond with JSON indicating an error message + return new NextResponse( + JSON.stringify({ success: false, message: 'authentication failed' }), + { status: 401, headers: { 'content-type': 'application/json' } } + ) + } +} +``` + ## Related
From 0341fb73922011868aa6928f2c0078c7c9aac3da Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 7 Nov 2022 20:49:29 +0100 Subject: [PATCH 06/15] Fix scrolling on router.refresh (#42583) Accidentally returned `true` in this case whereas all others are `false`. Fixes https://github.com/vercel/next.js/discussions/41745#discussioncomment-4075803. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/client/components/reducer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index e779e439a4b48..074deb8acaa71 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -1112,6 +1112,8 @@ function clientReducer( tree: tree, } } + // TODO-APP: Add test for not scrolling to nearest layout when calling refresh. + // TODO-APP: Add test for startTransition(() => {router.push('/'); router.refresh();}), that case should scroll. case ACTION_REFRESH: { const { cache, mutable } = action const href = state.canonicalUrl @@ -1151,7 +1153,7 @@ function clientReducer( pushRef: state.pushRef, // Apply focus and scroll. // TODO-APP: might need to disable this for Fast Refresh. - focusAndScrollRef: { apply: true }, + focusAndScrollRef: { apply: false }, cache: cache, prefetchCache: state.prefetchCache, tree: mutable.patchedTree, From 5b5e4221d0558f8e136192fa2f5703c6bdf3d066 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 7 Nov 2022 21:24:52 +0100 Subject: [PATCH 07/15] Fix relative TypeScript path in monorepos (#42586) When under a monorepo, it's possible that the installed TypeScript isn't under `./node_modules/typescript`, and hence the `tsdk` option for VS Code won't work correctly. This PR fixes that to ensure the path is correct. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/lib/typescript/writeVscodeConfigurations.ts | 5 +++-- packages/next/lib/verifyTypeScriptSetup.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/next/lib/typescript/writeVscodeConfigurations.ts b/packages/next/lib/typescript/writeVscodeConfigurations.ts index d8272239d895c..577b39fe4bb47 100644 --- a/packages/next/lib/typescript/writeVscodeConfigurations.ts +++ b/packages/next/lib/typescript/writeVscodeConfigurations.ts @@ -6,7 +6,8 @@ import * as CommentJson from 'next/dist/compiled/comment-json' // Write .vscode settings to enable Next.js typescript plugin. export async function writeVscodeConfigurations( - baseDir: string + baseDir: string, + tsPath: string ): Promise { try { const vscodeSettings = path.join(baseDir, '.vscode', 'settings.json') @@ -24,7 +25,7 @@ export async function writeVscodeConfigurations( } } - const libPath = './node_modules/typescript/lib' + const libPath = path.relative(baseDir, path.dirname(tsPath)) if ( settings['typescript.tsdk'] === libPath && settings['typescript.enablePromptUseWorkspaceTsdk'] diff --git a/packages/next/lib/verifyTypeScriptSetup.ts b/packages/next/lib/verifyTypeScriptSetup.ts index e48e18ecbda6d..d280046e3f1e5 100644 --- a/packages/next/lib/verifyTypeScriptSetup.ts +++ b/packages/next/lib/verifyTypeScriptSetup.ts @@ -103,8 +103,9 @@ export async function verifyTypeScriptSetup({ } // Load TypeScript after we're sure it exists: + const tsPath = deps.resolved.get('typescript')! const ts = (await Promise.resolve( - require(deps.resolved.get('typescript')!) + require(tsPath) )) as typeof import('typescript') if (semver.lt(ts.version, '4.3.2')) { @@ -125,7 +126,7 @@ export async function verifyTypeScriptSetup({ await writeAppTypeDeclarations(dir, !disableStaticImages) if (isAppDirEnabled) { - await writeVscodeConfigurations(dir) + await writeVscodeConfigurations(dir, tsPath) } let result From 7bbc1aec9a9899f70ab136b8e9a99ecee8f2d3e7 Mon Sep 17 00:00:00 2001 From: Alex Kirszenberg Date: Mon, 7 Nov 2022 22:06:18 +0100 Subject: [PATCH 08/15] Aggregate updates using `addStatusHandler` and `Promise.resolve` instead of `setTimeout` (#42350) The current `setTimeout` logic adds a constant overhead of 30ms when applying updates, which slows down HMR. As @sokra suggested, we can use the `addStatusHandler` API to have the HMR runtime let us know when its status changes. This also switches to `Promise.resolve` for update aggregation. --- .../react-dev-overlay/hot-reloader-client.tsx | 133 ++++++++---------- .../internal/error-overlay-reducer.ts | 85 +++++++++-- .../internal/helpers/use-error-handler.ts | 129 +++++++++++++---- .../dev/error-overlay/hot-dev-client.js | 56 +++++--- packages/react-dev-overlay/src/client.ts | 13 +- .../src/internal/ReactDevOverlay.tsx | 86 +++++++++-- .../react-dev-overlay/src/internal/bus.ts | 3 + .../react-refresh-utils/internal/helpers.ts | 70 +++++---- 8 files changed, 407 insertions(+), 168 deletions(-) diff --git a/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx b/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx index 750fb7da79ac4..f5a4f91f9fa80 100644 --- a/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx +++ b/packages/next/client/components/react-dev-overlay/hot-reloader-client.tsx @@ -14,13 +14,17 @@ import { errorOverlayReducer } from './internal/error-overlay-reducer' import { ACTION_BUILD_OK, ACTION_BUILD_ERROR, + ACTION_BEFORE_REFRESH, ACTION_REFRESH, ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, } from './internal/error-overlay-reducer' import { parseStack } from './internal/helpers/parseStack' import ReactDevOverlay from './internal/ReactDevOverlay' -import { useErrorHandler } from './internal/helpers/use-error-handler' +import { + RuntimeErrorHandler, + useErrorHandler, +} from './internal/helpers/use-error-handler' import { useSendMessage, useWebsocket, @@ -30,6 +34,7 @@ import { interface Dispatcher { onBuildOk(): void onBuildError(message: string): void + onBeforeRefresh(): void onRefresh(): void } @@ -38,10 +43,15 @@ type PongEvent = any let mostRecentCompilationHash: any = null let __nextDevClientId = Math.round(Math.random() * 100 + Date.now()) -let hadRuntimeError = false // let startLatency = undefined +function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) { + if (hasUpdates) { + dispatcher.onBeforeRefresh() + } +} + function onFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) { dispatcher.onBuildOk() if (hasUpdates) { @@ -104,6 +114,7 @@ function performFullReload(err: any, sendMessage: any) { // Attempt to update code on the fly, fall back to a hard reload. function tryApplyUpdates( + onBeforeUpdate: (hasUpdates: boolean) => void, onHotUpdateSuccess: (hasUpdates: boolean) => void, sendMessage: any, dispatcher: Dispatcher @@ -114,7 +125,7 @@ function tryApplyUpdates( } function handleApplyUpdates(err: any, updatedModules: any) { - if (err || hadRuntimeError || !updatedModules) { + if (err || RuntimeErrorHandler.hadRuntimeError || !updatedModules) { if (err) { console.warn( '[Fast Refresh] performing full reload\n\n' + @@ -124,7 +135,7 @@ function tryApplyUpdates( 'It is also possible the parent component of the component you edited is a class component, which disables Fast Refresh.\n' + 'Fast Refresh requires at least one parent function component in your React tree.' ) - } else if (hadRuntimeError) { + } else if (RuntimeErrorHandler.hadRuntimeError) { console.warn( '[Fast Refresh] performing full reload because your application had an unrecoverable error' ) @@ -142,6 +153,7 @@ function tryApplyUpdates( if (isUpdateAvailable()) { // While we were updating, there was a new update! Do it again. tryApplyUpdates( + hasUpdates ? () => {} : onBeforeUpdate, hasUpdates ? () => dispatcher.onBuildOk() : onHotUpdateSuccess, sendMessage, dispatcher @@ -161,14 +173,25 @@ function tryApplyUpdates( // https://webpack.js.org/api/hot-module-replacement/#check // @ts-expect-error module.hot exists - module.hot.check(/* autoApply */ true).then( - (updatedModules: any) => { - handleApplyUpdates(null, updatedModules) - }, - (err: any) => { - handleApplyUpdates(err, null) - } - ) + module.hot + .check(/* autoApply */ false) + .then((updatedModules: any) => { + const hasUpdates = Boolean(updatedModules.length) + if (typeof onBeforeUpdate === 'function') { + onBeforeUpdate(hasUpdates) + } + // https://webpack.js.org/api/hot-module-replacement/#apply + // @ts-expect-error module.hot exists + return module.hot.apply() + }) + .then( + (updatedModules: any) => { + handleApplyUpdates(null, updatedModules) + }, + (err: any) => { + handleApplyUpdates(err, null) + } + ) } function processMessage( @@ -260,6 +283,9 @@ function processMessage( // Attempt to apply hot updates or reload. if (isHotUpdate) { tryApplyUpdates( + function onBeforeHotUpdate(hasUpdates: boolean) { + onBeforeFastRefresh(dispatcher, hasUpdates) + }, function onSuccessfulHotUpdate(hasUpdates: any) { // Only dismiss it when we're sure it's a hot update. // Otherwise it would flicker right before the reload. @@ -287,6 +313,9 @@ function processMessage( // Attempt to apply hot updates or reload. if (isHotUpdate) { tryApplyUpdates( + function onBeforeHotUpdate(hasUpdates: boolean) { + onBeforeFastRefresh(dispatcher, hasUpdates) + }, function onSuccessfulHotUpdate(hasUpdates: any) { // Only dismiss it when we're sure it's a hot update. // Otherwise it would flicker right before the reload. @@ -306,7 +335,7 @@ function processMessage( clientId: __nextDevClientId, }) ) - if (hadRuntimeError) { + if (RuntimeErrorHandler.hadRuntimeError) { return window.location.reload() } startTransition(() => { @@ -361,6 +390,7 @@ export default function HotReload({ nextId: 1, buildError: null, errors: [], + refreshState: { type: 'idle' }, }) const dispatcher = useMemo((): Dispatcher => { return { @@ -370,72 +400,29 @@ export default function HotReload({ onBuildError(message: string): void { dispatch({ type: ACTION_BUILD_ERROR, message }) }, + onBeforeRefresh(): void { + dispatch({ type: ACTION_BEFORE_REFRESH }) + }, onRefresh(): void { dispatch({ type: ACTION_REFRESH }) }, } }, [dispatch]) - const handleOnUnhandledError = useCallback( - (ev: WindowEventMap['error']): void => { - if ( - ev.error && - ev.error.digest && - (ev.error.digest.startsWith('NEXT_REDIRECT') || - ev.error.digest === 'NEXT_NOT_FOUND') - ) { - ev.preventDefault() - return - } - - hadRuntimeError = true - const error = ev?.error - if ( - !error || - !(error instanceof Error) || - typeof error.stack !== 'string' - ) { - // A non-error was thrown, we don't have anything to show. :-( - return - } - - if ( - error.message.match(/(hydration|content does not match|did not match)/i) - ) { - error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error` - } - - const e = error - dispatch({ - type: ACTION_UNHANDLED_ERROR, - reason: error, - frames: parseStack(e.stack!), - }) - }, - [] - ) - const handleOnUnhandledRejection = useCallback( - (ev: WindowEventMap['unhandledrejection']): void => { - hadRuntimeError = true - const reason = ev?.reason - if ( - !reason || - !(reason instanceof Error) || - typeof reason.stack !== 'string' - ) { - // A non-error was thrown, we don't have anything to show. :-( - return - } - - const e = reason - dispatch({ - type: ACTION_UNHANDLED_REJECTION, - reason: reason, - frames: parseStack(e.stack!), - }) - }, - [] - ) + const handleOnUnhandledError = useCallback((error: Error): void => { + dispatch({ + type: ACTION_UNHANDLED_ERROR, + reason: error, + frames: parseStack(error.stack!), + }) + }, []) + const handleOnUnhandledRejection = useCallback((reason: Error): void => { + dispatch({ + type: ACTION_UNHANDLED_REJECTION, + reason: reason, + frames: parseStack(reason.stack!), + }) + }, []) useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection) const webSocketRef = useWebsocket(assetPrefix) diff --git a/packages/next/client/components/react-dev-overlay/internal/error-overlay-reducer.ts b/packages/next/client/components/react-dev-overlay/internal/error-overlay-reducer.ts index 2ae8fe1a1a351..0553640734b6a 100644 --- a/packages/next/client/components/react-dev-overlay/internal/error-overlay-reducer.ts +++ b/packages/next/client/components/react-dev-overlay/internal/error-overlay-reducer.ts @@ -3,6 +3,7 @@ import { SupportedErrorEvent } from './container/Errors' export const ACTION_BUILD_OK = 'build-ok' export const ACTION_BUILD_ERROR = 'build-error' +export const ACTION_BEFORE_REFRESH = 'before-fast-refresh' export const ACTION_REFRESH = 'fast-refresh' export const ACTION_UNHANDLED_ERROR = 'unhandled-error' export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' @@ -14,6 +15,9 @@ interface BuildErrorAction { type: typeof ACTION_BUILD_ERROR message: string } +interface BeforeFastRefreshAction { + type: typeof ACTION_BEFORE_REFRESH +} interface FastRefreshAction { type: typeof ACTION_REFRESH } @@ -28,6 +32,15 @@ export interface UnhandledRejectionAction { frames: StackFrame[] } +export type FastRefreshState = + | { + type: 'idle' + } + | { + type: 'pending' + errors: SupportedErrorEvent[] + } + export interface OverlayState { nextId: number buildError: string | null @@ -35,6 +48,20 @@ export interface OverlayState { rootLayoutMissingTagsError?: { missingTags: string[] } + refreshState: FastRefreshState +} + +function pushErrorFilterDuplicates( + errors: SupportedErrorEvent[], + err: SupportedErrorEvent +): SupportedErrorEvent[] { + return [ + ...errors.filter((e) => { + // Filter out duplicate errors + return e.event.reason !== err.event.reason + }), + err, + ] } export function errorOverlayReducer( @@ -42,6 +69,7 @@ export function errorOverlayReducer( action: Readonly< | BuildOkAction | BuildErrorAction + | BeforeFastRefreshAction | FastRefreshAction | UnhandledErrorAction | UnhandledRejectionAction @@ -54,21 +82,56 @@ export function errorOverlayReducer( case ACTION_BUILD_ERROR: { return { ...state, buildError: action.message } } + case ACTION_BEFORE_REFRESH: { + return { ...state, refreshState: { type: 'pending', errors: [] } } + } case ACTION_REFRESH: { - return { ...state, buildError: null, errors: [] } + return { + ...state, + buildError: null, + errors: + // Errors can come in during updates. In this case, UNHANDLED_ERROR + // and UNHANDLED_REJECTION events might be dispatched between the + // BEFORE_REFRESH and the REFRESH event. We want to keep those errors + // around until the next refresh. Otherwise we run into a race + // condition where those errors would be cleared on refresh completion + // before they can be displayed. + state.refreshState.type === 'pending' + ? state.refreshState.errors + : [], + refreshState: { type: 'idle' }, + } } case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { - return { - ...state, - nextId: state.nextId + 1, - errors: [ - ...state.errors.filter((err) => { - // Filter out duplicate errors - return err.event.reason !== action.reason - }), - { id: state.nextId, event: action }, - ], + switch (state.refreshState.type) { + case 'idle': { + return { + ...state, + nextId: state.nextId + 1, + errors: pushErrorFilterDuplicates(state.errors, { + id: state.nextId, + event: action, + }), + } + } + case 'pending': { + return { + ...state, + nextId: state.nextId + 1, + refreshState: { + ...state.refreshState, + errors: pushErrorFilterDuplicates(state.refreshState.errors, { + id: state.nextId, + event: action, + }), + }, + } + } + default: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = state.refreshState + return state } } default: { diff --git a/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts b/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts index d5867de015dca..bd96c248ea0c7 100644 --- a/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts +++ b/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts @@ -1,34 +1,111 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' + +export type ErrorHandler = (error: Error) => void + +export const RuntimeErrorHandler = { + hadRuntimeError: false, +} + +function isNextRouterError(error: any): boolean { + return ( + error && + error.digest && + (error.digest.startsWith('NEXT_REDIRECT') || + error.digest === 'NEXT_NOT_FOUND') + ) +} + +function isHydrationError(error: Error): boolean { + return ( + error.message.match(/(hydration|content does not match|did not match)/i) != + null + ) +} + +try { + Error.stackTraceLimit = 50 +} catch {} + +const errorQueue: Array = [] +const rejectionQueue: Array = [] +const errorHandlers: Array = [] +const rejectionHandlers: Array = [] + +if (typeof window !== 'undefined') { + // These event handlers must be added outside of the hook because there is no + // guarantee that the hook will be alive in a mounted component in time to + // when the errors occur. + window.addEventListener('error', (ev: WindowEventMap['error']): void => { + if (isNextRouterError(ev.error)) { + ev.preventDefault() + return + } + + RuntimeErrorHandler.hadRuntimeError = true + + const error = ev?.error + if ( + !error || + !(error instanceof Error) || + typeof error.stack !== 'string' + ) { + // A non-error was thrown, we don't have anything to show. :-( + return + } + + if (isHydrationError(error)) { + error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error` + } + + const e = error + errorQueue.push(e) + for (const handler of errorHandlers) { + handler(e) + } + }) + window.addEventListener( + 'unhandledrejection', + (ev: WindowEventMap['unhandledrejection']): void => { + RuntimeErrorHandler.hadRuntimeError = true + + const reason = ev?.reason + if ( + !reason || + !(reason instanceof Error) || + typeof reason.stack !== 'string' + ) { + // A non-error was thrown, we don't have anything to show. :-( + return + } + + const e = reason + rejectionQueue.push(e) + for (const handler of rejectionHandlers) { + handler(e) + } + } + ) +} export function useErrorHandler( - handleOnUnhandledError: (event: WindowEventMap['error']) => void, - handleOnUnhandledRejection: ( - event: WindowEventMap['unhandledrejection'] - ) => void + handleOnUnhandledError: ErrorHandler, + handleOnUnhandledRejection: ErrorHandler ) { - const stacktraceLimitRef = useRef() - useEffect(() => { - try { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 50 - stacktraceLimitRef.current = limit - } catch {} - - window.addEventListener('error', handleOnUnhandledError) - window.addEventListener('unhandledrejection', handleOnUnhandledRejection) - return () => { - if (stacktraceLimitRef.current !== undefined) { - try { - Error.stackTraceLimit = stacktraceLimitRef.current - } catch {} - stacktraceLimitRef.current = undefined - } + // Handle queued errors. + errorQueue.forEach(handleOnUnhandledError) + rejectionQueue.forEach(handleOnUnhandledRejection) + + // Listen to new errors. + errorHandlers.push(handleOnUnhandledError) + rejectionHandlers.push(handleOnUnhandledRejection) - window.removeEventListener('error', handleOnUnhandledError) - window.removeEventListener( - 'unhandledrejection', - handleOnUnhandledRejection + return () => { + // Remove listeners. + errorHandlers.splice(errorHandlers.indexOf(handleOnUnhandledError), 1) + rejectionHandlers.splice( + rejectionHandlers.indexOf(handleOnUnhandledRejection), + 1 ) } }, [handleOnUnhandledError, handleOnUnhandledRejection]) diff --git a/packages/next/client/dev/error-overlay/hot-dev-client.js b/packages/next/client/dev/error-overlay/hot-dev-client.js index a9c8d0e9d5fd1..8d128e38b2198 100644 --- a/packages/next/client/dev/error-overlay/hot-dev-client.js +++ b/packages/next/client/dev/error-overlay/hot-dev-client.js @@ -30,6 +30,7 @@ import { register, onBuildError, onBuildOk, + onBeforeRefresh, onRefresh, } from 'next/dist/compiled/@next/react-dev-overlay/dist/client' import stripAnsi from 'next/dist/compiled/strip-ansi' @@ -98,11 +99,7 @@ function handleSuccess() { // Attempt to apply hot updates or reload. if (isHotUpdate) { - tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates) { - // Only dismiss it when we're sure it's a hot update. - // Otherwise it would flicker right before the reload. - onFastRefresh(hasUpdates) - }) + tryApplyUpdates(onBeforeFastRefresh, onFastRefresh) } } @@ -139,11 +136,7 @@ function handleWarnings(warnings) { // Attempt to apply hot updates or reload. if (isHotUpdate) { - tryApplyUpdates(function onSuccessfulHotUpdate(hasUpdates) { - // Only dismiss it when we're sure it's a hot update. - // Otherwise it would flicker right before the reload. - onFastRefresh(hasUpdates) - }) + tryApplyUpdates(onBeforeFastRefresh, onFastRefresh) } } @@ -182,9 +175,19 @@ function handleErrors(errors) { let startLatency = undefined +function onBeforeFastRefresh(hasUpdates) { + if (hasUpdates) { + // Only trigger a pending state if we have updates to apply + // (cf. onFastRefresh) + onBeforeRefresh() + } +} + function onFastRefresh(hasUpdates) { onBuildOk() if (hasUpdates) { + // Only complete a pending state if we applied updates + // (cf. onBeforeFastRefresh) onRefresh() } @@ -301,7 +304,7 @@ function afterApplyUpdates(fn) { } // Attempt to update code on the fly, fall back to a hard reload. -function tryApplyUpdates(onHotUpdateSuccess) { +function tryApplyUpdates(onBeforeHotUpdate, onHotUpdateSuccess) { if (!module.hot) { // HotModuleReplacementPlugin is not in Webpack configuration. console.error('HotModuleReplacementPlugin is not in Webpack configuration.') @@ -342,7 +345,11 @@ function tryApplyUpdates(onHotUpdateSuccess) { if (isUpdateAvailable()) { // While we were updating, there was a new update! Do it again. - tryApplyUpdates(hasUpdates ? onBuildOk : onHotUpdateSuccess) + // However, this time, don't trigger a pending refresh state. + tryApplyUpdates( + hasUpdates ? undefined : onBeforeHotUpdate, + hasUpdates ? onBuildOk : onHotUpdateSuccess + ) } else { onBuildOk() if (process.env.__NEXT_TEST_MODE) { @@ -357,14 +364,23 @@ function tryApplyUpdates(onHotUpdateSuccess) { } // https://webpack.js.org/api/hot-module-replacement/#check - module.hot.check(/* autoApply */ true).then( - (updatedModules) => { - handleApplyUpdates(null, updatedModules) - }, - (err) => { - handleApplyUpdates(err, null) - } - ) + module.hot + .check(/* autoApply */ false) + .then((updatedModules) => { + if (typeof onBeforeHotUpdate === 'function') { + const hasUpdates = Boolean(updatedModules.length) + onBeforeHotUpdate(hasUpdates) + } + return module.hot.apply() + }) + .then( + (updatedModules) => { + handleApplyUpdates(null, updatedModules) + }, + (err) => { + handleApplyUpdates(err, null) + } + ) } function performFullReload(err) { diff --git a/packages/react-dev-overlay/src/client.ts b/packages/react-dev-overlay/src/client.ts index a9eb0a496eea5..8f774ec957ed1 100644 --- a/packages/react-dev-overlay/src/client.ts +++ b/packages/react-dev-overlay/src/client.ts @@ -89,7 +89,18 @@ function onRefresh() { Bus.emit({ type: Bus.TYPE_REFRESH }) } +function onBeforeRefresh() { + Bus.emit({ type: Bus.TYPE_BEFORE_REFRESH }) +} + export { getErrorByType } from './internal/helpers/getErrorByType' export { getServerError } from './internal/helpers/nodeStackFrames' export { default as ReactDevOverlay } from './internal/ReactDevOverlay' -export { onBuildOk, onBuildError, register, unregister, onRefresh } +export { + onBuildOk, + onBuildError, + register, + unregister, + onBeforeRefresh, + onRefresh, +} diff --git a/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx b/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx index 8db93560df47a..049de17386589 100644 --- a/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx +++ b/packages/react-dev-overlay/src/internal/ReactDevOverlay.tsx @@ -9,10 +9,36 @@ import { Base } from './styles/Base' import { ComponentStyles } from './styles/ComponentStyles' import { CssReset } from './styles/CssReset' +type RefreshState = + | { + // No refresh in progress. + type: 'idle' + } + | { + // The refresh process has been triggered, but the new code has not been + // executed yet. + type: 'pending' + errors: SupportedErrorEvent[] + } + type OverlayState = { nextId: number buildError: string | null errors: SupportedErrorEvent[] + refreshState: RefreshState +} + +function pushErrorFilterDuplicates( + errors: SupportedErrorEvent[], + err: SupportedErrorEvent +): SupportedErrorEvent[] { + return [ + ...errors.filter((e) => { + // Filter out duplicate errors + return e.event.reason !== err.event.reason + }), + err, + ] } function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState { @@ -23,21 +49,56 @@ function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState { case Bus.TYPE_BUILD_ERROR: { return { ...state, buildError: ev.message } } + case Bus.TYPE_BEFORE_REFRESH: { + return { ...state, refreshState: { type: 'pending', errors: [] } } + } case Bus.TYPE_REFRESH: { - return { ...state, buildError: null, errors: [] } + return { + ...state, + buildError: null, + errors: + // Errors can come in during updates. In this case, UNHANDLED_ERROR + // and UNHANDLED_REJECTION events might be dispatched between the + // BEFORE_REFRESH and the REFRESH event. We want to keep those errors + // around until the next refresh. Otherwise we run into a race + // condition where those errors would be cleared on refresh completion + // before they can be displayed. + state.refreshState.type === 'pending' + ? state.refreshState.errors + : [], + refreshState: { type: 'idle' }, + } } case Bus.TYPE_UNHANDLED_ERROR: case Bus.TYPE_UNHANDLED_REJECTION: { - return { - ...state, - nextId: state.nextId + 1, - errors: [ - ...state.errors.filter((err) => { - // Filter out duplicate errors - return err.event.reason !== ev.reason - }), - { id: state.nextId, event: ev }, - ], + switch (state.refreshState.type) { + case 'idle': { + return { + ...state, + nextId: state.nextId + 1, + errors: pushErrorFilterDuplicates(state.errors, { + id: state.nextId, + event: ev, + }), + } + } + case 'pending': { + return { + ...state, + nextId: state.nextId + 1, + refreshState: { + ...state.refreshState, + errors: pushErrorFilterDuplicates(state.refreshState.errors, { + id: state.nextId, + event: ev, + }), + }, + } + } + default: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = state.refreshState + return state } } default: { @@ -74,6 +135,9 @@ const ReactDevOverlay: React.FunctionComponent = nextId: 1, buildError: null, errors: [], + refreshState: { + type: 'idle', + }, }) React.useEffect(() => { diff --git a/packages/react-dev-overlay/src/internal/bus.ts b/packages/react-dev-overlay/src/internal/bus.ts index 1ffc87428b8b2..6c1a3992bc88b 100644 --- a/packages/react-dev-overlay/src/internal/bus.ts +++ b/packages/react-dev-overlay/src/internal/bus.ts @@ -3,6 +3,7 @@ import { StackFrame } from 'stacktrace-parser' export const TYPE_BUILD_OK = 'build-ok' export const TYPE_BUILD_ERROR = 'build-error' export const TYPE_REFRESH = 'fast-refresh' +export const TYPE_BEFORE_REFRESH = 'before-fast-refresh' export const TYPE_UNHANDLED_ERROR = 'unhandled-error' export const TYPE_UNHANDLED_REJECTION = 'unhandled-rejection' @@ -11,6 +12,7 @@ export type BuildError = { type: typeof TYPE_BUILD_ERROR message: string } +export type BeforeFastRefresh = { type: typeof TYPE_BEFORE_REFRESH } export type FastRefresh = { type: typeof TYPE_REFRESH } export type UnhandledError = { type: typeof TYPE_UNHANDLED_ERROR @@ -26,6 +28,7 @@ export type BusEvent = | BuildOk | BuildError | FastRefresh + | BeforeFastRefresh | UnhandledError | UnhandledRejection diff --git a/packages/react-refresh-utils/internal/helpers.ts b/packages/react-refresh-utils/internal/helpers.ts index 9ff15e9808c49..66282e3fd84c3 100644 --- a/packages/react-refresh-utils/internal/helpers.ts +++ b/packages/react-refresh-utils/internal/helpers.ts @@ -29,17 +29,23 @@ import RefreshRuntime from 'react-refresh/runtime' +type ModuleHotStatus = + | 'idle' + | 'check' + | 'prepare' + | 'ready' + | 'dispose' + | 'apply' + | 'abort' + | 'fail' + +type ModuleHotStatusHandler = (status: ModuleHotStatus) => void + declare const module: { hot: { - status: () => - | 'idle' - | 'check' - | 'prepare' - | 'ready' - | 'dispose' - | 'apply' - | 'abort' - | 'fail' + status: () => ModuleHotStatus + addStatusHandler: (handler: ModuleHotStatusHandler) => void + removeStatusHandler: (handler: ModuleHotStatusHandler) => void } } @@ -133,34 +139,46 @@ function shouldInvalidateReactRefreshBoundary( } var isUpdateScheduled: boolean = false +// This function aggregates updates from multiple modules into a single React Refresh call. function scheduleUpdate() { if (isUpdateScheduled) { return } + isUpdateScheduled = true - function canApplyUpdate() { - return module.hot.status() === 'idle' + function canApplyUpdate(status: ModuleHotStatus) { + return status === 'idle' } - isUpdateScheduled = true - setTimeout(function () { + function applyUpdate() { isUpdateScheduled = false + try { + RefreshRuntime.performReactRefresh() + } catch (err) { + console.warn( + 'Warning: Failed to re-render. We will retry on the next Fast Refresh event.\n' + + err + ) + } + } - // Only trigger refresh if the webpack HMR state is idle - if (canApplyUpdate()) { - try { - RefreshRuntime.performReactRefresh() - } catch (err) { - console.warn( - 'Warning: Failed to re-render. We will retry on the next Fast Refresh event.\n' + - err - ) - } - return + if (canApplyUpdate(module.hot.status())) { + // Apply update on the next tick. + Promise.resolve().then(() => { + applyUpdate() + }) + return + } + + const statusHandler = (status) => { + if (canApplyUpdate(status)) { + module.hot.removeStatusHandler(statusHandler) + applyUpdate() } + } - return scheduleUpdate() - }, 30) + // Apply update once the HMR runtime's status is idle. + module.hot.addStatusHandler(statusHandler) } // Needs to be compatible with IE11 From 22b449ba567b236190338982156987119141030f Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 7 Nov 2022 14:02:34 -0800 Subject: [PATCH 09/15] Fix default value handling for CNA in CI (#42596) Fixes: https://github.com/vercel/next.js/issues/42592 ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` --- packages/create-next-app/index.ts | 28 ++++++++++--------- test/integration/create-next-app/lib/utils.ts | 2 +- .../create-next-app/templates.test.ts | 18 ++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index cd3c8112a1439..c02ad153b29d8 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -157,14 +157,13 @@ async function run(): Promise { * to use TS or JS. */ if (!example) { - if (ciInfo.isCI) { - // default to JavaScript in CI as we can't prompt to - // prevent breaking setup flows - program.javascript = true - program.typescript = false - program.eslint = true - } else { - if (!program.typescript && !program.javascript) { + if (!program.typescript && !program.javascript) { + if (ciInfo.isCI) { + // default to JavaScript in CI as we can't prompt to + // prevent breaking setup flows + program.typescript = false + program.javascript = true + } else { const styledTypeScript = chalk.hex('#007acc')('TypeScript') const { typescript } = await prompts( { @@ -186,18 +185,21 @@ async function run(): Promise { }, } ) - /** * Depending on the prompt response, set the appropriate program flags. */ program.typescript = Boolean(typescript) program.javascript = !Boolean(typescript) } + } - if ( - !process.argv.includes('--eslint') && - !process.argv.includes('--no-eslint') - ) { + if ( + !process.argv.includes('--eslint') && + !process.argv.includes('--no-eslint') + ) { + if (ciInfo.isCI) { + program.eslint = true + } else { const styledEslint = chalk.hex('#007acc')('ESLint') const { eslint } = await prompts({ type: 'toggle', diff --git a/test/integration/create-next-app/lib/utils.ts b/test/integration/create-next-app/lib/utils.ts index ba05cf1052e95..cd0b631ece16c 100644 --- a/test/integration/create-next-app/lib/utils.ts +++ b/test/integration/create-next-app/lib/utils.ts @@ -23,7 +23,6 @@ export const createNextApp = (args: string[], options?: SpawnOptions) => { ...options, env: { ...process.env, - ...options.env, // unset CI env as this skips the auto-install behavior // being tested CI: '', @@ -32,6 +31,7 @@ export const createNextApp = (args: string[], options?: SpawnOptions) => { CONTINUOUS_INTEGRATION: '', RUN_ID: '', BUILD_NUMBER: '', + ...options.env, }, }) } diff --git a/test/integration/create-next-app/templates.test.ts b/test/integration/create-next-app/templates.test.ts index 553d5d84fda76..eb95af6c1fc81 100644 --- a/test/integration/create-next-app/templates.test.ts +++ b/test/integration/create-next-app/templates.test.ts @@ -67,6 +67,24 @@ describe('create-next-app templates', () => { }) }) + it('should create TS projects with --ts, --typescript with CI=1', async () => { + await useTempDir(async (cwd) => { + const projectName = 'typescript-test' + const childProcess = createNextApp([projectName, '--ts', '--eslint'], { + cwd, + env: { + ...process.env, + CI: '1', + GITHUB_ACTIONS: '1', + }, + }) + const exitCode = await spawnExitPromise(childProcess) + + expect(exitCode).toBe(0) + shouldBeTypescriptProject({ cwd, projectName, template: 'default' }) + }) + }) + it('should create JS projects with --js, --javascript', async () => { await useTempDir(async (cwd) => { const projectName = 'javascript-test' From 6f136f6a7ef00e35d9d4ed3d7fd8bbfbbcaefd29 Mon Sep 17 00:00:00 2001 From: Max Proske Date: Mon, 7 Nov 2022 14:19:42 -0800 Subject: [PATCH 10/15] Convert `with-absolute-imports` example to TypeScript (#42529) Converted example to TypeScript to match Contribution docs. - ~~Renamed `with-absolute-imports` example to `absolute-imports-and-aliases` to match Contribution docs~~ - ~~Replaced deprecated example with a `README.md` file~~ - Used module path aliases in example, to help developers decide which import strategy to use ## Documentation / Examples - [X] Make sure the linting passes by running `pnpm build && pnpm lint` - [X] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) Co-authored-by: JJ Kasper --- docs/advanced-features/module-path-aliases.md | 2 +- examples/with-absolute-imports/README.md | 22 ++++++++++++++-- .../components/button.tsx | 3 +++ .../components/{header.js => header.tsx} | 0 examples/with-absolute-imports/jsconfig.json | 5 ---- examples/with-absolute-imports/package.json | 6 +++++ examples/with-absolute-imports/pages/index.js | 9 ------- .../with-absolute-imports/pages/index.tsx | 11 ++++++++ examples/with-absolute-imports/tsconfig.json | 26 +++++++++++++++++++ 9 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 examples/with-absolute-imports/components/button.tsx rename examples/with-absolute-imports/components/{header.js => header.tsx} (100%) delete mode 100644 examples/with-absolute-imports/jsconfig.json delete mode 100644 examples/with-absolute-imports/pages/index.js create mode 100644 examples/with-absolute-imports/pages/index.tsx create mode 100644 examples/with-absolute-imports/tsconfig.json diff --git a/docs/advanced-features/module-path-aliases.md b/docs/advanced-features/module-path-aliases.md index 8015f82667cd0..ec19e194bbfd3 100644 --- a/docs/advanced-features/module-path-aliases.md +++ b/docs/advanced-features/module-path-aliases.md @@ -7,7 +7,7 @@ description: Configure module path aliases that allow you to remap certain impor
Examples
diff --git a/examples/with-absolute-imports/README.md b/examples/with-absolute-imports/README.md index 8a35deaf0acd0..1d30ec808a3d7 100644 --- a/examples/with-absolute-imports/README.md +++ b/examples/with-absolute-imports/README.md @@ -1,6 +1,24 @@ -# Example app with absolute imports +# Absolute Imports and Aliases -This example shows how to configure Babel to have absolute imports instead of relative imports without modifying the Webpack configuration. +This example shows how to configure [Absolute imports and Module path aliases](https://nextjs.org/docs/advanced-features/module-path-aliases) in `tsconfig.json` (or `jsconfig.json` for JavaScript projects). These options will allow absolute imports from `.` (the root directory), and allow you to create custom import aliases. + +If you’re working on a large project, your relative import statements might suffer from `../../../` spaghetti: + +```tsx +import Button from '../../../components/button' +``` + +In such cases, we might want to setup absolute imports using the `baseUrl` option, for clearer and shorter imports: + +```tsx +import Button from 'components/button' +``` + +Furthermore, TypeScript also supports the `paths` option, which allows you to configure custom module aliases. You can then use your alias like so: + +```tsx +import Button from '@/components/button' +``` ## Deploy your own diff --git a/examples/with-absolute-imports/components/button.tsx b/examples/with-absolute-imports/components/button.tsx new file mode 100644 index 0000000000000..1deaded46204c --- /dev/null +++ b/examples/with-absolute-imports/components/button.tsx @@ -0,0 +1,3 @@ +export default function Button() { + return +} diff --git a/examples/with-absolute-imports/components/header.js b/examples/with-absolute-imports/components/header.tsx similarity index 100% rename from examples/with-absolute-imports/components/header.js rename to examples/with-absolute-imports/components/header.tsx diff --git a/examples/with-absolute-imports/jsconfig.json b/examples/with-absolute-imports/jsconfig.json deleted file mode 100644 index 36aa1a4dc28f1..0000000000000 --- a/examples/with-absolute-imports/jsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/examples/with-absolute-imports/package.json b/examples/with-absolute-imports/package.json index bf29745bfe271..1412a9c80e177 100644 --- a/examples/with-absolute-imports/package.json +++ b/examples/with-absolute-imports/package.json @@ -9,5 +9,11 @@ "next": "latest", "react": "^18.2.0", "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^18.11.9", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.8", + "typescript": "^4.8.4" } } diff --git a/examples/with-absolute-imports/pages/index.js b/examples/with-absolute-imports/pages/index.js deleted file mode 100644 index 54c8bf264718a..0000000000000 --- a/examples/with-absolute-imports/pages/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import Header from 'components/header.js' - -export default function Home() { - return ( -
-
-
- ) -} diff --git a/examples/with-absolute-imports/pages/index.tsx b/examples/with-absolute-imports/pages/index.tsx new file mode 100644 index 0000000000000..47f057a3b4813 --- /dev/null +++ b/examples/with-absolute-imports/pages/index.tsx @@ -0,0 +1,11 @@ +import Header from 'components/header' +import Button from '@/components/button' + +export default function Home() { + return ( + <> +
+