Flaky e2e: "edit a card, verify changes persist" fails ~1 run in 3 on Escape #165

Closed
opened 2026-08-26 20:25:25 +00:00 by claude-bot · 2 comments
Collaborator

tests/e2e/board.spec.js:189 (edit a card, verify changes persist) fails intermittently at its last assertion:

await page.keyboard.press('Escape');
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3000 });

The dialog stays open. Measured 2 failures in 6 consecutive runs on an unmodified main (95e4eea), driven against a host-side dotnet run in Development at 127.0.0.1:5000. Every earlier assertion in the same test — including reopening the dialog and reading the saved description back — passes, so the circuit is up by the time Escape is pressed.

Where to look: CardModal.OnAfterRenderAsync focuses _backdrop on first render for an existing card, and the Escape handler is @onkeydown on that element relying on the event bubbling up from whatever has focus. If that focus call loses a race with something else on the page taking focus after the dialog's first render, the keydown lands on <body> and nothing hears it. Worth checking whether the reopen path (second .card-title click on an already-mounted page) re-runs OnAfterRenderAsync with firstRender: true at all — the component is recreated, but the second open is the case that fails.

This is not the prerender race docs/testing.md describes: that one is a click landing before the circuit attaches, and here the two assertions before it needed the circuit. Filed from the search work on claude/issue-159, which did not touch the card dialog and reproduces the same rate with those changes stashed.


Filed by Claude while working on #159 (run).

`tests/e2e/board.spec.js:189` (`edit a card, verify changes persist`) fails intermittently at its last assertion: ``` await page.keyboard.press('Escape'); await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3000 }); ``` The dialog stays open. Measured **2 failures in 6 consecutive runs on an unmodified `main`** (95e4eea), driven against a host-side `dotnet run` in Development at `127.0.0.1:5000`. Every earlier assertion in the same test — including reopening the dialog and reading the saved description back — passes, so the circuit is up by the time Escape is pressed. **Where to look:** `CardModal.OnAfterRenderAsync` focuses `_backdrop` on first render for an existing card, and the Escape handler is `@onkeydown` on that element relying on the event bubbling up from whatever has focus. If that focus call loses a race with something else on the page taking focus after the dialog's first render, the keydown lands on `<body>` and nothing hears it. Worth checking whether the reopen path (second `.card-title` click on an already-mounted page) re-runs `OnAfterRenderAsync` with `firstRender: true` at all — the component is recreated, but the second open is the case that fails. This is not the prerender race `docs/testing.md` describes: that one is a click landing before the circuit attaches, and here the two assertions before it needed the circuit. Filed from the search work on `claude/issue-159`, which did not touch the card dialog and reproduces the same rate with those changes stashed. --- _Filed by Claude while working on #159 ([run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/386))._
Author
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/398); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-165/run-398.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Fixed on branch claude/issue-165.

What was actually wrong

Your hunch about the focus call was right, and it's not a reopen-specific bug — the reopen is just where the test happens to press Escape fastest.

CardModal.OnAfterRenderAsync calls _backdrop.FocusAsync(), and that call is not part of the render that put the dialog on screen. In Blazor Server the sequence is: render batch out → browser acknowledges → server runs OnAfterRenderAsync → a second interop message comes back saying "focus this". So between the dialog being visible (which is when every assertion before the Escape passes) and the dialog being focused-from, there is a window one and a half round trips wide where activeElement is <body> — which no component owns, so Blazor routes the keydown nowhere.

I measured it directly, with a MutationObserver in the page recording when .modal-backdrop was inserted and when it received focus:

appeared: 2185.8ms   focused: 2190.8ms   window = 5ms

Five milliseconds on loopback with an idle server and one browser. It's the circuit's latency, so it's however far away the server is — which is why you see it at 2-in-6 and this container sees it at roughly 0-in-25.

firstRender: true does re-run on the reopen (the component is destroyed and recreated by @if (_modalCard is not null)), so that part is fine.

The fix

src/Ponente.Kanban/wwwroot/js/modal-escape.js — a delegated keydown listener on the document, attached from a plain script at the end of <body> rather than from a component, for the same reason the theme switcher in App.razor's head is: it covers every modal on every page, needs no interop to arm, and survives the circuit reconnecting. On Escape with nothing focused it focuses the topmost .modal-backdrop and re-dispatches the keydown there, where Blazor's own delegated listener routes it as the backdrop's @onkeydown exactly as a real event.

The guard is deliberately "target is <body> or <html>" and not "target is outside the dialog": that's precisely the broken state and nothing else, so it can never take an Escape away from a real handler (SearchBox, the column tag flyout) — those need a focused element to receive the event in the first place. It fixes FeedbackModal and ChangelogModal for free, since all three share the backdrop shape.

I did not change the C# focus call — it's still the right thing for keyboard and assistive tech; it just isn't what Escape can depend on. There's a comment at the call site saying so.

The test now blurs before pressing Escape. That's not papering over it — the losing state is "nothing focused", so the test asks for it every run instead of racing for it, and the assertion becomes the one that pins the JS.

Verified

Against a host-side dotnet run in Development, Postgres 15 local, Playwright 1.62.1:

  • Reproduced first: forcing the losing state on unmodified main failed 3/3 at that assertion. (The natural 1-in-3 does not reproduce in this container — it's too fast and too idle; 0 failures in 25 unmodified runs. Hence forcing it.)
  • With the fix: the test passes 15/15.
  • Negative control: with modal-escape.js emptied (file still present, so nothing else changes), the test fails 3/3 at line 230 — the Escape assertion, not somewhere else.
  • board.spec.js + changelog.spec.js + filter.spec.js + search.spec.js: 37/37 — that covers the filter flyout's "dismisses every way" and both search specs, i.e. the other Escape handlers.
  • mobile.spec.js: 7/7.
  • dotnet test Ponente.sln: 448/448, including DocumentationTests.

Not run: feedback.spec.js and the rest, which need the compose stack's stub-forge container — no container engine in this job.

Also updated: docs/ui.md (the card-dialog section, with why the guard is what it is), docs/frontend.md (the wwwroot/js list), and a changelog line in both locales — a dialog ignoring your first Escape is something a member notices.

I filed one out-of-scope finding I hit on the way: the calendar's two role="dialog" overlays have no Escape handling at all, and use their own class so this fix doesn't reach them.


⏱ 22m 48s · 79 turns · tokens: 106.2k in / 36.4k out (+5756.2k cache-read)
📜 Full session transcript
→ Opened PR: #172
→ Filed follow-up issue #173: Escape does not close either of the calendar's two dialogs

Fixed on branch `claude/issue-165`. ## What was actually wrong Your hunch about the focus call was right, and it's not a reopen-specific bug — the reopen is just where the test happens to press Escape fastest. `CardModal.OnAfterRenderAsync` calls `_backdrop.FocusAsync()`, and that call is **not part of the render that put the dialog on screen**. In Blazor Server the sequence is: render batch out → browser acknowledges → server runs `OnAfterRenderAsync` → a *second* interop message comes back saying "focus this". So between the dialog being visible (which is when every assertion before the Escape passes) and the dialog being focused-from, there is a window one and a half round trips wide where `activeElement` is `<body>` — which no component owns, so Blazor routes the keydown nowhere. I measured it directly, with a `MutationObserver` in the page recording when `.modal-backdrop` was inserted and when it received `focus`: ``` appeared: 2185.8ms focused: 2190.8ms window = 5ms ``` Five milliseconds on loopback with an idle server and one browser. It's the circuit's latency, so it's however far away the server is — which is why you see it at 2-in-6 and this container sees it at roughly 0-in-25. `firstRender: true` does re-run on the reopen (the component is destroyed and recreated by `@if (_modalCard is not null)`), so that part is fine. ## The fix `src/Ponente.Kanban/wwwroot/js/modal-escape.js` — a delegated `keydown` listener on the document, attached from a plain script at the end of `<body>` rather than from a component, for the same reason the theme switcher in `App.razor`'s head is: it covers every modal on every page, needs no interop to arm, and survives the circuit reconnecting. On Escape **with nothing focused** it focuses the topmost `.modal-backdrop` and re-dispatches the keydown there, where Blazor's own delegated listener routes it as the backdrop's `@onkeydown` exactly as a real event. The guard is deliberately "target is `<body>` or `<html>`" and not "target is outside the dialog": that's precisely the broken state and nothing else, so it can never take an Escape away from a real handler (`SearchBox`, the column tag flyout) — those need a focused element to receive the event in the first place. It fixes `FeedbackModal` and `ChangelogModal` for free, since all three share the backdrop shape. I did not change the C# focus call — it's still the right thing for keyboard and assistive tech; it just isn't what Escape can depend on. There's a comment at the call site saying so. The test now blurs before pressing Escape. That's not papering over it — the losing state is "nothing focused", so the test asks for it every run instead of racing for it, and the assertion becomes the one that pins the JS. ## Verified Against a host-side `dotnet run` in Development, Postgres 15 local, Playwright 1.62.1: - **Reproduced first**: forcing the losing state on unmodified `main` failed 3/3 at that assertion. (The natural 1-in-3 does not reproduce in this container — it's too fast and too idle; 0 failures in 25 unmodified runs. Hence forcing it.) - **With the fix**: the test passes 15/15. - **Negative control**: with `modal-escape.js` emptied (file still present, so nothing else changes), the test fails 3/3 at line 230 — the Escape assertion, not somewhere else. - `board.spec.js` + `changelog.spec.js` + `filter.spec.js` + `search.spec.js`: 37/37 — that covers the filter flyout's "dismisses every way" and both search specs, i.e. the other Escape handlers. - `mobile.spec.js`: 7/7. - `dotnet test Ponente.sln`: 448/448, including `DocumentationTests`. Not run: `feedback.spec.js` and the rest, which need the compose stack's stub-forge container — no container engine in this job. Also updated: `docs/ui.md` (the card-dialog section, with why the guard is what it is), `docs/frontend.md` (the `wwwroot/js` list), and a changelog line in both locales — a dialog ignoring your first Escape is something a member notices. I filed one out-of-scope finding I hit on the way: the calendar's two `role="dialog"` overlays have no Escape handling at all, and use their own class so this fix doesn't reach them. --- ⏱ 22m 48s · 79 turns · tokens: 106.2k in / 36.4k out (+5756.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-165/run-398.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/172 → Filed follow-up issue [#173](https://git.grandiras.net/Grandiras/Ponente/issues/173): Escape does not close either of the calendar's two dialogs
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#165
No description provided.