Commit graph inspector/core/react/useInitialConfig.ts
Author SHA1 Message Date
cliffhall
9ced0fd150 chore: lint core/react with eslint-plugin-react-hooks (#2192)
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 23:17:45 -04:00
cliffhall
de84b67046 fix(auth): stop blaming the passphrase for a corrupt payload, and refresh the stale docs
Round 23 produced **no inline comments** — nothing wrong in the code it had
not already seen. The five suppressed ones are all in unchanged code: one real
misdiagnosis and four pieces of drift.

**A decrypted-but-corrupt payload was reported as a wrong passphrase.** By the
time `JSON.parse` runs, GCM has verified the tag — so the passphrase is
*proven correct*, and a `SecretFileKeyMismatchError` there tells the user to
restore a secret they never lost. `asSecretMap` was already outside that catch
for exactly this reason; `JSON.parse` was not, so the same class of mistake
survived one line above the fix for it. The decrypt now has its own narrow
catch and everything after it reports a corrupt file.

The test fixture could not express this case — it `JSON.stringify`s its
payload, so it can only produce valid JSON. Split into a raw-plaintext variant
so a fixture can carry bytes that are authentic under the passphrase and still
unparseable, which is the only shape that reaches the branch.

The rest is documentation that had drifted from the code it describes:

- `handOff`'s doc still described the re-read-and-compare protocol that the
  atomic claim replaced two rounds ago — an obsolete concurrency invariant is
  worse than none, because the next reader takes it as the contract.
- The spec still described that same hand-off, and still called Client and
  Server Settings "the only two dialogs that accept a secret". Both now match
  the code, and the dialog count is called out as load-bearing: the one that
  was missing from it was the one taking secrets with no disclosure at all.
- The `void load()` in `refresh` carries the justification the repo's
  no-floating-promises rule requires — `load` owns its failures, and `refresh`
  is deliberately synchronous because `refreshingPersist` calls it from a
  `finally`.

Full `npm run ci` green, staged tree verified first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 23:08:52 -04:00
cliffhall
52087f7739 fix(auth): address Copilot review round 16
Three findings. The first is the third consecutive round in which my previous
fix introduced the next defect, all in the same ~15 lines of `useInitialConfig`.

**The request token did not subsume the unmount guard, and I said it did.**
Bumping the generation on effect teardown invalidates loads already *in
flight*; a `refresh()` called after unmount claims the next token, is
therefore current, and commits into a dead hook. That is a live path — the
callers fire `refresh()` from an async settings persist that can outlive
`App`. Mounted state is tracked separately again and `stale()` checks both.

The test for this was also wrong, in the way that matters: it called
`refresh()` after unmount but never settled the fetch, so it could not observe
the commit it existed to prevent. It settles the promise now.

**A tmpfs mount was reported as durable.** `docker run --tmpfs
/home/node/.mcp-inspector` puts a real entry in mountinfo, so a mount-table
lookup answered "durable", selected the file store, and published
`durable: true` for a file that disappears when the container stops — the
exact false promise the in-memory fallback exists to avoid, reached by
trusting the mount table over the filesystem type. `tmpfs`/`ramfs` are
excluded now, read from the field after the ` - ` separator rather than by
position, since the optional fields before it are variable in number.

**A malformed envelope was diagnosed as a changed passphrase.** A truncated
payload, a wrong-length IV or tag, or an out-of-range KDF parameter all threw
`SecretFileKeyMismatchError`, whose 503 tells the user to restore a passphrase
that cannot repair a structurally broken file. The read path now runs the same
`encryptedEnvelopeProblem` check the descriptor path already ran, and reserves
the key-mismatch error for an envelope that is well-formed and fails
authentication — which is the case where that advice is right. Two existing
tests asserted the old behavior and encoded the bug; both updated, and a new
one pins that a well-formed envelope with the wrong key still gets the
passphrase advice.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:31:03 -04:00
cliffhall
0ab542cbee fix(auth): order /api/config loads so an older response cannot win
Round 15, one finding — in the refresh path added one commit ago.

`refresh()` fires after each secret-bearing save, and nothing ordered the
responses. Two debounced settings saves landing close together issue two
refreshes; if the earlier request resolves last, it commits the descriptor
from *before* the write and the footer reverts to "Plaintext file" for a file
that is now encrypted. Reverting a security statement to a stale value is the
worst direction for this particular field to be wrong in, and it needed no
adverse network conditions — just two saves in quick succession.

Loads now claim a monotonic token and may commit only while they still hold
the current one. That subsumes the liveness ref the previous commit added:
bumping the token on effect teardown covers unmount *and* a `baseUrl` /
`authToken` change, and bumping it on every load covers the overlap.

The test drives it by holding the `fetch` promises and resolving them out of
order — newer first, then older — which fails against the previous
implementation and passes here.

The remaining suppressed comment is the hand-off's check-to-delete race,
which is the cross-process guarantee tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:14:23 -04:00
cliffhall
0c52172379 fix(auth): address Copilot review round 14
One inline and three of the four suppressed. Two of the four are cases where
an earlier fix of mine was applied to one branch and not its twin.

**The footer went stale after the write that changed it.** `useInitialConfig`
fetched once, and `secretStorage` is the one field it carries that describes
state *this app mutates*: the first save under a newly-set passphrase
re-encrypts a pre-existing plaintext file. The backend re-derives the
descriptor per request — deliberately, and there is a test for that — but the
client had no way to ask again, so the footer said "Plaintext file" for the
rest of the session about a file that no longer was. Exactly the misreport
this band exists to prevent, arrived at from the client side.

The hook now exposes `refresh()`, and both persist paths that can carry a
secret call it: server settings (OAuth client secret, stdio `env:` values)
and client settings (the enterprise IdP client secret). The refresh tracks
its own liveness through a ref, since unlike the mount fetch it has no effect
teardown to cancel against.

**The plaintext branch skipped the payload validation the encrypted branch
does.** Round 12 tightened the encrypted envelope check and left this one
behind, so `{ "encryption": "none", "secrets": [] }` — trivial to hand-write —
described a healthy plaintext store whose next save `readMap` refuses. Both
branches validate now.

**`secretStoreGetStrict`'s doc contradicted the code it was written for.** It
said falling back to `get` is right "for the in-memory and file stores —
neither can fail in a way that masquerades as absence". `FileSecretStore.get`
does exactly that masquerade, which is why round 9 gave that store its own
`getStrict`. The fallback stays (the seam is optional and doubles must
compile) but the comment no longer claims it is equivalent for a store that
can fail.

**The spec documented the performance bug rather than the fix.** It still said
`getMany` decrypts once per server, which was the shape round 13 replaced —
and describing the earlier bug as the design is worse than saying nothing.

The remaining suppressed comment is the hand-off's TOCTOU delete: the
cross-process guarantee, decided against for this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:56:41 -04:00
cliffhall
fdeffa1f00 fix(auth): address Copilot review round 6 on the secret-store PR
Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:40:29 -04:00
cliffhall
decc673a5d fix(auth): address Copilot review round 5, and land the impl its tests need
Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.

Round 5's four findings (two inline, two suppressed):

- **The stale-lock takeover was still racy.** Round 4 made *stealing*
  liveness-aware but left the takeover as remove-then-recreate, which is
  not an election: two waiters can both judge the same lock stale, the
  first removes it and takes a fresh one, and the second then removes
  *that* — both inside the section, which is the lost update the takeover
  exists to prevent. Claiming is now an atomic `rename` to a
  token-unique name, so exactly one waiter can take a given stale lock and
  the losers get ENOENT. A failed claim also falls through to the deadline
  instead of `continue`ing, so a takeover that can never succeed times out
  rather than spinning forever on a swallowed error.

- **Every `stat` failure was read as "no file yet".** An existing file we
  cannot inspect (EACCES on the directory, a mode-less filesystem) then
  produced no warning at all, and the footer went on stating owner-only
  permissions having verified nothing. There is now a third state,
  `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
  caveat that says the permissions could not be checked, a `warn` tone, and
  its own branch in the footer sentence — which otherwise would have
  claimed "Owner-only permissions." by omission, the same bug one layer up.

- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
  backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
  of them was compared in its escaped form, matched nothing, and read as
  the container's writable layer — `memory` selected and persistence
  silently lost on a box that had arranged for it. Decoded as octal in
  general now, since the rule is octal-in-general and a future addition
  would otherwise reintroduce exactly this.

- **File-backed secrets vanished once a keychain appeared.** Save secrets
  without libsecret, install libsecret, and the next run selects the
  keychain and stops seeing them: still on disk, read by nothing, and
  nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
  the run that finds a keychain, under the rules the two existing plaintext
  migrations already use — keychain wins on conflict, the file is removed
  only on complete success, and a file that cannot be read is never
  deleted, because that is precisely the case where the values would be
  lost.

Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:15:35 -04:00
cliffhall
b143dd6cbd fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious.

**The memory fallback made the migrations destructive.** Both migrations —
mcp.json in the remote server, client.json in `node-persistence` — lift a
plaintext secret off disk, write it to the secret store, and then delete it
from the file. That trade is safe only while the store is at least as
durable as the disk, which held by construction while `InMemorySecretStore`
was a test double. Making it a production fallback broke it: on an
unmounted container a plain `GET /api/servers` would have moved a user's
existing secrets into RAM and lost them at exit — destroying data on a
read.

`SecretStore` gains an optional `isDurable()`, absent meaning durable, and
both migrations withhold the disk delete when it answers false (the value
is still loaded, so the session behaves normally). The production fallback
is now a named `SessionSecretStore` rather than the bare
`InMemorySecretStore`, so the suite's many doubles keep standing in for a
*working keychain* — which is what they are there to be — and only code
that deliberately chooses RAM in production says so.

**Round 2's per-call descriptor never reached anyone.** Both web servers
resolved it once at startup and baked it into `initialConfig`, which
`/api/config` serves unchanged — so the freshness fix was inert for its
only consumer. The route now takes a `secretStorageResolver` and calls it
per request.

The lock needed three more fixes, and they compound:

- **Release removed whatever directory was at the path.** A holder whose
  work outran the stale threshold would delete the *next* owner's lock,
  letting a third writer into a section two processes believed they held.
  Ownership is now stamped with a random token and release only removes a
  lock still carrying it.
- **The timeout fell through to an unlocked write** — under contention,
  the one moment another writer is provably active. It now throws
  `SecretFileLockTimeoutError`, which `set` is allowed to do.
- **The first write of every process bypassed the lock**, because the
  secrets directory does not exist yet and `mkdir` failed ENOENT. The
  parent is created first. First writes are exactly when two processes are
  most likely to start together.

Fixing the timeout exposed a fourth: with stale (10s) below timeout, a
waiter stole from a holder that was merely *slow* rather than dead —
reinstating the lost update. A holder now heartbeats its lock, so
"stale" means the holder is gone, and the timeout is what a live-but-slow
holder produces. Lock timings are injectable so the tests can exercise
contention, staleness, and timeout without spending the real intervals.

Also: the loose-permission state is carried into `SecretStorageInfo` as
`looseMode` and rendered as the caveat (it outranks the encryption caveat —
a file others can read is a live exposure, while "unencrypted" is a
property of a file only its owner can open), rather than living in a
startup log the browser cannot see; and `usableSecretStorage` validates
`pendingEncryption` and `looseMode` as the optional booleans/numbers they
are (`pendingEncryption: "false"` is truthy and would have printed advice
that is the opposite of the truth) and rejects file-only fields on a
keychain or memory descriptor.

Tests: both migrations against a session store with a durable control
alongside, so the guard is visibly the difference; `/api/config` serving a
changing resolver and falling back without one; lock ownership after a
steal, the timeout under a live holder, no-steal from a slow holder, and a
locked first write; `looseMode` through the descriptor, the caveat
ordering, and the tone; and five more rejected payload shapes. Full
`npm run ci` green — 5,704 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:19:56 -04:00
cliffhall
3452a63101 fix(auth): address Copilot review on the secret-store PR
Six findings, all taken.

- The descriptor reported the *write policy*, not the file. Adding
  `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
  file flipped `store.encrypted` immediately, so the banner and footer
  claimed "File (encrypted)" while the existing secrets sat readable
  until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
  reads the envelope (never the payload, so it needs no passphrase) and
  the descriptor is built from that. The transitional state gets its own
  `pendingEncryption` flag, which changes the advice without softening
  the verdict — "set a passphrase" is the wrong thing to tell someone who
  already has, so the caveat names the pending write instead.

- The secrets file ignored the configured storage directory. A container
  mounting only that directory was judged unmounted, fell back to
  `memory`, and lost secrets the user had arranged to keep.
  `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
  that already relocates OAuth tokens and `client.json` — between the
  explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
  default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
  such variable exists.)

- `version` was never checked before the plaintext branch, so a
  `{ version: 2, encryption: "none" }` file was read and silently
  rewritten as version 1, discarding whatever the newer format added —
  the same destroy-to-satisfy-an-additive-request case the encrypted
  branch already refuses. Checked first now, so the answer no longer
  depends on which encryption mode the newer writer chose.

- The doc comment claimed a per-file salt made "a short, memorable value
  safe to use". It does not: the salt defeats precomputation, not
  guessing, and the scrypt cost is deliberately a few milliseconds
  because it runs on every read and write. Both the comment and the
  README now say to use a high-entropy value, generated rather than
  chosen.

- `usableSecretStorage` validated only `kind`, so a partial
  `{ kind: "file" }` was cast through and — because a missing `plaintext`
  is falsy — rendered as the quiet, neutral *encrypted* file. That is the
  confident half-answer the guard exists to reject, produced by the
  absence of information rather than by any claim the backend made.
  `reason`, `durable`, and each kind's own required fields are checked.

- The footer's inline `Tooltip` carried four static props; extracted to a
  `CaveatTooltip` constant per the `.withProps()` rule.

Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:26:33 -04:00
cliffhall
bc408f5869 feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950

Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.

Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.

- `FileSecretStore` — one `0600` JSON document at
  `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
  `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
  whole map is encrypted as a unit rather than value-by-value so the
  account names — `${serverId}:${field}` — are hidden too; a per-value
  scheme would leave a readable index of which servers you hold a client
  secret for. A file that can no longer be decrypted reads as empty
  (matching the interface's read tolerance) but *refuses to be written*,
  because replacing a file of still-valid secrets to satisfy an additive
  request destroys data.

- `secret-store-selection.ts` — the policy.
  `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
  otherwise probe the keychain and, if unreachable, fall back loudly. The
  fallback is `memory` in a container whose secrets directory is not on a
  mount, and `file` otherwise — so mounting the volume the README already
  recommends for the catalog flips the same run to durable storage with
  no configuration, and an unmounted container gets the honest answer
  rather than a file `--rm` will discard. Cached per process so the
  banner, `/api/config`, and the store doing the writing cannot disagree.

- Automatic, but never silent. The downgrade prints at startup, rides
  `GET /api/config`, and is named in a permanent 32px footer at the
  bottom of both dialogs that accept a secret. A startup banner is seen
  once by whoever started the process; a toast is seen once; a
  dismissible banner is the thing a user dismisses before doing the work
  it describes. The plaintext and memory cases render their caveat inline
  in a warning tone — a warning you must hover to discover is not one.

- `SecretStoreUnavailableError` is now the base every store's `set`
  throws, so the routes translate one condition into a 503 instead of
  matching `KeychainUnavailableError` by name.

Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.

Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:06:31 -04:00
Cliff Hall
8aba3122c5
refactor(web): consolidate the three GET /api/config hooks into one (#1770)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* refactor(web): consolidate the three GET /api/config hooks into one (#1643)

useSandboxUrl, useServerListWritable, and useInspectorVersion each fetched
the same static /api/config payload separately and read one field from it —
three requests on load for one payload. Replace them with a single
useInitialConfig hook that fetches once and exposes { version, sandboxUrl,
writable, loading } with the same tolerant parsing/defaults (writable
defaults true; version/sandboxUrl undefined when absent; unmount-safe).

- Add core/react/useInitialConfig.ts; wire App.tsx to it.
- Remove the three former single-field hooks and their tests.
- Fold the fetch-branch matrix into one useInitialConfig test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(web): address review nits on useInitialConfig tests (#1643)

- "keeps writable true for any non-false value" now sends real falsy-but-
  not-false values a nonconforming backend could actually emit (null / 0 /
  "no") instead of `writable: undefined`, which was indistinguishable from an
  absent key and duplicated the omitted-field test.
- Soften the post-unmount comment to say the test exercises the isCancelled()
  guard branches for coverage rather than implying it can detect their removal
  (React 18 dropped the setState-after-unmount warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs(web): fix contradicting unmount-guard comment; it.each writable cases (#1643)

Second-review follow-ups:
- core/react/useInitialConfig.ts: correct the isCancelled() rationale — React
  18 no longer warns on setState-after-unmount; the guard exists to stop a
  stale payload (after unmount or a baseUrl/authToken re-run) overwriting
  current state. The old wording contradicted the corrected test comment.
- useInitialConfig.test.tsx: convert the writable-non-false loop to it.each so
  each value (null / 0 / "no") reports independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(web): use vitest %j specifier in it.each title; tidy hook docblock (#1643)

Third-review follow-ups (both title/comment-only):
- useInitialConfig.test.tsx: `%p` is a Jest specifier vitest doesn't support,
  so all three cases rendered the literal `%p`. Switch to `%j` — titles now
  render null / 0 / "no" distinctly (verified: `writable=null`, `writable=0`,
  `writable="no"`).
- useInitialConfig.ts: extend the class docblock to say a response is dropped
  after unmount OR a re-fetch, matching the corrected inline guard comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:12:19 -04:00