chore: address Copilot review round 1 (#2192)

- `useEmaIdpLoginState`: the effect-triggered `refresh()` was discarded with
  `void`, but it does not own its failures — `getEmaIdpLoginState` awaits
  `storage.getIdpSession`, which rejects when the backend is unreachable.
  Terminate with `.catch()` and leave `loginState` untouched, as `logout`
  already does. Covered by a new test that also asserts no unhandled
  rejection escapes.
- Correct the history: the plugin was *client-local*, declared by both
  `clients/web` and `clients/tui`. The root could reach neither, since Node
  resolution walks up rather than down — that, not web-exclusivity, is why
  `core/react/` went unlinted.
- Correct the enforcement claim: `clients/tui` registers the plugin but
  deliberately enables only `rules-of-hooks` and `exhaustive-deps`, so
  `set-state-in-effect` and `refs` hold for `clients/web` and `core/react/`
  only. Says so in AGENTS.md and in the Copilot mirror, which would
  otherwise have reviewers cite rules the TUI gate does not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 23:50:18 -04:00
commit 7e6d6c0bc1
6 changed files with 61 additions and 10 deletions

View file

@ -51,7 +51,7 @@ const CardContent = Group.withProps({
### State and effects
- **Never reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` paints the stale value first and renders twice; it is an error under `react-hooks/set-state-in-effect` — enforced in `clients/web`, `clients/tui`, and `core/react/**` (#2192). The same rule set forbids writing a ref during render (`react-hooks/refs`): sync a "latest value" ref in an effect instead.
- **Never reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` paints the stale value first and renders twice; it is an error under `react-hooks/set-state-in-effect` — enforced in `clients/web` and, since #2192, in `core/react/**`. The same rule set forbids writing a ref during render (`react-hooks/refs`): sync a "latest value" ref in an effect instead. `clients/tui` is the exception — it enables only `rules-of-hooks` and `exhaustive-deps`, so do not cite these two against TUI code.
- Use **`useValueChange(value, onChange)`** (`src/hooks/useValueChange.ts`) — React's documented "adjusting state during render" pattern. It does not fire on the first render; seed the state with `useState`. The comparison is `Object.is`, so pass a **referentially stable** value — a primitive key (id/name/URI) or a memoized one, never a fresh object literal.
- The `onChange` runs **during render**, so it must be pure — `setState` and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned.
- Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers).

File diff suppressed because one or more lines are too long

View file

@ -618,7 +618,7 @@ Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …),
**Linting is type-aware.** All five ESLint scopes (`clients/{web,cli,tui,launcher}` plus the root `core/` + shared gate) enable `@typescript-eslint/no-floating-promises` at `error`, so a promise that is neither awaited, returned, `.catch(…)`-terminated, nor explicitly discarded with `void` fails `lint` — and therefore `validate` ([#1959](https://github.com/modelcontextprotocol/inspector/issues/1959)). The rule needs type information, so each scope's config names a parser project; the root scope's is **`tsconfig.lint.json`**, a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its `include`. See **TypeScript instructions** in [`AGENTS.md`](./AGENTS.md) for when `void` is acceptable.
**The React hook rules cover `core/react/` too.** `eslint-plugin-react-hooks` used to be a `clients/web` devDependency only, and each client's `eslint .` is scoped to its own directory — so the whole hook rule set, `set-state-in-effect` included, had never looked at `core/react/`, a directory of nothing but React hooks ([#2192](https://github.com/modelcontextprotocol/inspector/issues/2192)). "Neither surface has JSX" was the stated reason and it was never a good one: the rules judge hooks, not JSX. The plugin is now a root devDependency, applied by a `core/react/**` block in the root `eslint.config.js` — scoped there rather than to all of `core/**`, since the rules key off the `use`-prefix convention and a non-React `useFoo` helper elsewhere would be judged as a hook it is not.
**The React hook rules cover `core/react/` too.** Every copy of `eslint-plugin-react-hooks` used to be client-local — a devDependency of `clients/web` and of `clients/tui`, each reachable only from that client's own `eslint .` — so the whole hook rule set, `set-state-in-effect` included, had never looked at `core/react/`, a directory of nothing but React hooks ([#2192](https://github.com/modelcontextprotocol/inspector/issues/2192)). "Neither surface has JSX" was the stated reason and it was never a good one: the rules judge hooks, not JSX. The plugin is now a root devDependency as well, applied by a `core/react/**` block in the root `eslint.config.js` — scoped there rather than to all of `core/**`, since the rules key off the `use`-prefix convention and a non-React `useFoo` helper elsewhere would be judged as a hook it is not.
**And lint has no warning tier.** Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does ([#2085](https://github.com/modelcontextprotocol/inspector/issues/2085)) — a `warn`-level `react-hooks/exhaustive-deps` finding had otherwise let a stale-closure bug pass the mandatory pre-push gate and reach review. Fix the finding rather than silencing it; if a rule genuinely must be waived, use its inline disable comment with a one-line justification.

View file

@ -189,4 +189,47 @@ describe("useEmaIdpLoginState", () => {
).not.toHaveBeenCalled();
expect(result.current.loginState).toBe("none");
});
it("swallows a session-read rejection and leaves loginState unchanged", async () => {
const exp = Math.floor(Date.now() / 1000) + 3600;
const payload = btoa(JSON.stringify({ exp }))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
vi.mocked(storage.getIdpSession).mockResolvedValue({
idToken: `h.${payload}.s`,
});
const { result, rerender } = renderHook(
({ issuer }: { issuer: string }) =>
useEmaIdpLoginState(storage, issuer, true),
{ initialProps: { issuer: "https://idp.test" } },
);
await waitFor(() => {
expect(result.current.loginState).toBe("logged_in");
});
// The storage backend goes away. The effect-triggered refresh must not
// surface an unhandled rejection, and must leave the last state we
// actually read in place rather than falsely reporting signed-out.
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
try {
vi.mocked(storage.getIdpSession).mockRejectedValue(
new Error("storage unreachable"),
);
await act(async () => {
rerender({ issuer: "https://other-idp.test" });
await new Promise((resolve) => setTimeout(resolve, 0));
});
} finally {
process.off("unhandledRejection", unhandled);
}
expect(storage.getIdpSession).toHaveBeenCalledWith(
"https://other-idp.test",
);
expect(unhandled).not.toHaveBeenCalled();
expect(result.current.loginState).toBe("logged_in");
});
});

View file

@ -45,7 +45,14 @@ export function useEmaIdpLoginState(
// synchronous is the no-issuer reset to "none", which settles in a single
// extra render rather than cascading.
// eslint-disable-next-line react-hooks/set-state-in-effect -- async read of an external store
void refresh();
refresh().catch(() => {
// `refresh` does not own its failures: `getEmaIdpLoginState` awaits
// `storage.getIdpSession`, which rejects when the backend is
// unreachable. Swallow it here so it does not surface as an unhandled
// rejection, and leave `loginState` untouched — the same choice `logout`
// makes, so the UI keeps reflecting the last state we actually read
// rather than falsely reporting signed-out.
});
}, [active, refresh]);
const logout = useCallback(() => {

View file

@ -15,11 +15,12 @@ import { defineConfig, globalIgnores } from "eslint/config";
// never a reason to withhold them — the hook rules judge hooks, not JSX, and
// that directory is nothing but hooks. Until #2192 the whole
// `eslint-plugin-react-hooks` set, `set-state-in-effect` included, had never
// looked at them: the plugin was a devDependency of `clients/web` only, and
// Node resolution walks up rather than down, so the root could not load it.
// That is how the prop-into-effect re-sync #1955 fixed sat here unreported
// while the identical shape was an error over in `clients/web/src`. The plugin
// is now a root devDependency and the block below applies it.
// looked at them: every copy of the plugin was *client-local* (a devDependency
// of `clients/web` and of `clients/tui`), and Node resolution walks up rather
// than down, so the root could reach neither. That is how the prop-into-effect
// re-sync #1955 fixed sat here unreported while the identical shape was an
// error over in `clients/web/src`. The plugin is now a root devDependency too,
// and the block below applies it.
//
// Scoped to `core/react/**` rather than all of `core/**`: everything else
// there is plain isomorphic TypeScript, and the rules key off the `use`-prefix