From 9ced0fd15078bd2b797d5981b3ade141ac736783 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 29 Aug 2026 23:17:45 -0400 Subject: [PATCH] chore: lint core/react with eslint-plugin-react-hooks (#2192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eslint-plugin-react-hooks` was a `clients/web` devDependency only, and each client's `eslint .` is scoped to its own directory — so the whole hook rule set had never looked at `core/react/`, a directory of nothing but React hooks. "Neither surface has JSX" was the stated reason and was never a good one: the rules judge hooks, not JSX. Add the plugin at the root and apply it in a `core/react/**` block (scoped there rather than to all of `core/**`, since the rules key off the `use` prefix), with `exhaustive-deps` promoted to `error` as `clients/web` does. Resolve the 15 findings it then reports: - `useSettingsDraft` / `useClientSettingsDraft` seed their draft during render instead of in a `targetId`/`opened`-keyed effect, so the previous target's values never reach the DOM — the same rule `clients/web` spells `useValueChange`. Their "latest value" refs move from render-time writes into an effect; every reader (the debounce timer, `flush`, `onChange`) runs after a commit, so a post-commit write is what they need. - The three async loaders keep their effects behind justified inline disables: `set-state-in-effect` follows a call into an async function without modelling the `await`, verified against a minimal hook whose only setState is in a continuation. - `useInitialConfig`'s cleanup bump keeps reading `generation.current` at teardown — the rule's "copy it into a variable" advice would restore a stale request token and let a superseded response commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 6 +- README.md | 2 + core/react/useClientSettingsDraft.ts | 38 +- core/react/useEmaIdpLoginState.ts | 10 + core/react/useInitialConfig.ts | 14 + core/react/useServers.ts | 8 + core/react/useSettingsDraft.ts | 57 +-- eslint.config.js | 45 ++- package-lock.json | 554 +++++++++++++++++++++++++++ package.json | 1 + 11 files changed, 680 insertions(+), 57 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 74068df3..66436bcd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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`. +- **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. - 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). diff --git a/AGENTS.md b/AGENTS.md index 8682e53a..1adf7b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1045,7 +1045,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run local:gate` is the mandatory pre-push command** — it is a **strict superset of `.github/workflows/main.yml`** (which additionally runs `npm install`, done separately here): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `verify:bundle-externals` (the #2067 must-not-bundle gate) → `smoke` → **`smoke:web:firefox`** (the three browser-driven smokes again under Firefox — #2086; see below) → `local:storybook` (the Storybook play-function tests, installing Playwright chromium if needed). `smoke:web:firefox` is the step with no GitHub CI counterpart, deliberately (`smoke:tui` is the other, by self-skipping there). **It is named `local:gate` rather than `ci` on purpose, and there is no alias (#2146):** `npm ci` is a built-in that clean-installs from the lockfile and does *not* run this script, and a root script called `ci` invited the cross-engine passes into GitHub CI. `npm run ci` now fails with npm's missing-script error. The canonical CI-vs-local table lives in the [root README](./README.md#two-tiers-github-ci-and-the-local-gate); point at it rather than restating the split in a new place, since that restating is how the two tiers kept getting blurred. It also runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so the direction that matters holds: passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run local:gate` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run local:gate`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency that reaches a single `tsc` program from two installs resolves to two different versions across them), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. + - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). **`core/react/**` additionally gets `eslint-plugin-react-hooks` (#2192)** — a third root devDependency, and a third `files` block extending `reactHooks.configs.flat.recommended` with `exhaustive-deps` promoted to `error` the same way `clients/web` promotes it. "Neither surface has JSX" was the old reason for leaving it out and it was wrong: the hook rules judge hooks, not JSX, and `core/react/` is a directory of nothing but hooks — so the identical prop-into-effect re-sync that is an error in `clients/web/src` sat there unreported. Keep the block scoped to `core/react/**` rather than all of `core/**`: the rules key off the `use`-prefix naming convention, so a non-React `useFoo` helper elsewhere in `core/` would be judged as a hook it is not. The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. @@ -1121,7 +1121,7 @@ This exists because the gate's promise — that passing `npm run local:gate` loc Two consequences worth stating: - **Do not silence a finding to satisfy the gate.** A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment **with a one-line justification** — the same standard this document sets for `v8 ignore` and for `void` on a floating promise. Widening a `globalIgnores` or dropping a rule to make `lint` pass is not an acceptable fix. -- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in both React scopes (`clients/web`, `clients/tui`) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce. +- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in every React scope (`clients/web`, `clients/tui`, and the root's `core/react/**` block) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce. ### Typescript instructions @@ -1211,7 +1211,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` const MessageContainer = Paper.withProps({ variant: "message" }); ``` - State and effects - - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces. + - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the `eslint-plugin-react-hooks` recommended set enforces for the web client, the TUI, and — since #2192 — `core/react/` too. - Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop. - The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times. - An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`. diff --git a/README.md b/README.md index e6855ceb..6a1c9d58 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,8 @@ 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. + **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. For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md). diff --git a/core/react/useClientSettingsDraft.ts b/core/react/useClientSettingsDraft.ts index 648f6790..9f77ddf3 100644 --- a/core/react/useClientSettingsDraft.ts +++ b/core/react/useClientSettingsDraft.ts @@ -33,25 +33,33 @@ export function useClientSettingsDraft({ const timerRef = useRef | undefined>(undefined); const latestValuesRef = useRef(null); - const resolveInitialRef = useRef(resolveInitial); + // Seeded during render rather than in an effect, for the reason spelled + // out on {@link useSettingsDraft}: an effect would paint the previous + // session's draft for one frame before correcting itself. `seeded` is + // tracked separately from `draft` so a `resolveInitial` that legitimately + // returns `null` is not re-seeded on every render. + const [seeded, setSeeded] = useState(false); + if (seeded !== opened) { + setSeeded(opened); + setDraft(opened ? resolveInitial() : null); + } + const onPersistRef = useRef(onPersist); const onErrorRef = useRef(onError); const openedRef = useRef(opened); - resolveInitialRef.current = resolveInitial; - onPersistRef.current = onPersist; - onErrorRef.current = onError; - openedRef.current = opened; - + // Written in an effect rather than during render (`react-hooks/refs`). + // Every reader — the debounce timer, `flush`, and `onChange` — runs after + // a commit, so a post-commit write is what they need. `latestValuesRef` + // mirrors `draft` here so it is re-seeded (and cleared on close) without a + // second copy of the seeding rule; `onChange` still writes it + // synchronously, because a functional update in the same tick has to see + // the value the previous call produced. useEffect(() => { - if (!opened) { - setDraft(null); - latestValuesRef.current = null; - return; - } - const initial = resolveInitialRef.current(); - setDraft(initial); - latestValuesRef.current = initial; - }, [opened]); + onPersistRef.current = onPersist; + onErrorRef.current = onError; + openedRef.current = opened; + latestValuesRef.current = draft; + }); useEffect(() => { return () => { diff --git a/core/react/useEmaIdpLoginState.ts b/core/react/useEmaIdpLoginState.ts index 85932561..ade80f70 100644 --- a/core/react/useEmaIdpLoginState.ts +++ b/core/react/useEmaIdpLoginState.ts @@ -35,6 +35,16 @@ export function useEmaIdpLoginState( useEffect(() => { if (!active) return; + // Reading the persisted IdP session *is* the external system this effect + // exists to synchronize with, and the read is asynchronous: the + // `loginState` commit happens in a continuation after + // `getEmaIdpLoginState` resolves. `set-state-in-effect` follows the call + // into `refresh` without modelling the `await`, so it reports every + // "fetch on open" effect alike — verified against a minimal hook whose + // only setState is after an `await`. The one commit here that *is* + // 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(); }, [active, refresh]); diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index ab58ab9d..d3a5fde1 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -245,11 +245,25 @@ export function useInitialConfig( }, [base, authToken, doFetch]); useEffect(() => { + // `load` commits nothing before its first `await`: it claims a generation + // token, builds headers, and only then fetches, so every setState it makes + // runs in a continuation. `set-state-in-effect` follows the call into an + // async function without modelling the `await` boundary, so it reports + // this even though there is no synchronous commit to cascade from. + // eslint-disable-next-line react-hooks/set-state-in-effect -- no commit before the first await void load(); return () => { // Invalidate whatever is in flight: this fires on unmount and on a // `baseUrl`/`authToken` change, and in both cases a response for the // old inputs must not commit. + // + // `generation` is a monotonic request token, not a ref to a rendered + // node. `exhaustive-deps`' advice — copy `generation.current` into a + // variable inside the effect and use the copy here — is exactly wrong: + // the cleanup must bump whatever the counter holds *at teardown*, since + // that is what invalidates the load currently in flight. A value + // captured at effect setup would let a superseded response commit. + // eslint-disable-next-line react-hooks/exhaustive-deps -- must read the token at teardown generation.current++; }; }, [load]); diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 3b66a0c8..902fd393 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -231,6 +231,14 @@ export function useServers(opts: UseServersOptions): UseServersResult { ); useEffect(() => { + // Loading the server list from the backend is the external-system + // synchronization this effect is for, and every list/error commit happens + // after `loadServers()` resolves. `set-state-in-effect` follows the call + // without modelling the `await`, so it reports the whole shape. The + // synchronous part is the pair that *opens* a load — `setLoading(true)` + // and clearing a stale error — which settles in one extra render, and is + // the point of having a loading flag at all. + // eslint-disable-next-line react-hooks/set-state-in-effect -- async load of an external list void refresh(); }, [refresh]); diff --git a/core/react/useSettingsDraft.ts b/core/react/useSettingsDraft.ts index 186932da..bdaa1846 100644 --- a/core/react/useSettingsDraft.ts +++ b/core/react/useSettingsDraft.ts @@ -28,11 +28,12 @@ export interface UseSettingsDraftOptions { targetId: string | undefined; /** * Resolves the initial draft for a given server id at the moment the - * modal opens. Called once per target — must be stable enough that - * the caller doesn't accidentally provoke re-initialization while - * the user is editing. (We do not include this function in the - * effect deps; it's read through a ref so callers don't have to - * `useCallback` it.) + * modal opens. Called once per target, **during render** — so it must + * be pure: a render can be replayed under StrictMode or abandoned by + * concurrent React, which would run any side effect an unpredictable + * number of times. Its identity is never a dependency of anything, so + * callers do not have to `useCallback` it, and re-creating it on every + * render cannot re-seed an in-progress edit. */ resolveInitial: (id: string) => T; /** Persist the draft. Called from the debounced flush and from `flush`. */ @@ -73,31 +74,41 @@ export function useSettingsDraft({ const [draft, setDraft] = useState(null); const timerRef = useRef | undefined>(undefined); + // Seeding happens during render, not in an effect. An effect keyed on + // `targetId` renders once with the *previous* target's draft, paints + // it, and only then corrects itself — so opening the modal on a second + // server briefly shows the first one's values. React's documented + // "adjusting state when a prop changes" pattern discards the + // in-progress render instead, so nothing stale reaches the DOM. + // (`clients/web` spells the same thing `useValueChange`; `core/` cannot + // import from a client.) + // + // `seededFor` is compared rather than `draft`, because "already seeded + // for this target" and "the draft happens to be null" are different + // states: a caller whose `resolveInitial` legitimately yields `null` + // would otherwise be re-seeded on every render. + const [seededFor, setSeededFor] = useState(undefined); + if (seededFor !== targetId) { + setSeededFor(targetId); + setDraft(targetId ? resolveInitial(targetId) : null); + } + // Read callbacks (and the current draft) through refs so the consumer // doesn't have to `useCallback` them, and so the returned `flush` - // identity is stable across keystrokes. The effect that - // re-initializes the draft must depend on `targetId` only — anything - // else in its deps risks resetting an in-progress edit when the - // parent re-renders for unrelated reasons (e.g. a background - // server-list refresh). - const resolveInitialRef = useRef(resolveInitial); + // identity is stable across keystrokes. Written in an effect rather + // than during render (`react-hooks/refs`): a render can be replayed or + // abandoned, and every reader here — the debounce timer and `flush` — + // runs after a commit, so a post-commit write is what they need. const onPersistRef = useRef(onPersist); const onErrorRef = useRef(onError); const draftRef = useRef(null); const targetIdRef = useRef(targetId); - resolveInitialRef.current = resolveInitial; - onPersistRef.current = onPersist; - onErrorRef.current = onError; - draftRef.current = draft; - targetIdRef.current = targetId; - useEffect(() => { - if (!targetId) { - setDraft(null); - return; - } - setDraft(resolveInitialRef.current(targetId)); - }, [targetId]); + onPersistRef.current = onPersist; + onErrorRef.current = onError; + draftRef.current = draft; + targetIdRef.current = targetId; + }); // Unmount cleanup — a stale timer firing after unmount is harmless // today (the persist call still goes through to the backend) but diff --git a/eslint.config.js b/eslint.config.js index 3fd534b1..596fa772 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,6 @@ import js from "@eslint/js"; import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; @@ -10,22 +11,25 @@ import { defineConfig, globalIgnores } from "eslint/config"; // isomorphic TypeScript (browser-side OAuth + Node backends + shared runtime); // the shared surface is Node-only tooling/tests. // -// ⚠️ There is no React plugin here, and "neither surface has JSX" is NOT a -// sufficient reason for that — `core/react/` is a directory of React hooks, -// which the hook rules judge whether or not any JSX is present. So the whole -// `eslint-plugin-react-hooks` set, `set-state-in-effect` included, has never -// looked at them: it is a devDependency of `clients/web` only, and Node -// resolution walks up rather than down, so the root cannot even load it. -// That is why the prop-into-effect re-sync #1955 fixed sat here unreported -// while the identical shape is an error over in `clients/web/src`. Closing the -// gap means adding the plugin at the root and taking what it then reports — -// measured during #1955 as 15 findings, every one of them in a hook that issue -// did not convert (the two settings-draft hooks and the three async fetch -// hooks) and none in the 16 it did. Tracked on its own rather than folded in. +// `core/react/` gets the React hook rules, and "neither surface has JSX" was +// 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. // -// Type-aware linting for the two blocks below. `no-floating-promises` needs -// type information, which `tseslint.configs.recommended` (the non-type-aware -// set) does not provide — so each block adds a parser project (#1959). +// Scoped to `core/react/**` rather than all of `core/**`: everything else +// there is plain isomorphic TypeScript, and the rules key off the `use`-prefix +// naming convention, so a non-React `useFoo` helper elsewhere in `core/` would +// be judged as a hook it is not. +// +// Type-aware linting for the `core/` and shared surfaces below. +// `no-floating-promises` needs type information, which +// `tseslint.configs.recommended` (the non-type-aware set) does not provide — +// so a block of its own adds a parser project (#1959). // // `tsconfig.lint.json` exists for this and nothing else: the root has no // tsconfig of its own (`core/` is typechecked through `clients/web`'s @@ -81,6 +85,17 @@ export default defineConfig([ }, rules: sharedRules, }, + { + // #2192. `exhaustive-deps` is promoted from the recommended set's `warn` + // for the same reason `clients/web` promotes it (#2085): `--max-warnings 0` + // already fails the gate on it, and matching the severity keeps an editor + // squiggle honest rather than depending on a CLI flag. + files: ["core/react/**/*.{ts,tsx}"], + extends: [reactHooks.configs.flat.recommended], + rules: { + "react-hooks/exhaustive-deps": "error", + }, + }, { files: [ "test-servers/src/**/*.{ts,tsx,mts,cts}", diff --git a/package-lock.json b/package-lock.json index 7497c6f8..651de66e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@eslint/js": "^10.0.1", "@types/proper-lockfile": "^4.1.4", "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", @@ -63,6 +64,266 @@ "node": ">=18" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", @@ -300,6 +561,56 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/client": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", @@ -1417,6 +1728,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1467,6 +1791,40 @@ "node": "20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1520,6 +1878,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -1633,6 +2012,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1793,6 +2179,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.416", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz", + "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1861,6 +2254,16 @@ "tests/types" ] }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1935,6 +2338,26 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -2326,6 +2749,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -2443,6 +2876,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hono": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", @@ -2715,6 +3165,26 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2742,6 +3212,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3043,6 +3526,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3163,6 +3656,16 @@ "node": ">= 0.6" } }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4186,6 +4689,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4404,6 +4938,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -4456,6 +4997,19 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index e1c21d42..fdc8d878 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@eslint/js": "^10.0.1", "@types/proper-lockfile": "^4.1.4", "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4",