Skip to content

fix(api-explorer): clear the typed password on the mint, not on a later render - #1656

Merged
dawsontoth merged 4 commits into
stagefrom
claude/apiexplorer-flaky-test-login-9ce001
Aug 28, 2026
Merged

fix(api-explorer): clear the typed password on the mint, not on a later render#1656
dawsontoth merged 4 commits into
stagefrom
claude/apiexplorer-flaky-test-login-9ce001

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #1655.

ApiExplorer.test.tsx > clears the login password after re-authenticating while already authorized failed only in full-suite runs — green every time in isolation, red on whatever
PR happened to be running when it tripped. It reproduced here twice in 12 pnpm test runs
and never once on the file alone.

It is not a timeout, and it is not test-only. The password genuinely never cleared, because
the clear could arrive a commit late and land on the next thing the user typed.

The mechanism

LoginForm dropped the typed credentials from a useEffect keyed on a pending → idle
status transition. React flushes passive effects in a Scheduler task after the commit they
belong to, and shouldYieldToHost() splits the two into separate macrotasks whenever a
commit blows its 5 ms frame budget — which is what a loaded full-suite run does and an idle
single-file run does not.

findByText(/Credential set —/) resolves off the commit's DOM mutation, so in the split
ordering the test proceeded while the clear was still outstanding. The clear then landed on
the next fireEvent.change, wiping username, which disabled the submit button
(disabled={pending || !username || !password}), so the second mint never ran and the field
kept pw2 until the 1000 ms waitFor deadline. That accounts for the issue's 1165 ms
figure: the first await was fast, the second consumed the whole budget.

Pinning the ordering with an always-advancing performance.now() reproduces it 3/3:

PROBE at-findByText  username="dave" password="pw1"     <- clear not yet flushed
PROBE after-typing   username=""     password="pw2" disabled=true

Under the idle ordering the same probe reads username="" password="" at findByText — the
fast path everyone debugs with.

This is a real UX bug, not only a test artifact. A user who starts typing new credentials
as soon as the panel says "Credential set" can have the username wiped out from under them
and the Authorize button go dead on the empty field.

The change

runMint now reports how it finished — applied / discarded / failed — and the form
clears off that promise instead of off a re-render. The clear batches into the same commit as
the status and credential updates, so no commit that shows the credential still shows the
password that bought it. failed keeps the fields, so a typo is correctable without retyping.

Only applied clears. A discarded mint no longer owns the form: the method buttons stay
live while a mint is pending, so re-picking the auth type supersedes a mint without
unmounting it, and clearing there would blank credentials the user had already retyped — the
same defect one race narrower.

The one clear that should beat live typing is a sign-out, which the deleted effect covered
incidentally (revocation drove status back to idle). A revocation counter now keys
LoginForm, so typed credentials cannot outlive the session they were meant for whether or
not a mint was in flight. It advances only on a newly-observed epoch for this entity
onExplorerAuthInvalidated names no entity and fires on any sign-out, so an unconditional
bump would blank a form belonging to an instance nobody signed out of.

The test

The flaky test now installs that same forced-yield clock, so it exercises the bad
interleaving on every run rather than roughly one in twelve.

Every guard is mutation-checked — reverted locally, confirmed red, and red on a real
assertion rather than a timeout:

mutation failing assertion
restore the passive-effect clear expected 'dave' to be '' 64 ms
widen the clear to discarded expected '' to be 'bob' 76 ms
drop the revocation key expected 'alice' to be '' 34 ms
bump revocation unconditionally expected '' to be 'alice' 34 ms
drop the effect's epoch baseline expected '' to be 'alice' 37 ms

That check is the point rather than a formality: written naively, all four of these tests pass
against the broken code.

Second commit: a per-mousemove localStorage parse

Review caught that useRef(authStore.getExplorerAuthEpoch(entityId)) evaluates its
initializer on every render, and reading the epoch does a localStorage.getItem plus a
JSON.parse. useResizableSidebar keeps the sidebar width in useState and calls setWidth
from mousemove, so a sidebar drag re-renders ApiExplorer continuously — putting that parse
on exactly the path whose own comment says writing localStorage per mousemove "would
stutter". It's now useRef(0), baselined by the effect that already sets it on mount and on
an entityId change, which is the only point the value is read.

Fixing it surfaced that the baseline is load-bearing and was untested: an entity mounting on a
non-zero epoch would read the next unrelated sign-out as its own and blank the form. Covered
now, and mutation-checked.

Verification

  • 20 consecutive full-suite runs green. Before the fix it reproduced twice in 12.
  • tsc -b, oxlint, dprint check — all clean.
  • Explicitly not covered: any browser-level proof. Both new tests are jsdom component
    tests with a mocked mint.

Reviewer notes

Three things I decided rather than fixed, so you can overrule them:

  • A successful mint clears whatever is in the fields at settle time, including text typed
    during that same request. Flagged in review; I kept it, since clearing after a mint that
    succeeded is the stated point of the code and the erased text is credentials the user just
    proved obsolete. The inputs are disabled while pending, so the window is narrow.
  • The performance.now() fixture depends on React's current yield heuristic. If a React
    bump stops consulting it, the fixture goes inert and the suite would not go red. Real, and
    not fixable from inside the test. The superseded-mint and sign-out tests cover neighbouring
    invariants without the clock.
  • MintOutcome lives in SettingsPanel.tsx, which consumes it, and is imported back into
    ApiExplorer.tsx, which produces it. types.ts would be the tidier home; happy to move it.

Two findings review carried forward that are pre-existing and deliberately out of scope
both predate this PR and neither is touched by it. Happy to file them:

  • An unrelated entity's sign-out cancels this explorer's in-flight mint. The subscriber
    increments attemptRef and resets status unconditionally, so signing out of instance B
    silently kills a login you have in flight against instance A. I gated the revocation on
    this entity's epoch but left those two lines alone, since changing them alters behavior
    that predates the bug.
  • A same-tab sign-out emits no storage event, so revocation clearing waits on the stale
    mint's epoch-mismatch continuation rather than firing directly.

One review finding I did not act on, because I could not reproduce it: a claim that the
epoch-mismatch branch in runMint double-remounts the form after a cross-tab sign-out. It
cannot — the same subscriber does attemptRef.current++, so an in-flight mint exits at the
superseded guard and never reaches the epoch check. Demonstrated rather than argued: with the
revocation bump made unconditional, the double-settle test stayed green, which is only
possible if that branch never runs.

Review coverage

Four pre-push cross-model rounds ran; rounds 2 and 3 each found a genuine defect in the
previous round's fix, both now fixed. Coverage was thinner than the round count suggests —
every round had gemini fail on quota and cursor-composer fail on the 1Password SSH
agent (sign_and_send_pubkey: signing failed), with cursor-grok pruned. Actual coverage
each round was codex + harper-domain only
, so an extra human pass on the concurrency
reasoning is worth more here than the round count implies.

Separately, and out of scope for this PR: the suite has a second flake. One of the 12
baseline runs failed with Errors 1 error and no test named — an unhandled
TypeError: The "event" argument must be an instance of Event from undici's WebSocket,
attributed to ToolCallGroup.test.tsx but originating from a socket opened elsewhere
(NotificationsSubscriptionManager.tsx is the only new WebSocket in src). #1655 lists
that Errors 1 error husky shape as the same defect; it is not. Worth its own issue.

🤖 Generated with Claude Code

Review-Coverage: authored=claude; ran=codex; blocked=gemini(quota); declined=cursor-grok,cursor-composer,domain; rounds=5 @ 81a642f

Human-Review-Need: 3 @ 81a642f

…er render

The login form dropped the typed credentials from a `useEffect` keyed on a
`pending` -> `idle` status transition. React flushes passive effects in a
Scheduler task *after* the commit they belong to, and `shouldYieldToHost()`
splits the two into separate macrotasks whenever a commit blows the 5ms frame
budget. In that ordering the clear arrives a commit late: a user who starts
typing the next credentials as soon as the panel says "Credential set" has the
username wiped out from under them, which disables the submit button on an
empty field.

`runMint` now reports how it finished (`applied` / `discarded` / `failed`) and
the form clears off that promise, so the clear batches into the same commit as
the status and credential updates. Only `applied` clears: a `discarded` mint no
longer owns the form, and re-picking the auth type mid-flight supersedes a mint
without unmounting it, so clearing there would blank credentials the user had
already retyped -- the same defect one race narrower.

The one clear that *should* beat live typing is a sign-out, which the deleted
effect covered incidentally (revocation drove status back to `idle`). A
revocation counter now keys the form instead, so typed credentials cannot
outlive the session they were meant for whether or not a mint was in flight.
The counter advances only on a newly-observed epoch for this entity:
`onExplorerAuthInvalidated` names no entity and fires on any sign-out, so an
unconditional bump would blank a form belonging to an instance nobody signed
out of.

Guards are mutation-checked, none by timeout: restoring the effect fails the
re-auth test in 64ms on `expected 'dave' to be ''`; widening the clear to
`discarded` fails the superseded-mint test on `expected '' to be 'bob'`;
dropping the revocation key fails the sign-out test on `expected 'alice' to
be ''`; bumping revocation unconditionally fails the cross-entity test on
`expected '' to be 'alice'`.

Verified: 20 consecutive full-suite runs green (it reproduced twice in 12 runs
before this).

Refs #1655

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue where typed credentials in the API Explorer login form were incorrectly cleared due to late-flushing passive effects or discarded/failed mint operations. It replaces the useEffect-based status tracking with a promise-based outcome (MintOutcome) to conditionally drop typed credentials only when a mint is successfully applied. Additionally, it introduces a revocation counter to reset the form key when authentication is revoked, and adds comprehensive tests to verify these behaviors. I have no further feedback to provide as there are no review comments.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.63% 8030 / 13465
🔵 Statements 60.09% 8609 / 14325
🔵 Functions 52.45% 2029 / 3868
🔵 Branches 53.45% 5744 / 10745
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/store/authStore.ts 68.82% 57.14% 75.92% 69.11% 98, 184-205, 221-230, 240, 261, 285-296, 303-312, 367-370, 514-527, 575-581, 615, 619-626, 638, 651-679
src/features/instance/apis/explorer/ApiExplorer.tsx 92.43% 70% 96.66% 92.52% 135-136, 139-141, 156, 177, 190, 230
src/features/instance/apis/explorer/SettingsPanel.tsx 94.02% 82.19% 93.33% 93.93% 42, 206, 366-372, 389
Generated in workflow #1824 for commit 86b463b by the Vitest Coverage Report Action

…render

`useRef(authStore.getExplorerAuthEpoch(entityId))` evaluates its initializer on
every render, not just the first, and reading the epoch does a
`localStorage.getItem` plus a `JSON.parse`. `useResizableSidebar` keeps the
sidebar width in `useState` and calls `setWidth` from `mousemove`, so a sidebar
drag re-renders `ApiExplorer` continuously -- putting that parse on exactly the
path whose own comment says writing localStorage per `mousemove` "would
stutter".

The effect below already baselines the ref on mount and on an `entityId`
change, which is the only point the value is read, so the eager initializer was
redundant as well as costly.

That baseline is load-bearing and was untested: an entity that mounts on a
non-zero epoch would otherwise read the next unrelated sign-out as a change to
itself and blank the form. Covered now, and mutation-checked -- dropping the
baseline fails the new test on `expected '' to be 'alice'`.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review August 27, 2026 15:38
@dawsontoth
dawsontoth requested a review from a team as a code owner August 27, 2026 15:38

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good
🤖 Reviewed with Codex

Comment thread src/features/instance/apis/explorer/SettingsPanel.tsx Outdated
Comment thread src/features/instance/apis/explorer/ApiExplorer.tsx Outdated
Comment thread src/features/instance/apis/explorer/ApiExplorer.tsx Outdated
Three review findings on #1656, all sharing one cause: the explorer's
sign-out signal names no entity, so every explorer acted on every sign-out.

`onExplorerAuthInvalidated` now compares the epoch for the `id` it was given —
the parameter existed but was ignored (`_id`) — so an unrelated instance's
sign-out no longer increments this entity's `attemptRef`, which was silently
discarding its in-flight mint. It also notifies in-process subscribers on a
same-tab write, which fires no `storage` event of its own, so a same-tab
sign-out now empties the form directly rather than waiting for the cancelled
mint to settle. The mint's epoch-mismatch branch no longer bumps revocation:
the subscription owns that now, and a second bump would remount the form again
and take any credentials retyped since with it.

`credentialIsCurrent` re-read the epoch during render, so the localStorage
parse was still on the sidebar-resize path that 81a642f claimed to clear --
that commit removed a `useRef` initializer and missed this one. The epoch is
held in state and advanced by the subscription instead.

An `applied` mint no longer clears unconditionally. The inputs stay live while
a mint runs, so a user can submit Alice's credentials and start on Bob's before
a slow mint returns; clearing on Alice's success erased a draft it never saw.
The form counts edits and compares at settle, so the clear stays tied to the
values actually submitted.

Each guard is mutation-checked: ignoring the edit generation fails on
`expected '' to be 'bob'`; dropping the store's epoch comparison fails three
unrelated-entity tests; dropping the same-tab notification fails on
`expected 'alice' to be ''`.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth added this pull request to the merge queue Aug 28, 2026
`onExplorerAuthInvalidated` read its baseline epoch, then registered its
listener. A sign-out dispatched between the two reached nobody -- the event was
gone, and no later one was coming -- so the subscriber sat on a revoked
generation it believed was current. `ApiExplorer` had the same shape one level
up, seeding its cached epoch before subscribing.

Both now re-check once subscribed. The store compares against its baseline
after registering, which is a no-op unless the gap actually swallowed an event,
and the explorer reads its epoch after the subscription is live.

Reordering the baseline to after `addEventListener` was the first attempt and
is worse: `fire` closes over it, so an event arriving in that window throws a
TDZ `ReferenceError` rather than being missed quietly. The new store test
caught that, which is the reason it exists.

Covers the store's subscription directly for the first time -- entity scoping,
the global-logout wildcard, unsubscribe, and the race above. The race test
stages the sign-out *before* `addEventListener` registers, because staging it
after lets the ordinary event path satisfy it: written the obvious way it
passes against the unfixed code, and only fails on `expected 1 times, got 0`
in this order.

Refs #1655

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth removed this pull request from the merge queue due to a manual request Aug 28, 2026
@dawsontoth

dawsontoth commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 86b463b0 after the PR was dequeued — sorry for the interruption to an approved PR. A cross-model round on 6dc642f1 (the commit answering the three review threads) came back with two new minor findings, one of which I judged worth not shipping.

Fixed. onExplorerAuthInvalidated read its baseline epoch and then registered its listener. A sign-out landing between the two reached nobody — the event was already dispatched, and no later one is coming — so the subscriber sat on a revoked generation believing it current. ApiExplorer had the same shape one level up, seeding its cached epoch before subscribing. Both now re-check once subscribed; in the store that is a no-op unless the gap actually swallowed an event.

Two notes on how that landed, since both are the kind of thing worth not repeating:

  • Reordering the baseline to after addEventListener was the obvious fix and is worsefire closes over it, so an event in that window throws a TDZ ReferenceError rather than being missed quietly. The new store test caught it.
  • The race test only discriminates when the sign-out is staged before addEventListener registers. Staged the natural way (register, then dispatch) the ordinary event path satisfies it and it passes against the unfixed code. Mutation-checked in the correct order it fails on expected 1 times, got 0.

This also adds the first direct coverage of the store subscription itself — entity scoping, the global-logout wildcard, unsubscribe, and that race.

Not fixed, and flagged rather than folded in: if the localStorage write throws (storage disabled or full) the durable epoch never moves, so a same-tab sign-out does not revoke the explorer credential. That is pre-existing rather than something this PR introduced, and closing it properly means an in-memory epoch overlay in authStore — more than this PR should carry. Happy to file it.

Gate: tsc -b, oxlint, dprint check clean; 327 files, 2714 passed.

@dawsontoth
dawsontoth added this pull request to the merge queue Aug 28, 2026
Merged via the queue into stage with commit bb76bf4 Aug 28, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the claude/apiexplorer-flaky-test-login-9ce001 branch August 28, 2026 15:21
dawsontoth added a commit that referenced this pull request Aug 28, 2026
#1656 merged by rebase, so stage carries rebased copies of this branch's
parent commits. Both conflicts are that duplication: stage holds the #1656
versions of `authStore.ts` and the explorer-invalidation test, this branch
holds the same content plus the epoch fallback and its coverage. Verified
`git diff stage` over both files is exactly this branch's additions and nothing
of stage's is lost, so each was resolved to this branch's side.

Also corrects the `explorerEpochFallback` doc comment, which still claimed the
memory-only count fails safe across a reload -- the claim the previous commit
disproved and replaced with destroying the credential outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Flaky test: ApiExplorer 'clears the login password after re-authenticating' fails only in full-suite runs

2 participants