-
Notifications
You must be signed in to change notification settings - Fork 6
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
base: main
Are you sure you want to change the base?
Changes from 7 commits
de40373
558c4bd
98c1fd6
2996b70
9846500
fe5dec3
8a19819
5ea4aec
943176a
f0474fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
}`, | ||
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') | ||
roginfarrer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
document.addEventListener('scroll', () => {}) | ||
return <div />; | ||
}`, | ||
}, | ||
], | ||
invalid: [ | ||
|
@@ -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);`, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
{ | ||
|
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, | ||||||||||||
|
@@ -26,6 +27,8 @@ const browserOnlyGlobals = Object.keys(globals.browser).reduce< | |||||||||||
return acc; | ||||||||||||
}, new Set()); | ||||||||||||
|
||||||||||||
const validGlobalsForServerChecks = new Set(["document", "window"]); | ||||||||||||
cmackenthun marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
|
||||||||||||
type Options = [ | ||||||||||||
{ | ||||||||||||
allowedServerHooks?: string[]; | ||||||||||||
|
@@ -113,12 +116,97 @@ const create = Components.detect( | |||||||||||
}); | ||||||||||||
} | ||||||||||||
|
||||||||||||
function findFirstParentOfType( | ||||||||||||
cmackenthun marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this fix the error?
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, the error comes from the
|
||||||||||||
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) { | ||||||||||||
|
@@ -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) { | ||||||||||||
|
There was a problem hiding this comment.
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!There was a problem hiding this comment.
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 👍
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!