refactor(web): consolidate the three GET /api/config hooks into one#1770
Conversation
…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
|
@claude review |
|
Claude finished @cliffhall's task in 2m 36s —— View job Review: consolidate the three
|
| Aspect | Old (×3) | New | Match |
|---|---|---|---|
base normalization (replace(/\/$/, "")) |
✅ | ✅ | ✅ |
x-mcp-remote-auth: Bearer … only when token present |
✅ | ✅ | ✅ |
Three isCancelled() guards (post-fetch, post-json, finally) |
✅ | ✅ | ✅ |
!res.ok → leave defaults, still clear loading |
✅ | ✅ | ✅ |
version/sandboxUrl: usable non-empty string else undefined |
inline | usableString() helper |
✅ |
writable: only explicit false flips it |
✅ | ✅ | ✅ |
catch {} swallow → defaults |
✅ | ✅ | ✅ |
useCallback deps [base, authToken, doFetch] |
✅ | ✅ | ✅ |
No stale references. grep across *.ts/tsx/md/json for the three removed hook names returns only the two intentional historical mentions in the new hook's docblock (useInitialConfig.ts:22-24) and the test header. There's no core/react barrel to update, and no README/AGENTS section enumerates these hooks, so no doc changes are owed. clients/web/src/test/setup.ts:37 comment correctly updated.
"One fetch" claim holds. grep for api/config shows the hook is the only browser-side consumer; the nearby loadClientConfigRemote call in App.tsx:715 hits a different endpoint (/api/storage/client, core/client/remote.ts:29), so it isn't a fourth duplicate.
CI is green on the head commit (run 30134424637) — validate → coverage → smoke all passed, so the ≥90 per-file gate on the new core/react/useInitialConfig.ts is satisfied. (I could not run the suite in this checkout — clients/web/node_modules is absent on the action runner — so I'm relying on that run plus reading the branch matrix.)
Things I liked
ConfigPayloadstays a local minimal interface withunknownfields (useInitialConfig.ts:54-59) rather than importingInitialConfigPayloadfromcore/mcp/remote/node/server.ts. That's the right call twice over: it keeps a Node module out of the browser import graph, andunknownis what makes the legacy-backend tolerance type-honest instead of a lie the compiler believes.usableString()(:62-64) dedupes what was two copies of the same inline ternary, and both of its&&branches are exercised by the tests (non-string via the omitted-field case, empty-string via the""case).- Nice unadvertised win: all three values now land in a single state batch, so
writableandsandboxUrlcan no longer arrive in different renders — one less chance of CRUD controls or the Apps iframe flickering through an intermediate state. App.tsx:698-710— the consolidated comment preserves all three of the original explanatory comments (whywritableexists, why the browser can't read the version off disk). Good; those were the valuable part of the deleted code.
Minor nits (non-blocking)
1. clients/web/src/test/core/react/useInitialConfig.test.tsx:111-124 — this test doesn't test what its name says.
it("keeps writable true for any non-false value", async () => {
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ writable: undefined }));writable: undefined is indistinguishable from an absent key — and can never appear in real JSON — so this duplicates "applies each field's default when the payload omits it" (:88) and exercises no new branch. To actually cover "any non-false value", use values a nonconforming backend could really send: null, 0, "no". Each is falsy yet !== false, so each should stay writable — that's the assertion with teeth. Fix this →
2. useInitialConfig.test.tsx:207-209 — the post-unmount comment overstates what's proven.
// No assertion error / React warning means the guards held; every field
// stayed at its initial value.React 18 removed the setState-after-unmount warning, and result.current is frozen at the last render once unmount() runs — so both post-unmount tests (:186, :216) would pass even if the isCancelled() guards were deleted. They're valuable as branch coverage for the three guards, but they can't catch a regression in them. There's no clean way to observe this in React 18 (a render counter wouldn't help either — setState on an unmounted fiber is a silent no-op), so I'd keep the tests and just soften the comment to say they exist for guard-branch coverage rather than implying they detect a violation. This wording is inherited verbatim from the deleted useSandboxUrl.test.tsx, so it's not a regression from this PR.
3. useInitialConfig.ts:50-51 — loading is exposed but unconsumed. App.tsx:703-707 drops it, same as the three old hooks did. Keeping it is reasonable (it's the natural shape, and it's now more useful than before since one flag covers the whole payload), but it's worth noting that the "not yet loaded" and "backend omitted it" cases are still indistinguishable to consumers of sandboxUrl/version alone — e.g. the Apps screen shows its unavailable state during the in-flight window. Pre-existing, out of scope here; flagging only in case loading is meant to be wired up in follow-up work.
One thing I checked and am satisfied with
A single request is also a single point of failure — a non-ok or thrown /api/config now defaults all three fields at once, including leaving writable: true (full CRUD shown) after e.g. a 401. That's identical to the old behavior, though: all three hooks hit the same URL with the same header, so they'd have failed together anyway. Not a regression introduced here.
· branch v2/fix-1643-consolidate-config-hooks
- "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
|
Thanks for the thorough review! Addressed the two actionable nits in 884bb7d: Nit 1 — "keeps writable true" test was a dupe. Fixed. It now loops over Nit 2 — overstated post-unmount comment. Softened. It now says the test exists to exercise the Nit 3 — @claude review |
|
Claude finished @cliffhall's task in 1m 27s —— View job Re-review: nit fixes in
|
…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
|
Both addressed in 54d4e41: Leftover comment contradiction — fixed.
@claude review |
|
Claude finished @cliffhall's task in 2m 42s —— View job Re-review:
|
…ck (#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
|
Both addressed in the latest commit:
Class docblock consistency. Took the optional suggestion too — the class-level docblock now says a response is dropped after unmount or a re-fetch, so all three comments (docblock, inline guard, test) tell the same story. @claude review |
|
Claude finished @cliffhall's task in 2m 10s —— View job Re-review:
|
Closes #1643
Summary
The web app made three independent
GET /api/configfetches on load —useSandboxUrl,useServerListWritable, anduseInspectorVersioneach fetched the same staticInitialConfigPayloadand read one field from it. This consolidates them into a singleuseInitialConfighook that fetches once and exposes{ version, sandboxUrl, writable, loading }.Changes
core/react/useInitialConfig.ts— one fetch of/api/config, exposing all three fields with the same tolerant parsing/defaults the old hooks used (writabledefaultstrue;version/sandboxUrlundefinedwhen absent or non-usable-string; unmount-safe cancellation guards).clients/web/src/App.tsxto destructure all three fields from the single hook.useSandboxUrl/useServerListWritable/useInspectorVersionand their tests (nothing else consumed them).useInitialConfig.test.tsx.test/setup.ts.Acceptance
GET /api/configfetch on load (asserted viatoHaveBeenCalledTimes(1)).version/sandboxUrl/writablebehave exactly as before, including legacy-backend tolerance.npm run ci(validate → coverage → smoke → storybook) passes.🤖 Generated with Claude Code
https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5