Skip to content

feat: make module window check more robust #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/honest-peas-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"eslint-plugin-react-server-components": minor
---

Making checks for window usage more robust to not require "use client" when safely accessed behind a `typeof window !== 'undefined'` or `typeof document !== 'undefined'`check.

For example:
```
const HREF = typeof window !== 'undefined' ? window.location.href : '';

const MyComponent = () => {
return <div>{HREF}</div>;
}
```
does not need to be marked with a "use client" because all of it's client only actions are behind a server check.
110 changes: 89 additions & 21 deletions src/rules/use-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,21 @@ describe("use client", () => {
code: 'const foo = "bar"',
},
{
code: `import {createContext, useContext, useEffect} from 'react';
const context = createContext()
export function useTheme() {
const context = useContext(context);
useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
code: `const HREF = typeof window === 'undefined' ? undefined : window.location.href;`,
},
{
code: `import * as React from 'react';
const context = React.createContext()
export function Foo() {
return <div />;
}
export function useTheme() {
const context = React.useContext(context);
React.useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
Comment on lines -38 to -49
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved these to the invalid section cause honestly I'm not sure why they were valid to being with. From my local testing if I have const context = React.createContext() at the top of a component file and use that component in a server context I get a runtime error. If there's something I'm missing please let me know!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do move forward with it and you feel like this warrants a major LMK 👍

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would probably be best as a major. Happy to leave this in and cut the next release as a major

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roginfarrer When you say "leave this in" are you saying I should leave my changeset as is? Or update that to a major? Or just remove it all together?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roginfarrer just wondering if you have a chance to answer ☝️ and then I can make the changes and get this merged

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why they pass in both valid and invalid sections? Or did your changes make them invalid?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah my changes made them invalid. And when I tested the scenario in a small next project, I would get runtime client only errors in the dev server related to the React Context so I think having them be invalid is correct. Please feel free to double check that though!

code: `const HREF = typeof window !== 'undefined' ? window.location.href : '';`,
},
{
code: `const el = typeof document === 'undefined' ? undefined : document.createElement('element');`,
},
{
code: `const foo = "bar";
function Bar() {
if(typeof document !== 'undefined')
document.addEventListener('scroll', () => {})
return <div />;
}`,
},
],
invalid: [
Expand Down Expand Up @@ -99,6 +91,82 @@ function Bar() {
window.addEventListener('scroll', () => {})
return <div />;
}`,
},
{
code: `import {createContext, useContext, useEffect} from 'react';

const context = createContext()
export function useTheme() {
const context = useContext(context);
useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
errors: [{ messageId: "addUseClientBrowserAPI" }],
output: `'use client';

import {createContext, useContext, useEffect} from 'react';

const context = createContext()
export function useTheme() {
const context = useContext(context);
useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
},
{
code: `import * as React from 'react';

const context = React.createContext()
export function Foo() {
return <div />;
}
export function useTheme() {
const context = React.useContext(context);
React.useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
errors: [{ messageId: "addUseClientBrowserAPI" }],
output: `'use client';

import * as React from 'react';

const context = React.createContext()
export function Foo() {
return <div />;
}
export function useTheme() {
const context = React.useContext(context);
React.useEffect(() => {
window.setTimeout(() => {});
});
return context;
}`,
},
{
code: `const HREF = typeof window === 'undefined' ? window.location.href : window.location.href.slice(0,10);`,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good edge case to catch!

errors: [{ messageId: "addUseClientBrowserAPI" }],
output: `'use client';

const HREF = typeof window === 'undefined' ? window.location.href : window.location.href.slice(0,10);`,
},
{
code: `let HREF = '';
if (typeof window === 'undefined') {
HREF = window.location.href;
}`,
errors: [{ messageId: "addUseClientBrowserAPI" }],
output: `'use client';

let HREF = '';
if (typeof window === 'undefined') {
HREF = window.location.href;
}`,
},
// OBSERVERS
{
Expand Down
133 changes: 114 additions & 19 deletions src/rules/use-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Rule } from "eslint";
import type {
BinaryExpression,
Expression,
ExpressionStatement,
Identifier,
Expand All @@ -26,6 +27,8 @@ const browserOnlyGlobals = Object.keys(globals.browser).reduce<
return acc;
}, new Set());

const validGlobalsForServerChecks = new Set(["document", "window"]);

type Options = [
{
allowedServerHooks?: string[];
Expand Down Expand Up @@ -113,12 +116,97 @@ const create = Components.detect(
});
}

function findFirstParentOfType(
node: Rule.Node,
type: string
): Rule.Node | null {
let currentNode: Rule.Node | null = node;

while (currentNode) {
if (currentNode.type === type) {
return currentNode;
}
currentNode = currentNode?.parent;
}

return null;
}

function isNodeInTree(node: Rule.Node, target: Rule.Node): boolean {
let currentNode: Rule.Node | null = node;

while (currentNode) {
if (currentNode === target) {
return true;
}
currentNode = currentNode.parent;
}

return false;
}

function getBinaryBranchExecutedOnServer(node: BinaryExpression): {
isWindowCheck: boolean;
serverBranch: Rule.Node | null;
} {
const isWindowCheck =
node.left?.type === "UnaryExpression" &&
node.left.operator === "typeof" &&
node.left.argument?.type === "Identifier" &&
validGlobalsForServerChecks.has(node.left.argument?.name) &&
node.right?.type === "Literal" &&
node.right.value === "undefined" &&
(node.operator === "===" || node.operator === "!==");

let serverBranch = null;

if (!isWindowCheck) {
return { isWindowCheck, serverBranch };
}

//@ts-expect-error
const { parent } = node;
if (!parent) {
Comment on lines +137 to +139
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this fix the error?

Suggested change
//@ts-expect-error
const { parent } = node;
if (!parent) {
const parent = node?.parent
if (!parent) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the error comes from the parent field apparently not being a part of the BinaryExpression type 🤔

Property 'parent' does not exist on type 'BinaryExpression'.

return { isWindowCheck, serverBranch };
}

if (node.operator === "===") {
serverBranch =
parent.type === "IfStatement" ||
parent.type === "ConditionalExpression"
? parent.alternate
: null;
} else {
serverBranch =
parent.type === "IfStatement" ||
parent.type === "ConditionalExpression"
? parent.consequent
: null;
}

return { isWindowCheck, serverBranch };
}

const isNodePartOfSafelyExecutedServerBranch = (
node: Rule.Node
): boolean => {
let isUsedInServerBranch = false;
serverBranches.forEach((serverBranch) => {
if (isNodeInTree(node, serverBranch)) {
isUsedInServerBranch = true;
}
});
return isUsedInServerBranch;
};

const reactImports: Record<string | "namespace", string | string[]> = {
namespace: [],
};

const undeclaredReferences = new Set();

const serverBranches = new Set<Rule.Node>();

return {
Program(node) {
for (const block of node.body) {
Expand Down Expand Up @@ -195,26 +283,33 @@ const create = Components.detect(
});
}
},
MemberExpression(node) {
// Catch uses of browser APIs in module scope
// or React component scope.
// eg:
// const foo = window.foo
// window.addEventListener(() => {})
// const Foo() {
// const foo = window.foo
// return <div />;
// }
Identifier(node) {
const name = node.name;
// @ts-expect-error
const name = node.object.name;
const scopeType = context.getScope().type;
if (
undeclaredReferences.has(name) &&
browserOnlyGlobals.has(name) &&
(scopeType === "module" || !!util.getParentComponent(node))
) {
instances.push(name);
reportMissingDirective("addUseClientBrowserAPI", node.object);
if (undeclaredReferences.has(name) && browserOnlyGlobals.has(name)) {
// find the nearest binary expression so we can see if this instance of window is being used in a `typeof window === undefined`-like check
const binaryExpressionNode = findFirstParentOfType(
node,
"BinaryExpression"
) as BinaryExpression | null;
if (binaryExpressionNode) {
const { isWindowCheck, serverBranch } =
getBinaryBranchExecutedOnServer(binaryExpressionNode);
// if this instance isn't part of a window check we report it
if (!isWindowCheck) {
instances.push(name);
reportMissingDirective("addUseClientBrowserAPI", node);
} else if (isWindowCheck && serverBranch) {
// if it is part of a window check, we don't report it and we save the server branch so we can check if future window instances are a part of the branch of code safely executed on the server
serverBranches.add(serverBranch);
}
} else {
// if the window usage isn't part of the binary expression, we check to see if it's part of a safely checked server branch and report if not
if (!isNodePartOfSafelyExecutedServerBranch(node)) {
instances.push(name);
reportMissingDirective("addUseClientBrowserAPI", node);
}
}
}
},
ExpressionStatement(node) {
Expand Down