Skip to content

Load the web console's server state through TanStack Query - #23

Merged
fylorn merged 4 commits into
mainfrom
refactor/web-query-layer
Sep 14, 2026
Merged

fylorn merged 4 commits into
mainfrom
refactor/web-query-layer

Conversation

@fylorn

@fylorn fylorn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What

The web console's data loading moves from hand-rolled loaders onto TanStack Query v5. Every react-hooks/set-state-in-effect suppression is gone — 29 of them — and so are the 13 loader useCallback wrappers #22 added so those effects could list honest dependencies.

27 of the suppressions sat on loads. The other two had the same shape without a fetch — a setup-form validation effect and the Models page's ?import= deeplink — and are fixed the same way: derived during render instead of synced in an effect.

Verified with the CI steps — pnpm install --frozen-lockfile, check:i18n (1386 keys), lint (0), 103 tests (96 + 7 new), pnpm build — and make precommit. The Playwright E2E suite needs a running backend and is not part of CI; it was not run for this change.

The layer

  • src/lib/query-client.ts builds the client. Two defaults differ from the library's and say why: no retries (nearly every failure here is a 4xx a retry cannot fix, and three silent attempts hold a spinner up for seconds), and no refetch on window focus (admin pages mount several queries, some of them multi-page catalog loops).
  • Keys follow the endpoint: GET /api/admin/teams/{id}/members → ['admin', 'teams', id, 'members'], query-string parameters in a trailing object.
  • Writes invalidate what the screen shows, and every successful write drops the cached queries no screen is using. The second half is one listener: api() announces successful non-GET requests (onSuccessfulWrite) and main.tsx has the cache drop its inactive queries. A call site rarely knows everything a write touched — adding a team member moves the team list's count, the user list's team column and the team page — so no call site has to.
  • @tanstack/eslint-plugin-query joins the lint config. Its exhaustive-deps — a key must name everything its queryFn reads — is the query-layer counterpart of the honest effect dependencies Clear 127 lint findings and gate lint in CI #22 went for.
  • Tests render through renderWithQueryClient, which gives each call an empty cache.
  • The README's "Data fetching" section now describes the layer. Both sub-cases it listed are empty, so they are gone; the rule against adding suppressions stays.

Behaviour worth a look in review

Screens load what they loaded before. These differences are deliberate, or fall out of keying responses:

  • Revisiting a screen shows its cached data while it refetches, unless a write has happened since (see above). That is the cache's point; it is also the one place a screen can briefly show data another admin has since changed.
  • Ending a session sweeps the cache. It outlives a session in the tab, so logout — and the cross-tab logout broadcast — drop every cached query along with the user. Otherwise the next person to sign in there would be shown the previous admin's pages until each refetched. Covered by a test.
  • Signing out from Profile now signs out the whole UI. useAuth state used to be per component, so "revoke sessions" and "delete account" cleared only the profile page's copy of the user; the shell kept rendering until some later request 401'd. The signed-in user is one shared query now.
  • A reload after a write no longer blanks the screen. Teams, Models, MCP tools, the OIDC card and others flashed their skeleton on every refetch. Only a first load, or a new page or filter, shows one now.
  • RouteEditorDialog's upstream-model picker rendered the wrong shape. GET /api/admin/providers/{id}/remote-models returns { id, available?, reason? } objects since model verdicts landed; the dialog still treated them as strings and handed objects to React as children. It maps them to ids now.
  • A load error clears on the next successful load. Several pages set it and never cleared it.
  • OutboxBacklogDialog loses its onChanged prop. A retry or delete invalidates ['admin', 'webhook-outbox'], which also covers the Log Forwarders page's backlog counts.
  • Polling (trace timeline, outbox backlog, route health, backlog counts) uses refetchInterval, which pauses while the tab is hidden and resumes on the next interval once it is visible again.

Where the cache changed the logic, not just the plumbing

  • A dialog can mount already open. Landing on Models from the Providers page's "Import models" shortcut finds the provider list cached, so BatchImportDialog opens on its first render. Its reset treats that as an open transition, which useResetOnChange (initialised to the first value) would not. Covered by a test.
  • The import dialog offers nothing until both lists are fresh from the pick — a cached copy of a provider's routes can predate an import made since, and would offer duplicates.
  • Forms seed from data once. The platform-pricing card seeds its draft from the first response it fetched itself (isFetchedAfterMount), never from a cached copy that could predate another admin's save, and a refetch never overwrites what is being typed.
  • Leaving admin_shared in a resumed MCP wizard discards the credential probe, including one still in flight, so its late answer cannot jump the wizard back to Step 3; switching back probes again.

Counts

#22's description counts 18 + 9 suppressions and 17 loader wrappers. The tree had 18 + 11 suppressions, and 13 of #22's 17 new useCallbacks wrap loaders — the other four (mergeOrder in stat-cards; goNext, goPrev and handleSubmit in RoleWizard) are not loads and stay.

Not in this PR

Loads that never tripped the rule — useTeams, the dashboard's live panels, the command palette, a few dialogs — still fetch in effects. They can move over one at a time; the README describes the pattern.

🤖 Generated with Claude Code

fylorn and others added 4 commits September 14, 2026 23:10
Pull in @tanstack/react-query 5.102 and mount a shared client in
main.tsx. The client's defaults live in src/lib/query-client.ts with
their reasons: no retries, since nearly every failure here is a 4xx a
retry cannot fix, and no refetch on window focus, since admin pages
mount several queries at once.

api() now announces successful writes (onSuccessfulWrite), and main.tsx
has the cache drop the queries no screen is using. One write can change
what several cached screens hold; this way none of them reopens onto
data from before it, without each call site knowing what it touched.

@tanstack/eslint-plugin-query joins the lint config: its exhaustive-deps
keeps every value a queryFn reads in its key. Tests get
renderWithQueryClient, which renders inside a fresh cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every load that carried a react-hooks/set-state-in-effect suppression
goes through useQuery now, and writes invalidate what their screen shows
instead of calling a loader again. The loading flags, the abort
controllers, the fetch-token race guard and the 13 loader useCallback
wrappers they needed are gone; query keys follow the endpoint.

Behaviour that changes on purpose:

- Logout, and the cross-tab logout broadcast, clear the cache along
  with the user. The signed-in user is one shared query, so signing out
  from Profile now signs out the whole UI.
- A reload after a write refreshes in place instead of flashing the
  skeleton, and a load error clears on the next successful load.
- RouteEditorDialog maps remote-models entries to ids: the endpoint
  returns objects, and the dialog handed them to React as children.
- OutboxBacklogDialog drops onChanged: invalidating the webhook-outbox
  prefix also refreshes the parent's backlog counts.
- Polling uses refetchInterval, which pauses while the tab is hidden.

Where cached data can already be there on the first render, local state
follows it through trackers that start empty: BatchImportDialog can
mount open, the Models page's ?import= deeplink opens it during render,
and the pricing form seeds once, from a fetch made after mount.
BatchImportDialog also offers nothing until both lists are fresh, so a
cached copy cannot offer models imported since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adminErrors was state kept in step with the form by an effect that ran
validateAdmin after every change. It is a pure function of the form
values, so it is a useMemo now: an error clears on the keystroke that
fixes it, and the last set-state-in-effect suppression goes with the
effect. It was labelled a hand-rolled load; it never fetched anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Data fetching" section described hand-rolled loads and the two
reasons a suppression of set-state-in-effect could stand. Both are gone,
so the section now describes the query layer — keys, writes, signals,
deriving local state — and keeps the rule against new suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thanks for the PR. One thing before review: this targets main, and it should target dev.

main is a release-only line — branch-protected, linear history, and every commit on it is a tagged release snapshot. Routine work lands on dev and reaches main with the next release. GitHub pre-filled main here because it is the default branch, so this is our defaults being unhelpful rather than anything you did wrong.

No need to close and reopen: click Edit next to the PR title and change the base branch to dev. The commits and the discussion carry over.

See CONTRIBUTING.md and docs/operations/release.md for the branch contract.

@fylorn
fylorn merged commit 633f641 into main Sep 14, 2026
7 checks passed
@fylorn
fylorn deleted the refactor/web-query-layer branch September 14, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant