fix(site): deflake storybook interaction stories racing radix layer close - #28874
fix(site): deflake storybook interaction stories racing radix layer close#28874ThomasK33 wants to merge 6 commits into
Conversation
…r arming Six interaction stories flake when the suite runs under CPU load (as it does inside make pre-push -j24). Four click through a just-closed Radix Select before Radix asynchronously restores pointer events and unmarks the page aria-hidden; a shared waitForRadixLayerClose helper now re- queries the next interaction target until it is interactive again. Leave With Unsaved Changes raced deeper: the identifier combobox only commits the typed value to formik on blur, so the story's dialog relied on the link click's own pointerdown blur beating react-router's render- armed navigation blocker. The story now blurs explicitly and waits for the prompt's beforeunload leg to prove the blocker is armed before navigating, and the validation-error assertion waits for formik's async validation pass.
The interaction project runs under full CPU contention inside make pre-push, which keeps surfacing timing races that never fire standalone. Deterministic failures still fail every retry and block the push.
…istbox close fillForm clicks a login-type option and immediately types into the password field or clicks Save, racing Radix's asynchronous pointer-events and aria-hidden cleanup after the listbox closes. Fast runs (isolated files or a loaded pre-push) lose the race deterministically; idle full-suite runs pass only because per-story gaps give the cleanup time.
A full-suite discovery run under CPU load with retries and bail disabled surfaced the complete tail of the same class: TaskPrompt, MCPServersPage, AddMCPServerPageView, and WorkspaceSettingsPageView stories all interact immediately after an option click closes a Radix listbox. Retries cannot absorb these under sustained pre-push contention, so each is fixed at the root with waitForRadixLayerClose.
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 4 | Last posted: Round 4, 24 findings (2 P1, 8 P2, 8 P3, 1 P4, 3 Nit, 2 Note), REQUEST_CHANGES. Review Finding inventoryFinding inventory - PR #28874Findings
Law analysisNot run. Effective additions 151 (< 1000 threshold). Contested and acknowledgedCRF-8 (Note, site/src/testHelpers/storybook.tsx:39) - competing approaches to the Radix layer-close race
CRF-9 (Note, ModelForm.stories.tsx:175) - beforeunload probe couples to useUnsavedChangesPrompt internal
CRF-26 (P4, TaskPrompt.stories.tsx:499) - CRF-12 residual: external-auth negative assertion
Round logRound 1Netero-only first pass (pre-panel gate: P1 findings present). Reviewed against b5c49e1..9676ea9. 3 P1, 2 P2, 1 P3, 1 Nit, 2 Notes. All Netero findings verified by orchestrator: Makefile ordering (CRF-2) confirmed sequential via Round 2 updateBLOCKED by churn guard. Reviewed against b5c49e1..dbaa308. Churn guard: 6 addressed (CRF-1,2,3,4,5,6 in dbaa308), 1 acknowledged (CRF-9), 1 contested (CRF-8, author rejected pointerEventsCheck:0 as hiding the detection signal, no doc added), 1 silent (CRF-7). CRF-7 (em-dash Nit in PR description) triggers BLOCKED: PR body still contains 5 U+2014 and the author's round-1 response omits it. No reviewers spawned. Panel review deferred until the author fixes CRF-7 or states why not. CRF-4 modality audit (17-of-18 non-modal claim) not yet verified; the panel evaluates it next round. Event: COMMENT. Round 3 updatePROCEED (churn guard: CRF-7 fixed body-only, CRF-8 still contested). Netero re-scanned the restructured head dbaa308 (it last scanned round-1 head 9676ea9; round 2 ran no Netero). Netero found 2 new P2 + 2 new P3, so the pre-panel Netero decision gate fires again: Netero-only COMMENT round, panel deferred. New findings CRF-10 (P2), CRF-11 (P2), CRF-12 (P3), CRF-13 (P3). CRF-8 re-raised at P3. Orchestrator verified CRF-10 (aria-hidden vacuous assertion, confirmed structurally: canvas.queryByRole after listbox opens at line 1334) and CRF-11 (Label htmlFor=login_type / SelectTrigger id gives an accessible name; Netero's getByRole proof). Netero independently VERIFIED the CRF-4 modality audit (enumerated 33 role="option" files, all non-modal cmdk-in-Popover; no unfixed modal-Select with a post-pick pointer interaction). CRF-1/CRF-3 confirmed fixed. Consecutive Netero-only cap: next round must proceed to the panel. Event: COMMENT. Round 4 update (first panel round)PROCEED. Head bb3c9a0 (commit "fix(site): make radix layer waits mechanical and negative assertions meaningful", 6 files +37 -47). Churn guard: all 5 round-3 findings addressed (CRF-8 documented in site/AGENTS.md; CRF-10 Escape-close + wait; CRF-11 selectRadixOption; CRF-12 selectRadixOption; CRF-13 signature made mechanical). Consecutive-Netero cap reached (rounds 1, 3 Netero-only); this round runs the full panel. NOTE (orchestrator cross-check): the Round 4 panel resultFull panel: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Ging-ts, Ging-react, Nami, Gon, Leorio, Zoro (wildcard). Core Radix race verified fixed by all (CRF-10 mutation-proven correct by Pariston/Bisky; CRF-4 modality audit independently re-confirmed by Pariston/Mafuuu/Zoro; CRF-13 genuine root fix). 14 new findings CRF-14..CRF-27. Event: REQUEST_CHANGES (CRF-14 P1 title regression + 4 P2). Convergences: CRF-15 (9 reviewers, timeout vs cap), CRF-20 (7, hardcoded userEvent), CRF-21 (6, AGENTS.md rule), CRF-24 (6, selectOption wrapper). CRF-16 (Zoro) is the key structural finding: CRF-13 fixed the query but not the target choice. Contradiction resolved: Komugi's TaskPrompt-vacuous claim (CRF-26) empirically refuted by Pariston -> P4. Posted CRF-15,16,17,19,20,21,22,23,24,26,27 inline; CRF-14,18,25 folded to body/non-diff. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
First-pass review only. These are mechanical findings from Netero (our first-pass reviewer); the full review panel has not yet reviewed this PR and will do so after these are addressed. These are defects worth fixing before the panel spends parallel review time.
What the change does well: the root-caused waitForRadixLayerClose helper targets the real Radix teardown condition (re-querying by role so an aria-hidden target throws, then checking pointer-events, mirroring user-event's own gate), and it is well documented. Test density is high and the LeaveWithUnsavedChanges blur-then-probe fix is grounded in the actual useUnsavedChangesPrompt behavior.
Severity count: 3 P1, 2 P2, 1 P3, 1 Nit, 2 Notes.
Three items need attention beyond the inline comments:
[CRF-3] (P1) The PR title fails the required title check (CI already reports it red). Scope site/src does not contain site/vite.config.mts. Retitle to fix(site): deflake storybook interaction stories racing radix layer close so the scope covers all 10 changed files.
[CRF-7] (Nit) The PR description uses em dashes (U+2014), which the project bans in PR-facing text. The diff itself is clean. Replace them with commas, semicolons, or periods.
[CRF-8] (Note) The codebase now has two approaches to this exact race: this PR's explicit wait, and userEvent.setup({ pointerEventsCheck: 0 }) already used at AppLink.stories.tsx:120. One line in site/AGENTS.md or the helper JSDoc stating why the wait is preferred would keep the next author from picking the other.
The retry finding (CRF-1) and the false-justification finding (CRF-2) are coupled: the comment and PR body claim test-storybook runs concurrently with the Go tests under make pre-push, but the Makefile runs test-storybook in a separate sequential $(MAKE) invocation after the -j batch completes, with a comment explaining that ordering exists precisely to avoid CPU starvation. If the real contention source is the load harness rather than any real workflow, then a permanent global retry: 2 has no in-repo justification and just masks future flakes. As Netero put it: "What remains is exactly the middle band where a genuine intermittent bug looks green."
Both the retry: 2 decision and whether to accept the untouched-sites class (CRF-4) as out of scope are human decisions: file a ticket or state the acceptance explicitly. This review cannot accept either as permanent.
🤖 This review was automatically generated with Coder Agents.
Review round 1: drop retry:2 (it rested on a wrong claim about make scheduling and eroded the gate unboundedly), add selectRadixOption so plain select flows cannot forget the close wait, convert all plain call sites to it, delete the per-site comments the helper name makes redundant, and fix the one genuinely racing site among the 18 the review flagged (AppearanceForm SelectSingleFromSync; the other 17 are cmdk in non-modal popovers that never mark the page). Full interaction suite is green under a CPU-load harness with --retry=0 --bail=0.
Round 1 response — head
|
|
/coder-agents-review |
There was a problem hiding this comment.
Round 2. Strong response to round 1: six findings are addressed in dbaa308. The structural fix (selectRadixOption owning open then pick then wait so it cannot be forgotten) is the right call over the retry crutch, retry: 2 and its false comment are gone, the PR title now scopes only files under site/src, the FE4 comment noise is deleted, and the getByLabelText contract gap is eliminated by construction. CRF-9 is acknowledged with a sound rationale, and CRF-8 is contested with a real argument (pointerEventsCheck: 0 would silence the only signal that detects this class).
The panel review is blocked on one silent finding:
[CRF-7] (Nit) The em-dash finding on the PR description got no fix and no response. The body still contains 5 U+2014 characters (the project bans emdash/endash in PR-facing text). This is trivial, but a silent drop is what blocks the round, not the severity: the process does not spend parallel panel review time while any finding sits unanswered. Either edit the description to remove the em dashes (commas, semicolons, or periods), or reply stating why it should not be changed.
One item for the panel to verify next round, not a blocker: the CRF-4 modality audit claims 17 of the 18 flagged sites are non-modal (cmdk in a non-modal Popover, or inline lists) and therefore cannot race, leaving only AppearanceForm > SelectSingleFromSync as a real modal Select. That reasoning is plausible and the fix is in, but the 17-of-18 non-modal claim has not been independently verified; the panel will check it once the round unblocks.
Respond to CRF-7 (fix or justify) and the full panel runs on the next /coder-agents-review.
🤖 This review was automatically generated with Coder Agents.
|
CRF-7 addressed (apologies for the silent drop (it was body-only with no thread to anchor on): all five U+2014 characters are removed from the PR description, with the two paired-dash parentheticals rewritten rather than blindly substituted. No code change involved; the head remains Generated with |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 3, first-pass (Netero) again. The churn guard cleared round 2's block (the em-dash finding is fixed), so Netero re-scanned the restructured head. The restructure is genuinely better: Netero independently verified the CRF-4 modality audit (enumerated all 33 role="option" story files and confirmed every organization picker, model selector, autocomplete, and multi-select routes through a non-modal Popover, so there is no aria-hidden/pointer-events cleanup to race), and confirmed CRF-1 and CRF-3 are fixed. The selectRadixOption seam is the right call.
But the re-scan surfaced a new class the fix did not close, so this stays a first-pass round: the full panel has not reviewed yet and will run next round (the mechanical first-pass gate holds while P2s are open). Severity count: 2 P2, 3 P3.
The theme: waitForRadixLayerClose fixes the loud symptom of the Radix-layer race (a pointer interaction throwing on pointer-events: none) but not the silent one. While a modal Select's listbox is open, the page is aria-hidden, and testing-library's canvas.queryByRole excludes aria-hidden subtrees, so a negative queryByRole assertion issued in that window returns null unconditionally and passes no matter what. Netero found two such assertions in files this PR touches, and proved both by execution. As Netero put it: "a button that is definitely rendered is invisible to queryByRole in that window, so the two Delete assertions prove nothing."
The most consequential is CRF-10: in MCPServersPage.stories.tsx, the only assertions that a non-admin cannot see the Delete button run while the auth-method listbox is open, so they are vacuous. A UI regression that leaks Delete to a read-only org member would ship green.
CRF-8 re-raised (P3): the author's argument for rejecting pointerEventsCheck: 0 (it silences the only signal that detects this class) is sound, but it is the argument for banning the option, not for leaving it undocumented. One live usage remains at AppLink.stories.tsx:120; the next author who hits the same error will grep, find it, and copy it, re-opening the class. Either ban it mechanically (the way emdashes are banned via a check script) and convert the one usage, or add one line to site/AGENTS.md naming the default and the exception. A thread comment does not close it.
Two notes for later, not blockers: LeaveWithUnsavedChanges has two sequential 10s waits under a 15s default testTimeout, so a slow first probe truncates the second and the story dies with a generic timeout rather than the specific dialog-never-appeared error. And CRF-9's beforeunload coupling remains accepted.
Once CRF-10 and CRF-11 are addressed, the next /coder-agents-review runs the full panel.
site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx:1343
P2 [CRF-10] The Delete-permission assertions run while the Radix Select listbox is still open, so they cannot fail. (Netero)
UpdateOnlyOrgAdminCanUpdateMCPServeropens the "authentication method" Select at line 1334 and never closes it. Every subsequentcanvas.queryByRole(...)runs while the modal layer has the whole pagearia-hidden, and testing-library's ByRole queries exclude inaccessible subtrees. Both negative assertions (1343 Delete, 1346 delete server) return null unconditionally. Proved it: insertingexpect(canvas.queryByRole("button", { name: "Update server" })).toBeNull()before line 1342 passed, even though that button is definitely rendered.
Orchestrator verified the structure: the auth-method combobox opens at 1334, the body.getByRole("option", ...) at 1336-1338 confirms the listbox is open, and the two canvas.queryByRole Delete assertions follow immediately. This is the only place asserting a non-admin cannot see Delete (1440/1505 assert the positive case), so a regression leaking Delete to a read-only member ships green. Fix: close the listbox (Escape or pick an option, then waitForRadixLayerClose), or move the two Delete assertions above the Select interaction at 1331.
🤖
site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx:503
P3 [CRF-12] Same root cause as CRF-10, second instance: this negative assertion can pass while the page is still aria-hidden from the just-closed version Select. (Netero)
CheckExternalAuthOnChangingVersionspicks a version option at 498 with no wait, then assertscanvas.queryByRole("button", { name: /connect to github/i })is absent. The assertion cannot distinguish "the button was removed" from "the canvas is still hidden by the closing layer". Proved the window is non-zero even on an idle machine: anaria-hiddencheck on the canvas after the pick threw, andcanvas.queryByRole("button", { name: /run task/i })was null in the same window whilecanvas.getByText(/run task/i)resolved.
Lower severity than CRF-10 because the race window is short rather than permanent, but the failure mode is the same silent false pass. Fix: await waitForRadixLayerClose(() => canvas.getByRole("combobox", { name: /template version/i })) after the pick, or wrap the negative assertion in waitFor(() => expect(...).not.toBeInTheDocument()).
🤖
🤖 This review was automatically generated with Coder Agents.
…meaningful Review round 3: waitForRadixLayerClose now builds its ByRole query internally so the aria-hidden half of the contract cannot be bypassed (CRF-13), the vacuous negative Delete assertions in MCPServersPage close the listbox first (CRF-10), TaskPrompt's external-auth negative assertion converts to selectRadixOption (CRF-12), CreateUserPage uses selectRadixOption since its trigger is label-named after all (CRF-11), the pointerEventsCheck:0 exception is documented in site/AGENTS.md (CRF-8), and the unsaved-changes probe budget fits the test timeout.
Round 3 response — head
|
|
/coder-agents-review |
There was a problem hiding this comment.
First full panel round (rounds 1 and 3 were Netero first-pass, round 2 was blocked). The core of this PR is solid and now verified independently, not taken on trust: the Radix layer-close race is genuinely fixed. Pariston and Bisky both mutation-tested the CRF-10 fix (grant the org delete permission so a Delete button really renders, and the story fails at head; delete the two wait lines and the impossible assertion passes again), so the negative assertions now assert something. Pariston, Mafuuu, and Zoro each independently re-walked the CRF-4 modality audit and it holds. CRF-13 is a real root-cause fix, and the beforeunload probe is sound at the code level. The mechanism in the JSDoc is the mechanism that runs.
What still needs work, new findings this round: 1 P1, 4 P2, 5 P3, 1 P4, 3 Nit.
The one hard blocker is process, not code: [CRF-14] (P1) the required title check is red again. This round adds site/AGENTS.md (the CRF-8 doc fix), which is outside the PR title's fix(site/src) scope, so CRF-3 recurred exactly as before. As Mafu-san put it, both commits are titled fix(site) while the PR title stayed fix(site/src): the knowledge was present, the action was taken in one place and not the other. Retitle to fix(site).
The rest cluster into one theme the panel converged on hard: the helper is now mandated by site/AGENTS.md, so its ergonomics and diagnostics are load-bearing, and three of its rough edges undercut the very goal of the PR (legible failures).
- Nine reviewers independently flagged that the helper's 10s wait sits inside a 15s test cap [CRF-15], so a genuinely failed wait past the first call in a story reports as a bare
Test timed out in 15000msinstead of the named role/name error. That is the exact illegible signal that made this class take four rounds to pin down. Zoro measured the real teardown at a few hundred milliseconds; the fix is a ~2-3s default plus an override. - Zoro's [CRF-16] is the sharpest structural finding: CRF-13 made the query mechanical but left the choice of wait target with the caller, and a wrong target silently waits for nothing, which is the same vacuous class as CRF-6/10/12 moved into the parameter list. Waiting on the just-closed combobox trigger (always derivable, always outside the portal) removes the caller's decision and makes the helper self-protecting at both ends.
- Leorio [CRF-17] ran both failure branches: they say
expected 'none' not to be 'none'and, worse, blame a missing combobox that is actually just hidden, pointing the reader at the wrong file.
Two findings question the PR's own scope claims. [CRF-18] (Mafu-san P1, Mafuuu P2, Komugi P4): two TrialRequestForm stories still time out under the checked-in maxWorkers: 4, no busy-loop needed. They are a different, pre-existing class (per-character userEvent.type against the 15s cap, they fail at the base commit too), but they are in a file this PR edits, they defeat the stated "unblock pre-push" outcome, and the body's "complete tail / every failure is the same class / 457 files green" does not reproduce off the author's machine. This needs a human decision: fix the typing pattern, raise the storybook testTimeout, or narrow the body claim and file a ticket. And [CRF-22]: the new waitFor wrapped around ReasoningEffortValidationError runs strictly later than the version that already passed, so its "formik validates asynchronously" comment is a hypothesis; either it is speculative or it is a third failure class the "two classes" framing denies. One sentence from the author settles it.
One contradiction inside the panel, resolved: Komugi argued the CRF-12 external-auth negative is still vacuous [CRF-26]; Pariston empirically refuted it (mutating the mock so both versions need auth makes the story fail, so it discriminates). The concrete vacuity claim loses to the empirical result, so it is downgraded to P4, with an optional waitForElementToBeRemoved to close the residual timing margin.
Process note: the PR description has drifted, it documents the removed waitForRadixLayerClose(getTarget) callback API and has no round-3 section, so a reader following it writes code that will not compile. Worth a refresh alongside the retitle.
Everything green on the mechanics: tsc --noEmit and biome check clean across all reviewers, CRF-1/CRF-7 stayed fixed, no em dashes.
site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx:147
P2 [CRF-18] Wall-clock timeouts in this file still fail under the checked-in worker count, so the PR's stated outcome does not fully hold. (Mafu-san P1, Mafuuu P2, Komugi P4, Nami Note)
Run 1, the nine affected story files together at head:
Company Name Over Limit 19129msandSubmits Complete Form 15000ms, bothTest timed out in 15000ms.maxWorkers: 4is the checked-in default, not a harness; I did not need a busy loop. Root cause is wall-clock: line 147 types 101 characters one keystroke at a time, each a full React render plus formik validation. These are not the Radix class. (Mafuuu)
Mafu-san and Komugi both confirmed the same stories fail at the base commit too, so this is pre-existing and a different class, not a regression. Severity is contested (P1/P2/P4). It lands at P2 because it is in a file this PR edits, it defeats the stated "unblock make pre-push" goal, and the body's completeness claims ("surfaced the complete tail", "every failure across all waves is the same class", "457 files green") are falsified by a cheaper experiment than the one they describe. This needs a human decision, not silent acceptance: fix the typing (userEvent.type(el, str, { delay: null }) or paste), raise the storybook testTimeout, or file a ticket and narrow the body claim to "closes the Radix class".
🤖
site/src/pages/CreateUserPage/CreateUserForm.tsx:231
Nit [CRF-25] data-testid="login-type-input" is now dead markup. (Bisky, Gon)
CRF-11 replaced its only consumer with a role query (
selectRadixOption(canvas, /login type/i, ...)). A repo-wide grep forlogin-type-inputreturns the attribute and nothing else.site/AGENTS.mdallowsdata-testidonly where no role or accessible name exists, and this PR proved one exists. (Bisky)
Fix-chain cleanup: delete the attribute.
🤖
🤖 This review was automatically generated with Coder Agents.
| const target = canvas.getByRole(role, { name }); | ||
| expect(window.getComputedStyle(target).pointerEvents).not.toBe("none"); | ||
| }, | ||
| { timeout: 10_000 }, |
There was a problem hiding this comment.
P2 [CRF-15] The helper's 10s wait exceeds the 15s browser test budget, so a real wait failure reports as an opaque timeout instead of naming its target. (Nami P2; Bisky, Hisoka, Komugi, Pariston, Mafuuu, Mafu-san, Gon, Zoro P3)
The storybook project sets no
testTimeout, so vitest's browser default applies (resolved.testTimeout ??= resolved.browser.enabled ? 15e3 : 5e3).CheckPresetsWhenChangingTemplatecallswaitForRadixLayerClosefour times; only the first can ever reach its own timeout. The second through fourth are cut off by the 15s test cap, so testing-library'sUnable to find role "combobox"message is thrown away and replaced by vitest's genericTest timed out in 15000ms. That diagnostic is the entire value the helper buys. (Nami)
Nine reviewers converged here. Measured teardown is a few hundred milliseconds (Zoro/Hisoka/Komugi), so 10s is ~20-50x the observed window and buys nothing except the loss of the diagnostic. This is the exact illegible failure the PR exists to eliminate. Default to ~2-3s and accept an override; name the constant (Gon). This interacts with CRF-19 (the ModelForm budget) and CRF-17 (the message text).
🤖
| * pointer input. The role query is built internally so the aria-hidden | ||
| * half of the check cannot be bypassed with a non-role getter. | ||
| */ | ||
| export const waitForRadixLayerClose = async ( |
There was a problem hiding this comment.
P2 [CRF-16] waitForRadixLayerClose leaves the wait-target choice to the caller, and a wrong pick silently waits for nothing. (Zoro P2; Hisoka, Bisky P3)
The helper's entire check is "this element is in the accessibility tree and accepts pointer input". Any element inside the still-open layer, or in a portal Radix does not mark, satisfies both while the layer is open, so the wait returns on the first tick and proves nothing. This is the same vacuous-check class as CRF-6, CRF-10 and CRF-12, moved from the call sites into the helper's parameter list. The six manual call sites currently pick three different things. Only the combobox just closed is mechanically derivable and always correct: the trigger is outside the portal, so Radix un-hides it in the same operation that un-hides the page. (Zoro)
The key structural finding of the round. CRF-13 made the query mechanical but not the target. Hisoka showed the concrete failure mode: point the helper at a disabled submit button (forms keep them disabled until valid) and the pointer-events leg can never pass, burning 10s (reproduced at 10161ms). Zoro's fix, waitForRadixLayerClose(canvas, comboboxName) that queries the just-closed trigger internally, removes the decision, dodges the disabled-target trap, and drops the role param. It also makes selectRadixOption self-protecting at its leading edge, which resolves the redundant manual wait at ModelForm.stories.tsx:545 (Hisoka P3, Nami Note) and the "the wait cannot be forgotten" JSDoc claim, currently the wait was relocated to the caller's previous step, not made unforgettable. Bisky adds a lighter mitigation for the negative-assertion sites: co-locate a positive control (expect(getByRole(...)).toBeVisible()) so the claim proves the page is queryable.
🤖
| // getByRole excludes aria-hidden subtrees, so this throws until | ||
| // the page is unmarked. | ||
| const target = canvas.getByRole(role, { name }); | ||
| expect(window.getComputedStyle(target).pointerEvents).not.toBe("none"); |
There was a problem hiding this comment.
P2 [CRF-17] The helper the frontend is now required to use fails with messages that mislead. (Leorio)
I ran both branches to get the exact text. pointer-events branch:
expected 'none' not to be 'none'after burning 10 seconds, no element, no role, no name, no hint that a modal layer failed to tear down. aria-hidden branch: testing-library saysUnable to find an accessible element with the role "combobox"and a DOM dump, but the element is there, the page is justaria-hidden, so the developer goes to diff the component looking for a query that never broke. Ten seconds of silence, then a message that points at the wrong file. That's the failure mode this whole PR exists to kill, and the helper reproduces it in prose. (Leorio)
storybook/test re-exports vitest's expect, which takes a message as its second argument. Name the condition on the assertion and rethrow the timeout with context so the reader knows which of the two things happened and where to look. Cheap, and it converts the helper from a diagnostic liability into the diagnostic asset it is meant to be.
🤖
| const dialog = await screen.findByRole( | ||
| "dialog", | ||
| { name: /unsaved changes/i }, | ||
| { timeout: 10_000 }, |
There was a problem hiding this comment.
P3 [CRF-19] LeaveWithUnsavedChanges budgets 4s + 10s = 14s of a 15s cap, so under load a broken blocker reports as an anonymous timeout. (Hisoka, Komugi, Pariston, Nami, Gon, Leorio, Zoro P3)
The comment at :174 does the arithmetic ("this and the dialog wait below must fit the 15s test timeout together") but leaves under 1s for the story's type, tab, click and render. If the blocker dialog ever needs more than about 5s (the case the 10s was chosen for), the vitest cap fires first and the story reports
Test timed out in 15000msrather thanUnable to find role="dialog", the exact diagnosis this PR exists to make possible. (Komugi/Pariston)
The premise is accurate (the 15s cap is real), but the 10s dialog wait also predates the probe: it was sized for "the blocker may not be armed yet", and the probe at :176-185 now guarantees the blocker is armed before the click, so all that remains is one render. Cut the dialog wait to ~4-5s and the probe to ~2s; both are still an order of magnitude over the measured settle. Name the two literals (Gon).
🤖
| const combobox = await canvas.findByRole("combobox", { | ||
| name: comboboxName, | ||
| }); | ||
| await userEvent.click(combobox); |
There was a problem hiding this comment.
P3 [CRF-20] selectRadixOption hardcodes the module-level userEvent, silently discarding a caller's configured session. (Bisky, Nami, ging-ts, ging-react, Zoro P3; Komugi Nit; Mafuuu Note)
In user-event 14 the direct API is
setupDirect(), which builds a freshSystem()per call, so a story that doesconst user = userEvent.setup(...)gets its options and its pointer/keyboard state dropped for the duration of the helper.CreateUserPage.stories.tsx:107threads auserthroughfillFormwhile the select step now runs on the global instance. It breaks the moment a story that needs setup options has to obey the new AGENTS.md rule:SecretsPageView.stories.tsx:66(applyAccept: false) andAppLink.stories.tsx:120(pointerEventsCheck: 0) already exist. (Nami/ging-ts)
Seven reviewers. Nothing breaks today, but the new rule mandates this helper, so a caller with fake timers or a non-zero delay will hang inside it with no signal. Add an optional user: UserEventObject parameter defaulting to the module API (ging-ts typechecked the fix). ging-react notes the cleaner option: Storybook 8.3+ provides a per-story userEvent on the play context, which is exactly the shared instance this needs, and is worth a line in the AGENTS.md section this PR already edits.
🤖
| ), | ||
| ).toBeVisible(); | ||
| // Formik validation runs asynchronously after the value change. | ||
| await waitFor(() => { |
There was a problem hiding this comment.
P3 [CRF-22] The new waitFor around the validation assertion asserts a cause the diff does not support. (Zoro, Mafu-san P3)
Before this PR the same assertion ran immediately after the option click with no wait, and the PR does not list this assertion as a failure; the story is listed only for the pointer-events race on the second combobox click. The new code inserts
selectRadixOption's layer-close wait before the assertion, so it now runs strictly later than the version that already passed. The comment "Formik validation runs asynchronously after the value change" therefore records a hypothesis, not an observation. (Zoro)
Either it is speculative (delete the waitFor and restore the direct expect(...).toBeVisible()), or it fixed a real third failure class, in which case the body's "every failure across all waves is the same class" is wrong and that class needs the same audit the other two got. One sentence from the author settles which.
🤖
| play: async ({ canvasElement }) => { | ||
| const canvas = within(canvasElement); | ||
| await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); | ||
| // The identifier combobox only commits the typed value to the form |
There was a problem hiding this comment.
P3 [CRF-23] FE4 comment bloat recurs, a partial regression of CRF-5. (Gon)
Six comments added in
bb3c9a06explain the same aria-hidden race at the point of use, in prose, at two to seven lines each, while the helper JSDoc already owns that explanation.ModelForm:166(seven lines where three carry the reason),TaskPrompt:492(restates the helper's own JSDoc and names the identifier on the next line),MCPServersPage:1343(three lines for "Escape first, or the negatives pass vacuously"),ModelForm:188. (Gon)
The fix is one place for the why (the helper) and one line at each call site for the local deviation. Gon's counter-example is ModelForm:570 ("Formik validation runs asynchronously"): one line, states an invariant the code cannot show, do not touch it. That is the shape the other five should take. Trim, do not delete wholesale, the blur/blocker comment earns its length by explaining an ordering nobody would reconstruct from the code.
🤖
| }; | ||
|
|
||
| /** Radix Select portals its listbox into document.body. */ | ||
| const selectOption = async ( |
There was a problem hiding this comment.
Nit [CRF-24] selectOption is now a pure passthrough to selectRadixOption. (Bisky, Pariston, Mafuuu, Nami, Gon Nit; Zoro P3)
Its whole remaining body is
selectRadixOption(within(canvasElement), comboboxName, optionName), and the JSDoc that justified it ("Radix Select portals its listbox into document.body") was deleted in this diff, which is the tell.site/AGENTS.md:58: avoid single-use wrappers that add navigation without adding meaning. Both call sites are in one play that already holdscanvas. (Mafuuu)
Six reviewers. Delete it, hoist const canvas = within(canvasElement) where missing, and call the helper directly.
🤖
| const versionOption = await body.findByRole("option", { | ||
| name: /no external/i, | ||
| }); | ||
| await userEvent.click(versionOption); |
There was a problem hiding this comment.
P4 [CRF-26] CRF-12 residual: the external-auth negative assertion is not vacuous today, but its pass rests on a timing margin. (Komugi P3, refuted empirically by Pariston)
Komugi:
useExternalAuth(selectedVersionId)goes pending on a version switch, the connect button unmounts regardless, soqueryByRole(/connect to github/i).not.toBeInTheDocument()could pass without distinguishing "needs no auth" from "answer not arrived"; what makes it pass is the beforeEach mock resolving in microtasks vs the ~300ms layer wait. Pariston tested it: mutating the mock so both versions require GitHub auth makes the story fail, so the assertion does discriminate.
The concrete vacuity claim is empirically disproved, so this is P4, not a defect today. The residual is a determinism fragility: a slower mock or a query refetch would reopen the loading window. Optional hardening, await waitForElementToBeRemoved(canvas.getByRole("button", { name: /connect to github/i })), which requires the button to exist first and fails loudly if it never disappears.
🤖
| * half of the check cannot be bypassed with a non-role getter. | ||
| */ | ||
| export const waitForRadixLayerClose = async ( | ||
| canvas: ReturnType<typeof within>, |
There was a problem hiding this comment.
Nit [CRF-27] The parameter named canvas receives screen at one of ten call sites. (Gon, Hisoka, Zoro)
DateTimeRangePicker.stories.tsx:199passesscreen(document-scoped) because the picker content is portaled. It typechecks becauseScreenstructurally extendsBoundFunctions, and it behaves correctly, but the name stops describing the argument. (Zoro)
Rename the parameter to queries or scope in both helpers. The helper's correctness does not depend on the scope being the story canvas, so the accurate name also documents that.
🤖
Interaction stories across nine files flake when the suite runs under CPU load. (
make pre-pushrunstest-storybooksequentially after the Go batch precisely to limit contention: the load that broke my pushes was host-level, from concurrent worktree activity on the same machine, which a busy-loop harness reproduces. The races are real regardless of the load source: any sufficiently busy machine exposes them.) They pass on idle runs, which is why they survived review; under load they fail often enough to block every push from a hook-enabled checkout (see #28869 for the missing CI lane).Reproduced with a load harness (busy-loop processes pinning all cores). After fixing the initially observed failures, a full-suite discovery run under the harness with
--retry=0 --bail=0surfaced the complete tail; every failure across all waves is the same class and every affected story is fixed here deterministically.Two failure classes
1. Radix layer-close races (12 stories in 8 files). Clicking an option closes a Radix Select, but Radix restores pointer events and unmarks the page
aria-hiddenasynchronously after the close. A pointer interaction (or role query) issued immediately afterwards races that cleanup:Fixed structurally:
selectRadixOption(canvas, combobox, option)intestHelpers/storybook.tsxowns the whole open → pick → wait sequence so the wait cannot be forgotten; plain flows use it, and irregular flows (assertions on the open listbox, unnamed triggers) use the underlyingwaitForRadixLayerClose(getTarget), which re-queries the next interaction target by role (so anaria-hiddentarget throws) until it also accepts pointer input: the same condition user-event'sassertPointerEventsgates on. Applied inTrialRequestForm(selectOptionhelper),DateTimeRangePicker(ApplyCustomRange),ModelForm(ReasoningEffortInProviderConfiguration,ReasoningEffortValidationError,GoogleThinkingLevelBudgetMutualExclusion),CreateUserPage(fillForm, 4 stories),TaskPrompt(SelectTemplateVersion,CheckPresetsWhenChangingTemplate),AddMCPServerPageView(Default),MCPServersPage(AddFailurePreservesEnteredValues), andWorkspaceSettingsPageView(UpdateAutomaticUpdatesPolicy).2. Navigation-blocker arming race (
ModelForm > Leave With Unsaved Changes). The model identifier field is a catalog combobox that only commits the typed value to formik on blur, so typing alone never marks the form dirty. The story previously depended on the "Back to models" click's ownpointerdownblur committing the value and the navigation blocker arming in the resulting render, before the same click's navigation ran. Under load the navigation wins, the story unmounts to the "No Preview" splash, and no dialog ever exists. The story now blurs explicitly (userEvent.tab()), then waits for the unsaved-changes prompt'sbeforeunloadleg (registered in the same commit as the blocker) to observe the dirty state via a cancelable synthetic event before navigating. Standalone runtime dropped from flaky-10s to ~0.5s. The same-commit coupling of the two legs inuseUnsavedChangesPromptis what makes the probe sound.The window where a real user could out-click the blocker is a single frame after blur, so this is treated as a test-determinism fix, not a product fix.
Review round 1: retry guard removed, class audit completed
An earlier revision added
retry: 2for the interaction project; review (CRF-1/2) correctly called it an unbounded erosion of the gate resting on a wrong claim about make's scheduling, and it is removed. In its place the class is closed structurally: reviewer CRF-4 flagged 18 more shape-matched sites in 8 untouched files, and a modality audit showed 17 of them cannot race (cmdk in the site's non-modal Popover, or inline multi-select lists that never mark the page) while exactly one (AppearanceForm > SelectSingleFromSync, a real modal Radix Select) raced and is fixed here.One prior observation stands corrected in the same spirit:
CreateUserPagefails deterministically whenever the file runs fast (isolated or loaded) becausefillFormproceeded immediately after the login-type pick; idle full-suite runs passed only because per-story gaps gave Radix's cleanup wall-clock time.Validation
--retry=0: repeated runs of every affected file under full CPU contention, all green (previously failing every run).--retry=0 --bail=0): green after the final round (the same sweep previously surfaced every failure fixed here).biome checkandtsc --noEmitclean; FE1-FE10 self-audit clean (FE10 note: thegetComputedStyle(...).pointerEventscheck mirrors user-event's own gate).Generated with
mux• Model:anthropic:claude-fable-5• Thinking:xhigh