Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -2458,10 +2458,15 @@ function computeEffectsForLegacySignature(
category: ErrorCategory.IncompatibleLibrary,
reason: 'Use of incompatible library',
description: [
'This API returns functions which cannot be memoized without leading to stale UI. ' +
'To prevent this, by default React Compiler will skip memoizing this component/hook. ' +
'However, you may see issues if values from this API are passed to other components/hooks that are ' +
'memoized',
'⚠️ Incompatible API detected\n\n' +
'This API cannot be safely memoized.\n\n' +
'**Recommendation:**\n' +
'Add "use no memo" directive to opt-out of memoization:\n\n' +
'function useCustomHook() {\n' +
' "use no memo";\n' +
' const api = useIncompatibleAPI({...});\n' +
' ...\n' +
'}',
Comment on lines +2461 to +2469
Copy link
Member

Choose a reason for hiding this comment

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

This is incorrect, the point of this validation is that the compiler automatically skips code calling incompatible libraries

].join(''),
}).withDetails({
kind: 'error',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

## Input

```javascript
import {useVirtualizer} from '@tanstack/react-virtual';
import {useEffect} from 'react';

// This test verifies that incompatible-library warnings are shown
// even when eslint-disable-next-line is present in the function
export function useVirtualScroll({itemList, hasNextPage, isFetchingNextPage}) {
const parentRef = {current: null};

// This should trigger incompatible-library warning
const rowVirtualizer = useVirtualizer({
count: itemList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
});

// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const virtualItems = rowVirtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
if (lastItem && lastItem.index >= itemList.length - 5) {
if (hasNextPage && !isFetchingNextPage) {
// fetchNextPage();
}
}
}, [hasNextPage, isFetchingNextPage]);

return {parentRef, rowVirtualizer};
}

```


## Error

```
Found 1 error:

Compilation Skipped: Use of incompatible library

⚠️ Incompatible API detected

This API cannot be safely memoized.

**Recommendation:**
Add "use no memo" directive to opt-out of memoization:

function useCustomHook() {
"use no memo";
const api = useIncompatibleAPI({...});
...
}

error.incompatible-with-eslint-disable.ts:10:25
8 |
9 | // This should trigger incompatible-library warning
> 10 | const rowVirtualizer = useVirtualizer({
| ^^^^^^^^^^^^^^^ TanStack Virtual's `useVirtualizer()` API returns functions that cannot be memoized safely
11 | count: itemList.length,
12 | getScrollElement: () => parentRef.current,
13 | estimateSize: () => 60,
```



Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {useVirtualizer} from '@tanstack/react-virtual';
import {useEffect} from 'react';

// This test verifies that incompatible-library warnings are shown
// even when eslint-disable-next-line is present for the entire function
// eslint-disable-next-line react-hooks/exhaustive-deps
export function useVirtualScroll({itemList, hasNextPage, isFetchingNextPage}) {
const parentRef = {current: null};

// This should trigger incompatible-library warning
// even though the function has eslint-disable-next-line
const rowVirtualizer = useVirtualizer({
count: itemList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
});

useEffect(() => {
const virtualItems = rowVirtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
if (lastItem && lastItem.index >= itemList.length - 5) {
if (hasNextPage && !isFetchingNextPage) {
// fetchNextPage();
}
}
}, [hasNextPage, isFetchingNextPage, rowVirtualizer]);

return {parentRef, rowVirtualizer};
}
177 changes: 174 additions & 3 deletions packages/eslint-plugin-react-hooks/src/shared/ReactCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,84 @@ function hasFlowSuppression(
return false;
}

function hasFunctionSuppression(context: Rule.RuleContext, node: any): boolean {
const sourceCode = context.sourceCode ?? context.getSourceCode();
const allComments = sourceCode.getAllComments?.() || [];

// Get function start line
const functionStart = node.loc?.start.line;
if (!functionStart) return false;

for (const comment of allComments) {
const commentLine = comment.loc?.end.line;
if (!commentLine) continue;

// Check for eslint-disable-next-line before function
if (
commentLine === functionStart - 1 &&
comment.value.includes('eslint-disable-next-line') &&
comment.value.includes('react-hooks')
) {
return true;
}

// Check for eslint-disable block encompassing function
if (
commentLine <= functionStart &&
comment.value.includes('eslint-disable') &&
!comment.value.includes('eslint-disable-next-line') &&
comment.value.includes('react-hooks')
) {
// Check if there's a corresponding eslint-enable after function start
const enableComment = allComments.find(
c =>
c.loc &&
c.loc.start.line > commentLine &&
c.value.includes('eslint-enable'),
);
if (
!enableComment ||
(enableComment.loc && enableComment.loc.start.line > functionStart)
) {
return true;
}
}
}
return false;
}

function containsIncompatibleAPI(
context: Rule.RuleContext,
node: any,
): {found: boolean; loc?: any} {
const sourceCode = context.sourceCode ?? context.getSourceCode();
const text = sourceCode.getText(node);

// Known incompatible APIs from React Compiler
const incompatibleAPIs = [
'useVirtualizer',
// Add more as needed
];

for (const api of incompatibleAPIs) {
const regex = new RegExp(`\\b${api}\\s*\\(`, 'g');
const match = regex.exec(text);
if (match) {
// Try to find approximate location
const lines = text.substring(0, match.index).split('\n');
const line = (node.loc?.start.line || 0) + lines.length - 1;
return {
found: true,
loc: {
start: {line, column: 0},
end: {line, column: 80},
},
};
}
}
return {found: false};
}

function makeRule(rule: LintRule): Rule.RuleModule {
const create = (context: Rule.RuleContext): Rule.RuleListener => {
const result = getReactCompilerResult(context);
Expand All @@ -128,20 +206,113 @@ function makeRule(rule: LintRule): Rule.RuleModule {
// If Flow already caught this error, we don't need to report it again.
continue;
}
// Check if this line has eslint-disable for react-hooks
const sourceCode = context.sourceCode ?? context.getSourceCode();
const lineNum = loc.start.line;
let hasESLintDisable = false;

const allComments = sourceCode.getAllComments?.() || [];
for (const comment of allComments) {
const commentLine = comment.loc?.end.line;
if (!commentLine) continue;

// Check for eslint-disable-next-line or eslint-disable
if (
(commentLine === lineNum - 1 &&
comment.value.includes('eslint-disable-next-line') &&
comment.value.includes('react-hooks')) ||
(commentLine <= lineNum &&
comment.value.includes('eslint-disable') &&
!comment.value.includes('eslint-disable-next-line') &&
comment.value.includes('react-hooks'))
) {
hasESLintDisable = true;
break;
}
}

// For incompatible-library warnings with eslint-disable, show critical warning
let message = detail.printErrorMessage(result.sourceCode, {
eslint: true,
});

if (rule.category === 'IncompatibleLibrary' && hasESLintDisable) {
message =
'🚨 This hook will NOT be memoized\n\n' +
"You're using an incompatible API AND have eslint-disable in this function.\n" +
'React Compiler will skip memoization of this hook.\n\n' +
'**Critical: Impact on parent components**\n' +
"If this hook is used in a MEMOIZED component, it will break the component's\n" +
'memoization by returning new object references every render.\n\n' +
'**Required action:**\n' +
'Add "use no memo" to COMPONENTS that use this hook:\n\n' +
'function MyComponent() {\n' +
' "use no memo"; // ← Add this!\n' +
' const { data } = useThisHook({...});\n' +
' return <div>...</div>;\n' +
'}\n\n' +
'**Alternative solutions:**\n' +
'1. Remove eslint-disable from this hook and fix dependency issues\n' +
'2. Use this API directly in components (not in custom hooks)';
}

/*
* TODO: if multiple rules report the same linter category,
* we should deduplicate them with a "reported" set
*/
context.report({
message: detail.printErrorMessage(result.sourceCode, {
eslint: true,
}),
message,
loc,
suggest: makeSuggestions(detail.options),
});
}
}
}

// For incompatible-library rule, also check functions with suppressions
// that React Compiler skipped analyzing
if (rule.category === 'IncompatibleLibrary') {
return {
'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression'(
node: any,
) {
// Only check if function has suppression
if (!hasFunctionSuppression(context, node)) {
return;
}

// Check if function contains incompatible API
const result = containsIncompatibleAPI(context, node);
if (!result.found) {
return;
}

// Report critical warning
context.report({
node,
loc: result.loc || node.loc,
message:
'🚨 This hook will NOT be memoized\n\n' +
"You're using an incompatible API AND have eslint-disable in this function.\n" +
'React Compiler will skip memoization of this hook.\n\n' +
'**Critical: Impact on parent components**\n' +
"If this hook is used in a MEMOIZED component, it will break the component's\n" +
'memoization by returning new object references every render.\n\n' +
'**Required action:**\n' +
'Add "use no memo" to COMPONENTS that use this hook:\n\n' +
'function MyComponent() {\n' +
' "use no memo"; // ← Add this!\n' +
' const { data } = useThisHook({...});\n' +
' return <div>...</div>;\n' +
'}\n\n' +
'**Alternative solutions:**\n' +
'1. Remove eslint-disable from this hook and fix dependency issues\n' +
'2. Use this API directly in components (not in custom hooks)',
});
},
};
}

return {};
};

Expand Down