diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts index 94be2100f57..bd53f41b8ff 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -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' + + '}', ].join(''), }).withDetails({ kind: 'error', diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.expect.md new file mode 100644 index 00000000000..f572287e059 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.expect.md @@ -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, +``` + + + diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.js new file mode 100644 index 00000000000..b4c85f2d608 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.incompatible-with-eslint-disable.js @@ -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}; +} diff --git a/packages/eslint-plugin-react-hooks/src/shared/ReactCompiler.ts b/packages/eslint-plugin-react-hooks/src/shared/ReactCompiler.ts index 854e26149f7..b63d69f8ae4 100644 --- a/packages/eslint-plugin-react-hooks/src/shared/ReactCompiler.ts +++ b/packages/eslint-plugin-react-hooks/src/shared/ReactCompiler.ts @@ -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); @@ -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