Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/catch-native-storage-user-read-rejections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"react-doctor": patch
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
---

Detect missing rejection handlers for direct OIDC user reads backed by native browser storage, including one immutable local facade. Cache provider proofs across repeated reads while preserving their filesystem dependencies.
1 change: 1 addition & 0 deletions packages/core/tests/cross-file-rule-ids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe("CROSS_FILE_RULE_IDS", () => {
"no-locale-format-in-render",
"no-match-media-in-state-initializer",
"no-mutating-reducer-state",
"no-promise-then-side-effect-in-effect-without-catch",
"no-reset-all-state-on-prop-change",
"no-side-effect-in-state-updater-function",
"no-unguarded-browser-global-at-module-scope",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// rule: no-promise-then-side-effect-in-effect-without-catch
// weakness: cross-file
// source: SDK promise role regression
// verdict: fail
import { useEffect, useState } from "react";
import { UserManager, WebStorageStateStore } from "oidc-client";
const settings = { userStore: new WebStorageStateStore({ store: window.localStorage }) };
const manager = new UserManager(settings);
export const AccountPanel = () => {
const [user, setUser] = useState(null);
useEffect(() => {
manager.getUser().then(setUser);
}, []);
return <output>{user?.name}</output>;
};
1 change: 1 addition & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export const LIBRARY_SNIPPET_POOL = [

// Module scope — SSR hazards, guard aliases, contexts, caches, styled.
export const MODULE_SCOPE_SNIPPET_POOL = [
`import {useEffect as useAccountEffect,useState as useAccountState} from "react"; import {UserManager,WebStorageStateStore} from "oidc-client"; const accountSettings={userStore:new WebStorageStateStore({store:window.localStorage})}; const accountManager=new UserManager(accountSettings); export const FuzzAccountUser=()=>{const [user,setUser]=useAccountState(null);useAccountEffect(()=>{accountManager.getUser().then(setUser)},[]);return <output>{user?.name}</output>};`,
`import { create as createFuzzZustandStore } from "zustand"; export const useFuzzZustandStore = createFuzzZustandStore(() => ({ items: [] })); export const fuzzZustandSelection = useFuzzZustandStore((state) => ({ items: state.items }));`,
`import { create as createFuzzZustandTarget } from "zustand"; const useFuzzZustandTarget = createFuzzZustandTarget(() => ({ value: 0, other: 0 })); export const FuzzZustandWholeStoreTarget = () => { const { value: fuzzZustandValue } = useFuzzZustandTarget(); return <output>{fuzzZustandValue}</output>; };`,
`import { create as createFuzzZustandGetStore } from "zustand"; const useFuzzZustandGetStore = createFuzzZustandGetStore((_set, get) => ({ count: get().count }));`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const CROSS_FILE_RULE_IDS: ReadonlySet<string> = new Set([
"remotion-no-next-image",
"rerender-memo-with-default-value",
"rules-of-hooks",
"no-promise-then-side-effect-in-effect-without-catch",
"server-sequential-independent-await",
"rn-no-deprecated-modules",
"rn-no-legacy-shadow-styles",
Expand Down
48 changes: 48 additions & 0 deletions packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,51 @@ export const REACT_PROVIDER_MUTATION_CAPABILITY_NAMES = new Set([
"__proto__",
"prototype",
]);

export const OIDC_CLIENT_MODULE_SOURCE = "oidc-client";
export const OIDC_GET_USER_ARGUMENT_COUNT = 0;
export const OIDC_CONSTRUCTOR_ARGUMENT_COUNT = 1;
export const OIDC_WINDOW_DECODER_ARGUMENT_COUNT = 1;
export const OIDC_CONSTRUCTOR_ARGUMENT_INDEX = 0;
export const OIDC_STORE_OPTION_PROPERTY_COUNT = 1;
export const OIDC_INSTANCE_METHOD_NAMES = new Set(["getUser", "signinRedirect", "signoutRedirect"]);
export const OIDC_UNSUPPORTED_CAPABILITY_NAMES = new Set([
"eval",
"Function",
"require",
"module",
"exports",
"globalThis",
"global",
"self",
"Object",
"Reflect",
"Proxy",
"document",
"parent",
"top",
"frames",
"localStorage",
"sessionStorage",
]);
export const OIDC_UNSUPPORTED_MEMBER_NAMES = new Set([
"constructor",
"prototype",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"defineProperty",
"defineProperties",
"setPrototypeOf",
]);
export const OIDC_UNSUPPORTED_RUNTIME_TYPES = new Set([
"WithStatement",
"ImportExpression",
"TSEnumDeclaration",
"TSModuleDeclaration",
"TSParameterProperty",
"TSImportEqualsDeclaration",
"TSExportAssignment",
]);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isRejectableOidcUserManagerCall } from "./utils/is-rejectable-oidc-user-manager-call.js";
import { resolveImportedApiReference } from "./utils/resolve-imported-api-reference.js";
import { readNearestPackageManifest } from "./utils/read-nearest-package-manifest.js";
import { resolvePackageVersion } from "./utils/resolve-package-version.js";
Expand Down Expand Up @@ -573,6 +574,20 @@ const collectInkNoRawTextDependencies: CrossFileDependencyCollector = (input) =>
});
};

const collectOidcUserManagerDependencies: CrossFileDependencyCollector = ({
absoluteFilePath,
getProgram,
}) => {
const program = getProgram();
attachParentReferences(program);
const scopes = analyzeScopes(program);
walkAst(program, (node) => {
if (isNodeOfType(node, "CallExpression")) {
isRejectableOidcUserManagerCall(node, { filename: absoluteFilePath, scopes });
}
});
};

export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap<string, CrossFileDependencyCollector> =
new Map([
...INK_RULE_IDS.map((ruleId): [string, CrossFileDependencyCollector] => [
Expand All @@ -585,6 +600,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap<string, CrossFileDepe
["effect-listener-cleanup-reference-mismatch", collectEffectValueHelperDependencies],
["exhaustive-deps", collectForwardedHookDependencies],
["rules-of-hooks", collectImportedHookDependencies],
["no-promise-then-side-effect-in-effect-without-catch", collectOidcUserManagerDependencies],
["no-barrel-import", collectNoBarrelImportDependencies],
["nextjs-async-dynamic-api-not-awaited", collectNearestManifestDependencies],
["nextjs-missing-metadata", collectNextjsMissingMetadataDependencies],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as fs from "node:fs";
import os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vite-plus/test";
import { REACT_DOCTOR_PLUGIN_RESET_HOOK_KEY } from "./constants/host.js";
import plugin from "./react-doctor-plugin.js";
import { parseSourceFile } from "./utils/parse-source-file.js";
import { resetFilesystemCaches } from "./utils/reset-filesystem-caches.js";

describe("react-doctor plugin filesystem-cache reset hook", () => {
Expand All @@ -14,4 +18,31 @@ describe("react-doctor plugin filesystem-cache reset hook", () => {
Symbol.for("react-doctor.reset-filesystem-caches"),
);
});

it("refreshes same-size cross-file source after the filesystem epoch reset", () => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parse-epoch-"));
const providerPath = path.join(temporaryDirectory, "provider.ts");
const preservedTimestamp = new Date("2025-01-01T00:00:00.000Z");
try {
fs.writeFileSync(providerPath, '"string";');
fs.utimesSync(providerPath, preservedTimestamp, preservedTimestamp);
expect(parseSourceFile(providerPath)).toMatchObject({
type: "Program",
body: [{ type: "ExpressionStatement", expression: { type: "Literal", value: "string" } }],
});

fs.writeFileSync(providerPath, '"number";');
fs.utimesSync(providerPath, preservedTimestamp, preservedTimestamp);
expect(fs.statSync(providerPath).mtimeMs).toBe(preservedTimestamp.getTime());
expect(fs.statSync(providerPath).size).toBe(Buffer.byteLength('"string";'));
resetFilesystemCaches();
expect(parseSourceFile(providerPath)).toMatchObject({
type: "Program",
body: [{ type: "ExpressionStatement", expression: { type: "Literal", value: "number" } }],
});
} finally {
resetFilesystemCaches();
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
});
Loading
Loading