diff --git a/docs/02-app/01-building-your-application/07-configuring/02-eslint.mdx b/docs/02-app/01-building-your-application/07-configuring/02-eslint.mdx index 5b0b63d34b380..bd27123bef013 100644 --- a/docs/02-app/01-building-your-application/07-configuring/02-eslint.mdx +++ b/docs/02-app/01-building-your-application/07-configuring/02-eslint.mdx @@ -93,29 +93,30 @@ Next.js provides an ESLint plugin, [`eslint-plugin-next`](https://www.npmjs.com/ Enabled in the recommended configuration -| | Rule | Description | -| :-----------------: | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| | [@next/next/google-font-display](/docs/messages/google-font-display) | Enforce font-display behavior with Google Fonts. | -| | [@next/next/google-font-preconnect](/docs/messages/google-font-preconnect) | Ensure `preconnect` is used with Google Fonts. | -| | [@next/next/inline-script-id](/docs/messages/inline-script-id) | Enforce `id` attribute on `next/script` components with inline content. | -| | [@next/next/next-script-for-ga](/docs/messages/next-script-for-ga) | Prefer `next/script` component when using the inline script for Google Analytics. | -| | [@next/next/no-assign-module-variable](/docs/messages/no-assign-module-variable) | Prevent assignment to the `module` variable. | -| | [@next/next/no-async-client-component](/docs/messages/no-async-client-component) | Prevent client components from being async functions. | -| | [@next/next/no-before-interactive-script-outside-document](/docs/messages/no-before-interactive-script-outside-document) | Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`. | -| | [@next/next/no-css-tags](/docs/messages/no-css-tags) | Prevent manual stylesheet tags. | -| | [@next/next/no-document-import-in-page](/docs/messages/no-document-import-in-page) | Prevent importing `next/document` outside of `pages/_document.js`. | -| | [@next/next/no-duplicate-head](/docs/messages/no-duplicate-head) | Prevent duplicate usage of `` in `pages/_document.js`. | -| | [@next/next/no-head-element](/docs/messages/no-head-element) | Prevent usage of `` element. | -| | [@next/next/no-head-import-in-document](/docs/messages/no-head-import-in-document) | Prevent usage of `next/head` in `pages/_document.js`. | -| | [@next/next/no-html-link-for-pages](/docs/messages/no-html-link-for-pages) | Prevent usage of `` elements to navigate to internal Next.js pages. | -| | [@next/next/no-img-element](/docs/messages/no-img-element) | Prevent usage of `` element due to slower LCP and higher bandwidth. | -| | [@next/next/no-page-custom-font](/docs/messages/no-page-custom-font) | Prevent page-only custom fonts. | -| | [@next/next/no-script-component-in-head](/docs/messages/no-script-component-in-head) | Prevent usage of `next/script` in `next/head` component. | -| | [@next/next/no-styled-jsx-in-document](/docs/messages/no-styled-jsx-in-document) | Prevent usage of `styled-jsx` in `pages/_document.js`. | -| | [@next/next/no-sync-scripts](/docs/messages/no-sync-scripts) | Prevent synchronous scripts. | -| | [@next/next/no-title-in-document-head](/docs/messages/no-title-in-document-head) | Prevent usage of `` with `Head` component from `next/document`. | -| <Check size={18} /> | @next/next/no-typos | Prevent common typos in [Next.js's data fetching functions](/docs/pages/building-your-application/data-fetching) | -| <Check size={18} /> | [@next/next/no-unwanted-polyfillio](/docs/messages/no-unwanted-polyfillio) | Prevent duplicate polyfills from Polyfill.io. | +| | Rule | Description | +| :-----------------: | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| <Check size={18} /> | [@next/next/google-font-display](/docs/messages/google-font-display) | Enforce font-display behavior with Google Fonts. | +| <Check size={18} /> | [@next/next/google-font-preconnect](/docs/messages/google-font-preconnect) | Ensure `preconnect` is used with Google Fonts. | +| <Check size={18} /> | [@next/next/inline-script-id](/docs/messages/inline-script-id) | Enforce `id` attribute on `next/script` components with inline content. | +| <Check size={18} /> | [@next/next/next-script-for-ga](/docs/messages/next-script-for-ga) | Prefer `next/script` component when using the inline script for Google Analytics. | +| <Check size={18} /> | [@next/next/no-assign-module-variable](/docs/messages/no-assign-module-variable) | Prevent assignment to the `module` variable. | +| <Check size={18} /> | [@next/next/no-async-client-component](/docs/messages/no-async-client-component) | Prevent client components from being async functions. | +| <Check size={18} /> | [@next/next/no-before-interactive-script-outside-document](/docs/messages/no-before-interactive-script-outside-document) | Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`. | +| <Check size={18} /> | [@next/next/no-css-tags](/docs/messages/no-css-tags) | Prevent manual stylesheet tags. | +| <Check size={18} /> | [@next/next/no-document-import-in-page](/docs/messages/no-document-import-in-page) | Prevent importing `next/document` outside of `pages/_document.js`. | +| <Check size={18} /> | [@next/next/no-duplicate-head](/docs/messages/no-duplicate-head) | Prevent duplicate usage of `<Head>` in `pages/_document.js`. | +| <Check size={18} /> | [@next/next/no-head-element](/docs/messages/no-head-element) | Prevent usage of `<head>` element. | +| <Check size={18} /> | [@next/next/no-head-import-in-document](/docs/messages/no-head-import-in-document) | Prevent usage of `next/head` in `pages/_document.js`. | +| <Check size={18} /> | [@next/next/no-html-link-for-pages](/docs/messages/no-html-link-for-pages) | Prevent usage of `<a>` elements to navigate to internal Next.js pages. | +| <Check size={18} /> | [@next/next/no-img-element](/docs/messages/no-img-element) | Prevent usage of `<img>` element due to slower LCP and higher bandwidth. | +| <Check size={18} /> | [@next/next/no-page-custom-font](/docs/messages/no-page-custom-font) | Prevent page-only custom fonts. | +| <Check size={18} /> | [@next/next/no-script-component-in-head](/docs/messages/no-script-component-in-head) | Prevent usage of `next/script` in `next/head` component. | +| <Check size={18} /> | [@next/next/no-styled-jsx-in-document](/docs/messages/no-styled-jsx-in-document) | Prevent usage of `styled-jsx` in `pages/_document.js`. | +| <Check size={18} /> | [@next/next/no-sync-scripts](/docs/messages/no-sync-scripts) | Prevent synchronous scripts. | +| <Check size={18} /> | [@next/next/no-title-in-document-head](/docs/messages/no-title-in-document-head) | Prevent usage of `<title>` with `Head` component from `next/document`. | +| <Check size={18} /> | @next/next/no-typos | Prevent common typos in [Next.js's data fetching functions](/docs/pages/building-your-application/data-fetching) | +| <Check size={18} /> | [@next/next/no-unwanted-polyfillio](/docs/messages/no-unwanted-polyfillio) | Prevent duplicate polyfills from Polyfill.io. | +| <Check size={18} /> | [@next/next/no-redirect-in-try-catch-without-rethrow](/docs/messages/no-redirect-in-try-catch-without-rethrow) | Ensure that when using `redirect` within a try-catch block, the catch block must start with a call to `unstable_rethrow` to ensure proper error propagation. | If you already have ESLint configured in your application, we recommend extending from this plugin directly instead of including `eslint-config-next` unless a few conditions are met. Refer to the [Recommended Plugin Ruleset](#recommended-plugin-ruleset) to learn more. diff --git a/errors/no-redirect-in-try-catch-without-rethrow.mdx b/errors/no-redirect-in-try-catch-without-rethrow.mdx new file mode 100644 index 0000000000000..7731baf644616 --- /dev/null +++ b/errors/no-redirect-in-try-catch-without-rethrow.mdx @@ -0,0 +1,45 @@ +--- +title: No Redirect in Try-Catch Without Rethrow +--- + +> Ensure that when using `redirect` within a try-catch block, the catch block must start with a call to `unstable_rethrow` to ensure proper error propagation. + +## Why This Error Occurred + +You attempted to use the `redirect` function within a try-catch block without rethrowing the error using `unstable_rethrow`. When `redirect` is called, it throws a `NEXT_REDIRECT` error internally. If this error is caught without being rethrown, it prevents the redirect from executing as intended and suppresses the error handling. + +## Possible Ways to Fix It + +To ensure proper error handling and that the redirect can proceed as intended, the catch block should start with a call to `unstable_rethrow`. This ensures that Next.js's internal error handling is respected and that the redirect is properly executed. + +If you need to handle other errors, you can still do so after the `unstable_rethrow` call. + +## Example + +### Incorrect Usage: + +```javascript +try { + // some code that might throw an error + redirect('/some-path') +} catch (error) { + // handle error +} +``` + +### Correct Usage: + +```javascript +try { + // some code that might throw an error + redirect('/some-path') +} catch (error) { + unstable_rethrow(error) + // handle other errors if necessary +} +``` + +## Useful Links + +- [Next.js Redirect Documentation](/docs/app/building-your-application/routing/redirecting#redirect-function) +- [Next.js Error Handling](/docs/app/building-your-application/routing/error-handling) diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 6eaeb64772a58..a3bf09b910dba 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -15,6 +15,7 @@ "fast-glob": "3.3.1" }, "devDependencies": { + "@types/estree": "1.0.5", "eslint": "8.56.0" }, "scripts": { diff --git a/packages/eslint-plugin-next/src/index.ts b/packages/eslint-plugin-next/src/index.ts index 78b8553f8f435..babb156900a41 100644 --- a/packages/eslint-plugin-next/src/index.ts +++ b/packages/eslint-plugin-next/src/index.ts @@ -21,6 +21,7 @@ module.exports = { 'no-title-in-document-head': require('./rules/no-title-in-document-head'), 'no-typos': require('./rules/no-typos'), 'no-unwanted-polyfillio': require('./rules/no-unwanted-polyfillio'), + 'no-redirect-in-try-catch-without-rethrow': require('./rules/no-redirect-in-try-catch-without-rethrow'), }, configs: { recommended: { @@ -49,6 +50,7 @@ module.exports = { '@next/next/no-duplicate-head': 'error', '@next/next/no-head-import-in-document': 'error', '@next/next/no-script-component-in-head': 'error', + '@next/next/no-redirect-in-try-catch-without-rethrow': 'error', }, }, 'core-web-vitals': { diff --git a/packages/eslint-plugin-next/src/rules/no-redirect-in-try-catch-without-rethrow.ts b/packages/eslint-plugin-next/src/rules/no-redirect-in-try-catch-without-rethrow.ts new file mode 100644 index 0000000000000..079345a22449a --- /dev/null +++ b/packages/eslint-plugin-next/src/rules/no-redirect-in-try-catch-without-rethrow.ts @@ -0,0 +1,84 @@ +import { defineRule } from '../utils/define-rule' +import type { Node, BlockStatement, ImportDeclaration } from 'estree' + +const url = + 'https://nextjs.org/docs/messages/no-redirect-in-try-catch-without-rethrow' + +export = defineRule({ + meta: { + docs: { + description: + 'Ensure that when using `redirect` within a try-catch block, the catch block must start with a call to `unstable_rethrow` to ensures that errors are correctly propagated.', + recommended: true, + url, + }, + type: 'problem', + schema: [], + }, + create(context) { + let redirectName = 'redirect' + let rethrowName = 'unstable_rethrow' + + function checkImport(node: ImportDeclaration) { + if (node.source.value === 'next/navigation') { + node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier') { + if (specifier.imported.name === 'redirect') { + redirectName = specifier.local.name + } else if (specifier.imported.name === 'unstable_rethrow') { + rethrowName = specifier.local.name + } + } + }) + } + } + + function isRethrowFirstStatement(blockStatement: BlockStatement): boolean { + const firstStatement = blockStatement.body[0] + return ( + firstStatement?.type === 'ExpressionStatement' && + firstStatement.expression.type === 'CallExpression' && + firstStatement.expression.callee.type === 'Identifier' && + firstStatement.expression.callee.name === rethrowName + ) + } + + function containsRedirectCall(node: Node): boolean { + switch (node.type) { + case 'ExpressionStatement': + return ( + node.expression.type === 'CallExpression' && + node.expression.callee.type === 'Identifier' && + node.expression.callee.name === redirectName + ) + case 'BlockStatement': + return node.body.some(containsRedirectCall) + case 'IfStatement': + return ( + containsRedirectCall(node.consequent) || + (node.alternate ? containsRedirectCall(node.alternate) : false) + ) + default: + return false + } + } + + return { + ImportDeclaration: checkImport, + TryStatement(node) { + const tryBlock = node.block + const catchBlock = node.handler.body + + if ( + containsRedirectCall(tryBlock) && + !isRethrowFirstStatement(catchBlock) + ) { + context.report({ + node: catchBlock, + message: `When using \`redirect\` in a try-catch block, ensure you include \`unstable_rethrow\` at the start of the catch block to properly handle Next.js errors. See: ${url}`, + }) + } + }, + } + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99de34509a773..591dd21595a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -828,6 +828,9 @@ importers: specifier: 3.3.1 version: 3.3.1 devDependencies: + '@types/estree': + specifier: 1.0.5 + version: 1.0.5 eslint: specifier: 8.56.0 version: 8.56.0 @@ -4977,9 +4980,6 @@ packages: '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} - '@types/estree@1.0.0': - resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} - '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -19405,7 +19405,7 @@ snapshots: '@types/acorn@4.0.6': dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 '@types/amphtml-validator@1.0.0': dependencies: @@ -19530,12 +19530,10 @@ snapshots: '@types/estree-jsx@1.0.0': dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 '@types/estree@0.0.39': {} - '@types/estree@1.0.0': {} - '@types/estree@1.0.5': {} '@types/events@3.0.0': {} @@ -23280,7 +23278,7 @@ snapshots: estree-util-attach-comments@2.1.1: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 estree-util-build-jsx@2.2.2: dependencies: @@ -23311,7 +23309,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 esutils@2.0.3: {} @@ -24349,7 +24347,7 @@ snapshots: hast-util-to-estree@2.2.1: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 '@types/estree-jsx': 1.0.0 '@types/hast': 2.3.1 '@types/unist': 2.0.3 @@ -25045,11 +25043,11 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 is-reference@3.0.1: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 is-regex@1.1.4: dependencies: @@ -26968,7 +26966,7 @@ snapshots: micromark-util-events-to-acorn@1.2.1: dependencies: '@types/acorn': 4.0.6 - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 estree-util-visit: 1.2.1 micromark-util-types: 1.0.2 uvu: 0.5.6 @@ -28083,7 +28081,7 @@ snapshots: periscopic@3.1.0: dependencies: - '@types/estree': 1.0.0 + '@types/estree': 1.0.5 estree-walker: 3.0.3 is-reference: 3.0.1 diff --git a/test/unit/eslint-plugin-next/no-redirect-in-try-catch-without-rethrow.test.ts b/test/unit/eslint-plugin-next/no-redirect-in-try-catch-without-rethrow.test.ts new file mode 100644 index 0000000000000..22a0aff3de6cb --- /dev/null +++ b/test/unit/eslint-plugin-next/no-redirect-in-try-catch-without-rethrow.test.ts @@ -0,0 +1,126 @@ +import rule from '@next/eslint-plugin-next/dist/rules/no-redirect-in-try-catch-without-rethrow' +import { RuleTester } from 'eslint' +;(RuleTester as any).setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) +const ruleTester = new RuleTester() + +ruleTester.run('no-redirect-in-try-catch-without-rethrow', rule, { + valid: [ + `'use server' + + import { redirect } from "next/navigation" + + export async function navigate(data) { + redirect(\`/posts/\${data.get('id')}\`) + }`, + `'use server' + + import { redirect, unstable_rethrow } from "next/navigation" + + export async function navigate(data) { + try { + redirect(\`/posts/\${data.get('id')}\`) + } catch (error) { + unstable_rethrow(error) + } + }`, + `'use server' + + import { redirect, unstable_rethrow as rethrow } from "next/navigation" + + export async function navigate(data) { + try { + redirect(\`/posts/\${data.get('id')}\`) + } catch (error) { + rethrow(error) + } + }`, + `'use server' + + import * as Navigation from "next/navigation" + + export async function navigate(data) { + try { + Navigation.redirect(\`/posts/\${data.get('id')}\`) + } catch (error) { + Navigation.unstable_rethrow(error) + } + }`, + ], + invalid: [ + { + code: ` + 'use server' + + import { redirect } from "next/navigation" + + export async function navigate(data) { + try { + redirect(\`/posts/\${data.get('id')}\`) + } catch (e) { + console.error(e); + } + }`, + filename: 'app/actions.ts', + errors: [ + { + message: + 'When using `redirect` in a try-catch block, ensure you include `unstable_rethrow` at the start of the catch block to properly handle Next.js errors. See: https://nextjs.org/docs/messages/no-redirect-in-try-catch-without-rethrow', + }, + ], + }, + { + code: ` + 'use server' + + import { redirect } from "next/navigation" + + export async function navigate(data) { + try { + if (data.id) { + redirect(\`/posts/\${data.id}\`) + } + } catch (e) { + console.error(e); + } + }`, + filename: 'app/actions.ts', + errors: [ + { + message: + 'When using `redirect` in a try-catch block, ensure you include `unstable_rethrow` at the start of the catch block to properly handle Next.js errors. See: https://nextjs.org/docs/messages/no-redirect-in-try-catch-without-rethrow', + }, + ], + }, + { + code: ` + 'use server' + + import { redirect, unstable_rethrow } from "next/navigation" + + export async function navigate(data) { + try { + redirect(\`/posts/\${data.get('id')}\`) + } catch (e) { + console.error(e); + unstable_rethrow(e); + } + }`, + filename: 'app/actions.ts', + errors: [ + { + message: + 'When using `redirect` in a try-catch block, ensure you include `unstable_rethrow` at the start of the catch block to properly handle Next.js errors. See: https://nextjs.org/docs/messages/no-redirect-in-try-catch-without-rethrow', + }, + ], + }, + ], +})