diff --git a/.changeset/browser-guard-cache.md b/.changeset/browser-guard-cache.md new file mode 100644 index 0000000000..c503442526 --- /dev/null +++ b/.changeset/browser-guard-cache.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Invalidate cached browser-render diagnostics when imported hydration guards change. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts index 15623b409f..f9515affef 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.test.ts @@ -325,6 +325,85 @@ describe("forwarded Hook dependency collectors", () => { }); }); +describe("browser render guard collector", () => { + it("records package, alias, re-export, and imported Hook content on repeat collections", () => { + writeFixtureFile( + "package.json", + `{ "dependencies": { "next": "^15.0.0", "react": "^19.0.0" } }\n`, + ); + writeFixtureFile( + "tsconfig.json", + `{ "compilerOptions": { "baseUrl": ".", "paths": { "@hooks": ["src/hooks/index"] } } }\n`, + ); + writeFixtureFile( + "src/use-hydrated.ts", + `import { useSyncExternalStore } from "react"; +const subscribe = () => () => {}; +export const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false); +`, + ); + writeFixtureFile( + "src/hooks/index.ts", + `export { useHydrated as useClientReady } from "../use-hydrated";\n`, + ); + writeFixtureFile("src/nested-unrelated.ts", "export const nestedUnrelated = true;\n"); + writeFixtureFile( + "src/unrelated.ts", + `import { nestedUnrelated } from "./nested-unrelated"; +export const unrelated = nestedUnrelated; +`, + ); + const appPath = writeFixtureFile( + "src/App.tsx", + `import { useClientReady as useHydrated } from "@hooks"; +import { unrelated } from "./unrelated"; +export const App = () => { + const hydrated = useHydrated(); + return hydrated && {document.title}{String(unrelated)}; +}; +`, + ); + const expectedContentPaths = [ + fixturePath("package.json"), + fixturePath("tsconfig.json"), + fixturePath("src/hooks/index.ts"), + fixturePath("src/use-hydrated.ts"), + ]; + + for (const trace of [ + collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]), + collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]), + ]) { + for (const expectedPath of expectedContentPaths) { + expect(trace?.contentPaths.has(expectedPath)).toBe(true); + } + expect(trace?.contentPaths.has(fixturePath("src/unrelated.ts"))).toBe(true); + expect(trace?.contentPaths.has(fixturePath("src/nested-unrelated.ts"))).toBe(false); + } + }); + + it("records unresolved candidates and terminates on cyclic re-exports", () => { + writeFixtureFile("src/cycle-a.ts", `export { useHydrated } from "./cycle-b";\n`); + writeFixtureFile("src/cycle-b.ts", `export { useHydrated } from "./cycle-a";\n`); + const appPath = writeFixtureFile( + "src/App.tsx", + `import { useHydrated } from "./cycle-a"; +import { useMissingHydration } from "./missing-hydration"; +export const App = () => { + const hydrated = useHydrated() || useMissingHydration(); + return hydrated && {window.innerWidth}; +}; +`, + ); + const trace = collectFor(appPath, ["no-unguarded-browser-global-in-render-or-hook-init"]); + + expect(trace).not.toBeNull(); + expect(trace?.contentPaths.has(fixturePath("src/cycle-a.ts"))).toBe(true); + expect(trace?.contentPaths.has(fixturePath("src/cycle-b.ts"))).toBe(true); + expect(trace?.existencePaths.has(fixturePath("src/missing-hydration.ts"))).toBe(true); + }); +}); + describe("nextjs collectors", () => { it("records ancestor layout probes for a page file only", () => { writeFixtureFile("app/layout.tsx", "export default ({ children }) => children;\n"); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts index bb88610023..e7cf551471 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts @@ -19,6 +19,7 @@ import { hasAncestorMetadataLayout } from "./utils/find-ancestor-metadata-layout import { hasAncestorSuspenseLayout } from "./utils/find-ancestor-suspense-layout.js"; import { isBarrelIndexModule } from "./utils/is-barrel-index-module.js"; import { isLegacyArchReactNativeFile } from "./utils/is-legacy-arch-react-native-file.js"; +import { isFunctionLike } from "./utils/is-function-like.js"; import { resolveInkVersion } from "./utils/resolve-ink-version.js"; import { isNodeOfType } from "./utils/is-node-of-type.js"; import { isReactApiCall } from "./utils/is-react-api-call.js"; @@ -30,6 +31,7 @@ import { resolveCrossFileFunctionExport, resolveCrossFileValueExportWithFilePath, } from "./utils/resolve-cross-file-function-export.js"; +import type { ResolvedCrossFileValueExport } from "./utils/resolve-cross-file-function-export.js"; import { resolveRelativeImportPath } from "./utils/resolve-relative-import-path.js"; import { stripParenExpression } from "./utils/strip-paren-expression.js"; import { walkAst } from "./utils/walk-ast.js"; @@ -220,10 +222,12 @@ const flattenProgramImportEntries = (program: EsTreeNode): ImportEntryName[] => return entries; }; -const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ - absoluteFilePath, - staticImports, -}) => { +const collectFunctionExportDependencies = ( + { absoluteFilePath, staticImports }: CrossFileDependencyCollectorInput, + maximumForwardDepth: number, + shouldTraverseResolvedExport: (resolved: ResolvedCrossFileValueExport) => boolean = () => true, + shouldTraverseFilePath: (filePath: string) => boolean = () => true, +): void => { const greatestTraversedDepthByFilePath = new Map(); const collectProgramDependencies = ( @@ -241,7 +245,14 @@ const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ entry.source, entry.exportedName, ); - if (!resolved || remainingDepth === 0) continue; + if ( + !resolved || + remainingDepth === 0 || + !shouldTraverseResolvedExport(resolved) || + !shouldTraverseFilePath(resolved.filePath) + ) { + continue; + } collectProgramDependencies(resolved.filePath, resolved.programNode, remainingDepth - 1); } }; @@ -252,15 +263,21 @@ const collectForwardedHookDependencies: CrossFileDependencyCollector = ({ entry.source, entry.exportedName, ); - if (!resolved) continue; - collectProgramDependencies( - resolved.filePath, - resolved.programNode, - CUSTOM_HOOK_DEPENDENCY_FORWARD_DEPTH, - ); + if ( + !resolved || + !shouldTraverseResolvedExport(resolved) || + !shouldTraverseFilePath(resolved.filePath) + ) { + continue; + } + collectProgramDependencies(resolved.filePath, resolved.programNode, maximumForwardDepth); } }; +const collectForwardedHookDependencies: CrossFileDependencyCollector = (input) => { + collectFunctionExportDependencies(input, CUSTOM_HOOK_DEPENDENCY_FORWARD_DEPTH); +}; + const collectCreateRefDependencies: CrossFileDependencyCollector = ({ absoluteFilePath, program, @@ -397,6 +414,18 @@ const collectNearestManifestDependencies: CrossFileDependencyCollector = ({ abso classifyPackagePlatform(absoluteFilePath); }; +// The browser-render guard follows imported functions and re-exports without +// a depth limit while looking for Hooks that establish hydration state. +const collectBrowserRenderGuardDependencies: CrossFileDependencyCollector = (input) => { + collectNearestManifestDependencies(input); + collectFunctionExportDependencies( + input, + Number.POSITIVE_INFINITY, + (resolved) => isFunctionLike(resolved.exportedNode), + (filePath) => !filePath.split(/[\\/]/).includes("node_modules"), + ); +}; + // rn-no-legacy-shadow-styles / rn-style-prefer-boxshadow gate on // `isLegacyArchReactNativeFile`, which reads the nearest manifest plus // `android/gradle.properties` and the Expo app-config files. The helper @@ -444,7 +473,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap