feat(esfeat(eslint-plugin): add no-window-eq-undefined rule and tests to prevent SSR unsafe window checks

This commit is contained in:
sunub 2025-10-31 12:29:31 +09:00
parent a4742594a9
commit 55dfec82b0
3 changed files with 273 additions and 0 deletions

View File

@ -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',

View File

@ -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,
};

View File

@ -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<Options, MessageIds>({
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;
}