This commit is contained in:
sunub 2025-12-23 15:34:34 -03:00 committed by GitHub
commit d489e2946c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 456 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

@ -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();
<div>{isBrowser ? "Client" : "Server"}</div>;
}`,
},
{
code: `
import useIsBrowser from '@docusaurus/useIsBrowser';
function SomeComponent() {
const isBrowser = useIsBrowser();
const component = isBrowser ? <Client /> : <Server />;
return <div>{component}</div>;
}`,
},
{
code: `
function SomeComponent() {
React.useEffect(() => {
if(typeof window === 'undefined') {
doSomethingForSSR();
}
}, []);
return <Translate>text</Translate>;
}`,
},
{
code: `
function SomeComponent() {
function handleClick() {
if (typeof window === 'undefined') {
doSomethingForSSR();
}
}
return <button onClick={handleClick}>Click me</button>;
}`,
},
],
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" ? <Server /> : <Client />;',
errors,
},
{
code: 'return <div>{typeof window === "undefined" ? "SSR" : "CSR"}</div>;',
errors,
},
{
code: 'const isClient = typeof window !== undefined && someCheck();',
errors,
},
],
});

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

View File

@ -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 <div>{isClient ? 'Client Content' : 'Server Placeholder'}</div>;
};
// 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 <div>{isBrowser ? 'Client HTML' : 'Server HTML'}</div>;
};
```
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