diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 0b767ef631..03aa615ef5 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -15,6 +15,7 @@ export = { plugins: ['@docusaurus'], rules: { '@docusaurus/string-literal-i18n-messages': 'error', + '@docusaurus/no-window-eq-undefined': 'error', '@docusaurus/no-html-links': 'warn', '@docusaurus/prefer-docusaurus-heading': 'warn', }, @@ -23,6 +24,7 @@ export = { plugins: ['@docusaurus'], rules: { '@docusaurus/string-literal-i18n-messages': 'error', + '@docusaurus/no-window-eq-undefined': 'error', '@docusaurus/no-untranslated-text': 'warn', '@docusaurus/no-html-links': 'warn', '@docusaurus/prefer-docusaurus-heading': 'warn', diff --git a/packages/eslint-plugin/src/rules/__tests__/no-window-eq-undefined.test.ts b/packages/eslint-plugin/src/rules/__tests__/no-window-eq-undefined.test.ts new file mode 100644 index 0000000000..49b12a4e6f --- /dev/null +++ b/packages/eslint-plugin/src/rules/__tests__/no-window-eq-undefined.test.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import rule from '../no-window-eq-undefined'; +import {RuleTester, getCommonValidTests} from './testUtils'; + +const errors = [{messageId: 'noWindowEqUndefined'}] as const; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('no-window-eq-undefined', rule, { + valid: [ + ...getCommonValidTests(), + { + code: ` + import useIsBrowser from '@docusaurus/useIsBrowser'; + + function SomeComponent() { + const isBrowser = useIsBrowser(); +
{isBrowser ? "Client" : "Server"}
; + }`, + }, + { + code: ` + import useIsBrowser from '@docusaurus/useIsBrowser'; + + function SomeComponent() { + const isBrowser = useIsBrowser(); + const component = isBrowser ? : ; + return
{component}
; + }`, + }, + { + code: ` + function SomeComponent() { + React.useEffect(() => { + if(typeof window === 'undefined') { + doSomethingForSSR(); + } + }, []); + return text; + }`, + }, + { + code: ` + function SomeComponent() { + function handleClick() { + if (typeof window === 'undefined') { + doSomethingForSSR(); + } + } + return ; + }`, + }, + ], + invalid: [ + { + code: 'if (typeof window === "undefined") { doSomething(); }', + errors, + }, + { + code: 'if (typeof window !== undefined) { doSomething(); }', + errors, + }, + { + code: 'if (typeof window == "undefined") { doSomething(); }', + errors, + }, + { + code: 'if (typeof window != undefined) { doSomething(); }', + errors, + }, + { + code: 'if ("undefined" === typeof window) { doSomething(); }', + errors, + }, + { + code: 'if ("undefined" !== typeof window) { doSomething(); }', + errors, + }, + { + code: 'if (undefined == typeof window) { doSomething(); }', + errors, + }, + { + code: 'if (undefined != typeof window) { doSomething(); }', + errors, + }, + { + code: 'const isBrowser = typeof window === "undefined";', + errors, + }, + { + code: 'const component = typeof window != "undefined" ? : ;', + errors, + }, + { + code: 'return
{typeof window === "undefined" ? "SSR" : "CSR"}
;', + errors, + }, + { + code: 'const isClient = typeof window !== undefined && someCheck();', + errors, + }, + ], +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index caa726907f..11b0333f0a 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -9,10 +9,12 @@ import noHtmlLinks from './no-html-links'; import preferDocusaurusHeading from './prefer-docusaurus-heading'; import noUntranslatedText from './no-untranslated-text'; import stringLiteralI18nMessages from './string-literal-i18n-messages'; +import noWindowEqUndefined from './no-window-eq-undefined'; export default { 'no-untranslated-text': noUntranslatedText, 'string-literal-i18n-messages': stringLiteralI18nMessages, 'no-html-links': noHtmlLinks, 'prefer-docusaurus-heading': preferDocusaurusHeading, + 'no-window-eq-undefined': noWindowEqUndefined, }; diff --git a/packages/eslint-plugin/src/rules/no-window-eq-undefined.ts b/packages/eslint-plugin/src/rules/no-window-eq-undefined.ts new file mode 100644 index 0000000000..0d8d51f9a4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-window-eq-undefined.ts @@ -0,0 +1,269 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {createRule} from '../util'; +import type {TSESTree} from '@typescript-eslint/types/dist/ts-estree'; + +type Options = []; + +type MessageIds = 'noWindowEqUndefined'; + +type ExpressionOrPrivateIdentifier = + | TSESTree.Expression + | TSESTree.PrivateIdentifier; + +export default createRule({ + name: 'no-window-eq-undefined', + meta: { + type: 'problem', + docs: { + description: + "forbid typeof window !== 'undefined' because this is not an adequate way to escape SSR", + recommended: false, + }, + schema: [], + messages: { + noWindowEqUndefined: + "Do not use 'typeof window' to synchronously detect SSR. This can cause hydration mismatches.", + }, + }, + defaultOptions: [], + create(context) { + let lastEffect: TSESTree.CallExpression | null = null; + return { + CallExpression(node) { + const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee); + if ( + isEffectIdentifier(nodeWithoutNamespace) && + node.arguments.length > 0 + ) { + lastEffect = node; + } + }, + 'CallExpression:exit': function CallExpressionExit( + node: TSESTree.CallExpression, + ) { + if (node === lastEffect) { + lastEffect = null; + } + }, + BinaryExpression(node) { + if (!isEquality(node) && !isStrictEquality(node)) { + return; + } + + const {left, right} = node; + const isMatch = + (isTypeofWindowCheck(left) && isUndefined(right)) || + (isUndefined(left) && isTypeofWindowCheck(right)); + if (isMatch) { + if (lastEffect !== null) { + return; + } + + const ancestors = context.getAncestors(); + const parentFunction = getParentFunctionNode(ancestors); + + if (parentFunction) { + const functionNameNode = getFunctionName(parentFunction); + + if ( + !functionNameNode || + (!isComponentName(functionNameNode) && !isHook(functionNameNode)) + ) { + return; + } + } + + context.report({ + node, + messageId: 'noWindowEqUndefined', + }); + } + }, + }; + }, +}); + +function isStrictEquality( + node: TSESTree.Expression, +): node is TSESTree.BinaryExpression { + return ( + node.type === 'BinaryExpression' && + (node.operator === '===' || node.operator === '!==') + ); +} + +function isEquality( + node: TSESTree.Expression, +): node is TSESTree.BinaryExpression { + return ( + node.type === 'BinaryExpression' && + (node.operator === '==' || node.operator === '!=') + ); +} + +function isTypeofStatement( + node: ExpressionOrPrivateIdentifier, +): node is TSESTree.UnaryExpression { + return ( + node.type === 'UnaryExpression' && node.operator === 'typeof' && node.prefix + ); +} + +function isTypeofWindowCheck(node: ExpressionOrPrivateIdentifier): boolean { + if (isTypeofStatement(node)) { + const argument = node.argument; + return argument.type === 'Identifier' && argument.name === 'window'; + } + return false; +} + +function isUndefinedStringLiteral( + node: ExpressionOrPrivateIdentifier, +): boolean { + return ( + node.type === 'Literal' && (node as TSESTree.Literal).value === 'undefined' + ); +} + +function isUndefinedReference(node: ExpressionOrPrivateIdentifier): boolean { + return node.type === 'Identifier' && node.name === 'undefined'; +} + +function isUndefined(node: ExpressionOrPrivateIdentifier): boolean { + return isUndefinedStringLiteral(node) || isUndefinedReference(node); +} + +// --- 2B. React/AST 헬퍼 (Effect, Component, Hook) --- + +function getParentFunctionNode(ancestors: TSESTree.Node[]) { + let parentFunction: TSESTree.Node | undefined; + + for (let i = ancestors.length - 1; i >= 0; i -= 1) { + const n = ancestors[i]; + if (!n) { + continue; + } + + if ( + n.type === 'FunctionExpression' || + n.type === 'FunctionDeclaration' || + n.type === 'ArrowFunctionExpression' + ) { + parentFunction = n; + break; + } + } + return parentFunction; +} + +function getFunctionName(node: TSESTree.Node) { + if ( + // @ts-expect-error parser-hermes produces these node types + node.type === 'ComponentDeclaration' || + // @ts-expect-error parser-hermes produces these node types + node.type === 'HookDeclaration' || + node.type === 'FunctionDeclaration' || + (node.type === 'FunctionExpression' && node.id) + ) { + return node.id; + } else if ( + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + if ( + node.parent?.type === 'VariableDeclarator' && + node.parent.init === node + ) { + return node.parent.id; + } else if ( + node.parent?.type === 'AssignmentExpression' && + node.parent.right === node && + node.parent.operator === '=' + ) { + return node.parent.left; + } else if ( + node.parent?.type === 'Property' && + node.parent.value === node && + !node.parent.computed + ) { + return node.parent.key; + } else if ( + node.parent?.type === 'AssignmentPattern' && + node.parent.right === node && + // @ts-expect-error Property computed does not exist on type `AssignmentPattern`. + !node.parent.computed + ) { + return node.parent.left; + } else { + return undefined; + } + } else { + return undefined; + } +} + +function isHookName(s: string): boolean { + return s === 'use' || /^use[A-Z\d]/.test(s); +} + +function isHook(node: TSESTree.Node): boolean { + if (node.type === 'Identifier') { + return isHookName(node.name); + } else if ( + node.type === 'MemberExpression' && + !node.computed && + isHook(node.property) + ) { + const obj = node.object; + const isPascalCaseNameSpace = /^[A-Z].*/; + return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name); + } else { + return false; + } +} + +function isComponentName(node: TSESTree.Node): boolean { + return node.type === 'Identifier' && /^[A-Z]/.test(node.name); +} + +function getNodeWithoutReactNamespace( + node: TSESTree.Expression | TSESTree.Super, +): TSESTree.Expression | TSESTree.Identifier | TSESTree.Super { + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'React' && + node.property.type === 'Identifier' && + !node.computed + ) { + return node.property; + } + return node; +} + +function isEffectIdentifier( + node: TSESTree.Node, + additionalHooks?: RegExp, +): boolean { + const isBuiltInEffect = + node.type === 'Identifier' && + (node.name === 'useEffect' || + node.name === 'useLayoutEffect' || + node.name === 'useInsertionEffect'); + + if (isBuiltInEffect) { + return true; + } + + if (additionalHooks && node.type === 'Identifier') { + return additionalHooks.test(node.name); + } + + return false; +} diff --git a/website/docs/api/misc/eslint-plugin/no-window-eq-undefined.mdx b/website/docs/api/misc/eslint-plugin/no-window-eq-undefined.mdx new file mode 100644 index 0000000000..887f434105 --- /dev/null +++ b/website/docs/api/misc/eslint-plugin/no-window-eq-undefined.mdx @@ -0,0 +1,66 @@ +--- +slug: /api/misc/@docusaurus/eslint-plugin/no-window-eq-undefined +--- + +# no-window-eq-undefined + +Enforce not using typeof window, window === undefined, or any similar expression to detect the SSR (Server-Side Rendering) environment. + +In Docusaurus, it's recommended to safely separate client-side code using the `@docusaurus/useIsBrowser` hook or React lifecycle methods like `useEffect`. + +## Motivation and Rationale + +In web development, checking for the existence of the window object (e.g., typeof window !== 'undefined') is a common way to determine if code is running in a browser (client) or on a server (Node.js). + +However, in React applications, especially those using SSR like Docusaurus, this pattern is inappropriate and risky for the following reasons: + +**SSR Conflict**: When React components are rendered on the server (Node.js), attempting to access the global window object in the top-level scope or during the initial render phase will lead to build errors or hydration mismatches when the client-side JavaScript takes over. + +**Inconsistency**: Docusaurus requires robust mechanisms to ensure code only runs on the client. Checking typeof window is prone to subtle issues and doesn't align with the framework's recommended practice for environment segregation. + +## Rule Details {#details} + +** Examples of **incorrect** code for this rule ** : + +```js +import React from 'react'; + +// 1. Using typeof window for conditional rendering (runs during SSR) +const MyComponent = () => { + // This is executed on the server and is dangerous. + const isClient = typeof window !== 'undefined'; + + return
{isClient ? 'Client Content' : 'Server Placeholder'}
; +}; + +// 2. Using direct window comparison +if (window === undefined) { + // This dangerous pattern is discouraged, even if it seems to work. + console.log('Running on server'); +} +``` + +The following code uses banned patterns to conditionally render or execute logic. + +--- + +**Examples of **correct** code for this rule** : + +```jsx +import React from 'react'; +import useIsBrowser from '@docusaurus/useIsBrowser'; + +const App = () => { + // isBrowser is safely managed by Docusaurus. + const isBrowser = useIsBrowser(); + + return
{isBrowser ? 'Client HTML' : 'Server HTML'}
; +}; +``` + +This hook ensures the value is only true after the component has mounted on the client. + +## Further Reading + +- https://docusaurus.io/docs/docusaurus-core#useIsBrowser +- https://react.dev/reference/rules/rules-of-hooks