From 55dfec82b050c7e2513b3fa40553efe194eb020d Mon Sep 17 00:00:00 2001 From: sunub Date: Fri, 31 Oct 2025 12:29:31 +0900 Subject: [PATCH] feat(esfeat(eslint-plugin): add no-window-eq-undefined rule and tests to prevent SSR unsafe window checks --- packages/eslint-plugin/src/index.ts | 2 + packages/eslint-plugin/src/rules/index.ts | 2 + .../src/rules/no-window-eq-undefined.ts | 269 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/no-window-eq-undefined.ts 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/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; +}