Skip to content

feat(editor): move endpoint availability onto TanStack Query - #7285

Merged
ConnorYoh merged 3 commits into
mainfrom
claude/react-query-endpoint-config
Aug 10, 2026
Merged

feat(editor): move endpoint availability onto TanStack Query#7285
ConnorYoh merged 3 commits into
mainfrom
claude/react-query-endpoint-config

Conversation

@ConnorYoh

Copy link
Copy Markdown
Member

Description of Changes

Stacked on #7264, sibling of #7283. Independent of #7283 — the only overlap is two additive lines in core/query/keys.ts and core/api/config.ts. Either can merge first.

The problem

useEndpointConfig kept its own cache: a module-level globalFetchDone boolean, a mutable globalEndpointCache object, and a resetGlobalCache() called from the JWT listener. Which consumer mounted first decided who paid for the request, and nothing invalidated it except a page reload.

End state

One shared query for the whole availability map; each of the 12 consumers projects the endpoints it asked for. 251 lines to 101, same return shape, no consumer changes.

Before After
Cross-consumer cache globalFetchDone + mutable module object query key
Invalidation resetGlobalCache() mutating that object invalidateQueries
Per-endpoint check own useState triple query keyed by endpoint

Behaviour kept deliberately:

  • Unknown endpoints and any failure still read as enabled. This fires before auth settles, and disabling every tool on a hiccup is worse than letting one call fail later.
  • retry is off for the availability map. The fallback is the answer, so retrying only doubles a request every logged-out visitor makes on load.

Desktop is untouched

desktop/hooks/useEndpointConfig.ts shadows this module entirely — no shared code, so core converting doesn't affect it and there's no half-migrated state.

It's 482 lines of orchestration rather than fetching: dependency-ready gating, tauriBackendService and selfHostedServerMonitor subscriptions, a 2.5s timeout retry for backend startup, a legacy ?endpoints= fallback for old servers, and SaaS-routing optimism that rewrites disabled endpoints to enabled. It also has no test coverage to convert against, and it decides whether tools appear at all in the desktop app.

That's a different job from this one and wants its own review. Next PR.

Testing

9 new tests: projection onto the requested subset, one request across consumers, unknown-endpoint fallback, failure fallback with no retry, empty-list no-fetch, JWT invalidation, and the three single-endpoint cases.

task frontend:check green: 1677 tests across 192 files, typecheck on all five flavours, eslint, dpdm, prettier.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines ignoring generated files. enhancement New feature or request labels Aug 4, 2026
Base automatically changed from claude/react-query-editor-foundation to main August 4, 2026 14:12
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines ignoring generated files. and removed size:L This PR changes 100-499 lines ignoring generated files. labels Aug 4, 2026
@stirlingbot stirlingbot Bot added the has conflicts Pull request has merge conflicts with the base branch label Aug 4, 2026
useEndpointConfig kept its own cache: a module-level globalFetchDone
boolean, a mutable globalEndpointCache object, and a resetGlobalCache()
called from the JWT listener. Which consumer mounted first decided who
paid for the request, and the cache outlived nothing but a page reload.
251 lines to 101.

One shared query for the whole availability map; each consumer projects
the endpoints it asked for. Same return shape, so the 12 consumers are
untouched.

- Unknown endpoints and any failure still read as enabled. This fires
  before auth settles, and disabling every tool on a hiccup is worse than
  letting one call fail later.
- retry is off for the availability map: the fallback is already the
  answer, so retrying only doubles a request that every logged-out visitor
  makes on load.
- JWT change invalidates instead of mutating a module global.

Desktop is untouched. It shadows this module entirely with a 482-line
override whose orchestration — dependency-ready gating, backend-status and
server-monitor subscriptions, timeout retries, SaaS routing optimism — is
not a plain query, and it has no test coverage to convert against. Its own
PR.
@ConnorYoh
ConnorYoh force-pushed the claude/react-query-endpoint-config branch from 5b85654 to f722279 Compare August 4, 2026 15:10
@dosubot dosubot Bot added size:L This PR changes 100-499 lines ignoring generated files. and removed size:XL This PR changes 500-999 lines ignoring generated files. labels Aug 4, 2026
@stirlingbot stirlingbot Bot added Front End Issues or pull requests related to front-end development and removed has conflicts Pull request has merge conflicts with the base branch labels Aug 4, 2026
@ConnorYoh

Copy link
Copy Markdown
Member Author

CI failures here are infrastructure, not the change:

  • deploy-v2-pr — Ubuntu apt mirrors unreachable during the Docker build (Unable to connect to archive.ubuntu.com, then Package 'gnupg' has no installation candidate). Same failure on feat(editor): move app config onto TanStack Query #7283.
  • All checks passed — the aggregate gate, failing only because of the above.

Everything that exercises this change passed: frontend-validation, playwright-e2e, playwright-e2e-live, all three build flavours, and Aikido.

Needs a re-run once the mirrors settle.

@stirlingbot stirlingbot Bot added the has conflicts Pull request has merge conflicts with the base branch label Aug 7, 2026
Both conflicts are additive unions in the shared query layer, from #7283
landing app config on main while this branch adds endpoint availability.

- core/query/keys.ts: keep appConfig alongside endpointsAvailability and
  endpointEnabled.
- core/api/config.ts: keep fetchAppConfig and DEFAULT_APP_CONFIG alongside
  fetchEndpointsAvailability and fetchEndpointEnabled.

No behavioural overlap between the two sides.
@ConnorYoh
ConnorYoh requested a review from a team as a code owner August 10, 2026 10:37
@stirlingbot stirlingbot Bot removed the has conflicts Pull request has merge conflicts with the base branch label Aug 10, 2026
@ConnorYoh
ConnorYoh added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 35a861f Aug 10, 2026
40 checks passed
@ConnorYoh
ConnorYoh deleted the claude/react-query-endpoint-config branch August 10, 2026 13:15
pull Bot pushed a commit to qtxtz/Stirling-PDF that referenced this pull request Aug 29, 2026
…Tools#7726)

# Description of Changes

Step 5 of the TanStack Query rollout, covering the admin People, Teams
and Team details screens. Follows Stirling-Tools#7264, Stirling-Tools#7283, Stirling-Tools#7285.

## The problem

Two separate ones, in the same three files.

**Reads.** Each section fetched and held its own copy of the same
resources: People read the roster and the team list, Teams read the team
list plus the roster again when its add-member modal opened, Team
details read all three. Cost scaled with how many screens you visited
rather than with how much data exists.

**Writes.** Thirteen handlers each did the same five things by hand: set
a processing flag, call the service, toast the outcome, dig a message
out of an axios error, and reload their own slice. Refreshing was a
convention, not a mechanism, and one handler had already forgotten it.

## The fix

Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one
`useAdminMutation` helper that every write is declared against:

```ts
const createTeam = useAdminMutation({
  write: (name: string) => teamService.createTeam(name),
  invalidates: ["teams"],
  success: t("workspace.teams.createTeam.success"),
  errorFallback: t("workspace.teams.createTeam.error"),
  onDone: () => { setNewTeamName(""); setCreateModalOpened(false); },
});
```

Each write names the slices it disturbs, which is the part that only
works when reads and writes are designed together: `createTeam`
invalidates the team list, while a membership move invalidates the list,
both teams' detail rows and the roster, because it genuinely changes all
three. Invalidation refetches only mounted queries, so this costs
nothing extra.

The blanket "invalidate everything" helper survives in exactly one role:
child components (invite, password change, seat update) that write
through their own services, where the affected scopes are not visible
from the call site.

## Why it is better, measured

Request counts come from one harness driving `teams -> team details ->
back -> people`, run against the branch point and against this branch.
The assertion is committed, so it cannot silently regress.

| | Before | After |
|---|---|---|
| Requests | 7 | **3** |
| `getTeams` | 4 | **1** |
| `getUsers` | 2 | **1** |
| `getTeamDetails` | 1 | 1 |
| Committed renders | 17 | **15** |

Three is one per distinct resource, the floor for that sequence. The
four `getTeams` were the Teams table, Team details fetching the same
list for its "move to team" dropdown, the explicit refresh on the back
button, and People.

Renders barely move, which is expected: this changes where data lives,
not how often React draws. It is reported because a caching change can
quietly cost renders, and this one does not.

On the code itself, across the three sections:

| | |
|---|---|
| Net lines | **-216** |
| `useState`/`useEffect` removed | **11**, none added |
| Duplicated `isAxiosError` blocks | 13 to **1** |
| `setProcessing` calls | 19 to **0** |

`isAxiosError` is no longer imported by any of the three files.

## Bug fixed

`disableMfaByAdmin` showed a success toast and never refreshed. The menu
item renders only when `user.mfaEnabled` is true, so an admin disabled
MFA, was told it worked, and watched the option stay on screen until a
manual reload. It is covered by a test that fails if the invalidation is
removed.

## Behaviour worth checking in review

- A write no longer blocks its handler before closing the modal. The
dialog closes when the write succeeds and the table updates when the
refetch lands, rather than the button spinning through both.
- Modal submit buttons now track their own mutation rather than one
shared flag. Team details still derives a single busy flag, now from its
five mutations rather than a `useState`, so its row actions disable
together as before.
- The per-handler `console.error` is kept, once, in the shared error
path.

## Testing

Five tests, each verified by breaking the implementation and confirming
that one test, and only that one, fails:

| Mutation | Caught by |
|---|---|
| Drop the shared stale window (`staleTime: 0`) | request-count test |
| Make invalidation a no-op | write-visibility test |
| Ignore the login-enabled gate | login-disabled test |
| Stop invalidating after the MFA write | MFA-refresh test |
| Fall back to the generic error message | server-message test |

The write tests drive the real flows through their modals and menus
rather than calling hooks directly.

`task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail identically
with this branch's changes reverted.

## Scope

The three services keep their current shape; nothing outside these three
sections and the new hook module changes. Child modals that write
through their own services still refresh via the blanket helper, and
converting those is separate work.
pull Bot pushed a commit to kokizzu/Stirling-PDF that referenced this pull request Aug 29, 2026
…ls#7436)

# Description of Changes

Step 4 of the TanStack Query rollout, and the first of the polling
hooks. Follows Stirling-Tools#7264, Stirling-Tools#7283, Stirling-Tools#7285.

## The problem

`useSigningSessions` hand-rolled its own fetch, loading state and
`setInterval`. Two consequences:

- **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle
background timers, they do not stop them, so a backgrounded editor with
Shared Sign open keeps hitting both endpoints for as long as it is open.
- **No tests.** The hook had none, and its quietest behaviour (below) is
the easiest thing to break without noticing.

## End state

One query behind `qk.signingSessions()`, with the polling lifecycle
handed to the library:

- Polling stops while the tab is hidden, and refetches on return rather
than leaving data up to a full interval stale.
- Mounts render from cache while they revalidate, so moving between the
tool picker and the signing tool no longer flashes an empty list.
- 12 tests where there were none.

Same return shape, so no consumer files change.

### What this is not

This is not a deduplication win. The three consumers are never mounted
at the same time: `ToolPanel` renders the tool picker or the active tool
and never both, so the badge cannot be on screen with either of the
others, and `SharedSigningLauncher` and `useSigningSessionController`
sit inside two different tools. The shared key earns its keep on cache
reuse across those transitions, not on concurrent fetches.

## The bit worth reviewing

The hand-rolled `{ silent: true }` flag encoded three states, and no
single Query flag reproduces them:

| | Spinner | Toast on failure |
|---|---|---|
| First load | yes | yes |
| Background poll | no | no |
| Explicit refetch | **yes** | **yes** |

`isLoading` is false during an explicit refetch when data is already on
screen; `isFetching` is true during a background poll. Neither matches,
so the user-initiated case is tracked with a small flag and the failure
toast is gated on `isLoadingError` plus the explicit path.

## Testing

Twelve tests. Rather than trust them, each claim was checked by breaking
the implementation and confirming the relevant test fails:

| Mutation | Caught by |
|---|---|
| `refetchIntervalInBackground: true` | hidden-tab test |
| Drop `refetchOnWindowFocus` | returns-to-view test |
| Drop the user-initiated spinner flag | manual-refresh test |
| Toast on every error | background-failure-is-silent test |
| Give each observer its own key | dedupe test |

Three things worth knowing for the next conversion:

- **`waitFor` flushes renders.** Recording an index *after*
`waitFor(callCount === 2)` skips past the in-flight render, so a "did
the spinner flip on" assertion passes vacuously. The marker has to go
before the poll.
- **Fake timers hide in-flight state.** The fetch settles inside the
same `act()`, so the intermediate render never happens. That test uses
real timers and a held-open promise.
- **`visibilitychange` has to bubble.** query-core listens for it on
`window`, and the real event bubbles from `document`. A test helper
dispatching a non-bubbling event never reaches the focus manager, and
the pause behaviour still appears to work because `refetchInterval`
reads `document.visibilityState` directly at tick time rather than
through the event.

**One claim is deliberately unguarded.** `isLoading` vs `isFetching` for
a background poll produces no re-render at all, so there is nothing
observable for a test to assert and no user-visible difference to
protect.

## Pre-existing failures

`task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, fail identically with this branch's
changes reverted and are untouched by it.

## Scope

This is one of five pollers. The remaining four, `useLocalFolderPoller`,
`WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud
`TeamSection`, are separate files with their own consumers and follow
separately, now that the silent-refresh pattern has a worked example.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Front End Issues or pull requests related to front-end development size:L This PR changes 100-499 lines ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants