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