Skip to content

fix(channels): keep a panel reopened before its close's return lands - #2562

Open
matt2e wants to merge 1 commit into
mainfrom
mtoohey/flaky-panel-reopen-during-close
Open

matt2e wants to merge 1 commit into
mainfrom
mtoohey/flaky-panel-reopen-during-close

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Closing a routed panel tab calls history.go(-1) to retire its visit, and that return lands on a later popstate. Until it lands, the navigator still shows the closing visit, but the opener already has focus. If a Profile was reopened from the opener in that window, it was hidden when the return arrived, because entering the channel visit restored that visit's saved selection. This PR treats a reopen made before the return lands as superseding the close, so the panel stays open with a fresh visit of its own.

fix(channels): keep a panel reopened before its close's return lands

Problem

Two WebKit journeys lost a Profile that was reopened right after its tab closed. Both failed on attempt 1 on unrelated branches, and the same SHA passed on rerun.

  • profiles.spec.mjs:321 "profile plumbing: exact avatar/mention targets, thread enrichment, lifecycle and recovery", branch peon/participant-collator, run 41752442750 (webkit 13/14): https://github.com/block/buzz-app/actions/runs/41752442750/job/126320885261. After the Mic tab closed, Space on the focused mention never reopened the Profile, and the test failed at :479.
  • solo-workspace.spec.mjs:159 "Solo chat Activity opens the agent's profile locally, restores per conversation and retires on plugin disable", branch peon/dnd-stable-transform, run 41757728323 attempt 1 (webkit 5/14): https://github.com/block/buzz-app/actions/runs/41757728323/job/126338025674. After the agent's Profile closed, Enter on the focused Activity trigger never brought back "Profile details".

Cause

  • Closing a routed panel tab removes it locally. dismissContent then calls navigator.dismiss. The browser driver retires the visit and calls history.go(-1), which lands on a later popstate. Until then the navigator still shows the retiring visit, while afterClose has already focused the opener.
  • A reopen in that window runs open() and then visitPanel. visitPanel sees the navigator already on that Profile's detail, so it doesn't navigate. When the return lands, the route effect enters the channel visit, and enterVisit restores that visit's saved selection, which hides the reopened panel. Reopening a different panel pushes its visit on top of the retiring one, and the late return then removes it.
  • Both traces show this sequence:
    • Profiles run: the close finished at 80128 ms and Space ran at 80148–80193 ms with the URL still on Mic's detail. By 80383 ms the URL was the channel and the dock was closing.
    • Solo run: the close finished at 251783 ms and Enter ran at 251808–251872 ms. By 251999 ms the URL was the channel and the dock was hidden.
  • The dock isn't the cause. PanelDock keeps a reopened subtree through its exit (PanelDock.test.tsx).

Fix

  • When a dismissal retires the current visit and the navigator hasn't moved yet, dismissContent records a pending return. A synchronous driver or a replacing dismissal moves the navigator at once, so neither records anything.
  • While that return is pending, visitPanel records the panel as the reopen instead of navigating.
  • When the return lands on the dismissal's fallback, the route effect checks whether that panel is still selected. If it is, the effect reselects and opens it, which gives it its own visit. If the panel has closed again, or the return landed somewhere else, nothing is revived.
  • A later close before the return lands, such as closing the thread beneath a closed Profile, moves where the return lands. The record follows the latest fallback and keeps the reopen.
  • While the return is pending, the route effect no longer reapplies the retiring visit's detail. Otherwise a re-render could reopen the panel that was just closed.
  • Two new cases in profiles.spec.mjs hold history.go and pause the dock's exit while a Profile closes. One reopens Mic by click and by Space. The other closes a thread's Profile and then the thread, and opens Mic. Each asserts that the Profile shows while the return is held, the navigator stays on the closed visit with exactly one return held, a fresh Mic visit lands after the release, and Back returns to the channel and closes the panel.

The existing journeys are unchanged, and no assertions are loosened.

Residual limit

Thread and other explicit navigation during a pending return is not deferred, so a late return can still undo it. #2113 lists the same limit.

Validation

Local Linux workstation, Playwright 1.63.0, --no-deps --retries=0:

  • New cases without the fix: 4/4 failed. The reopen case failed at :406 (no fresh visit). The thread case failed at :442, with the click stacking a visit on the closed one. With the fix but without the latest-fallback step, the thread case failed 2/2 at :452. With the fix, all pass.
  • Adversarial ordering, with temporary changes that delayed history.go calls from click or key handlers by 250 ms, 6 repeats per engine:
    • Without the fix: 9/24 failed, seven with the CI signatures.
    • With the fix: 24/24 passed, twice.
  • Natural contention, with 4 CPUs shared with 4 busy loops, 2 workers and 10 repeats per engine: 40/40 passed with the fix.
  • The 38 browser specs that close panel tabs, both engines: all passed at 6 workers. At 12 workers, 42 failed on timeouts because the test server had stopped serving under load, and those 42 passed on rerun.
  • Vitest for channels, panels, navigation and channel-navigation: 87 files, 1539 tests passed.
  • Rebased on main f4805605f5:
    • tsc --noEmit, biome check --error-on-warnings on the changed files and git diff --check are clean.
    • Vitest src/bundled/channels: 61 files, 1097 tests passed.
    • profiles.spec.mjs and solo-workspace.spec.mjs in full, WebKit and Chromium, --workers=4 --retries=0: 48/48 passed.

A green run doesn't prove the flake is gone. Hosted CI covers broad validation.

Coordination

🤖 Generated with Claude Code

@matt2e
matt2e requested a review from a team as a code owner November 5, 2026 02:48
## Problem

Two WebKit journeys lost a Profile that was reopened right after its
tab closed. Both failed on attempt 1 on unrelated branches, and the same
SHA passed on rerun.

- `profiles.spec.mjs:321` "profile plumbing: exact avatar/mention
  targets, thread enrichment, lifecycle and recovery", branch
  peon/participant-collator, run 41752442750 (webkit 13/14),
  https://github.com/block/buzz-app/actions/runs/41752442750/job/126320885261
  The test closed the Mic tab and pressed Space on the focused mention.
  The Profile never reopened, and the test failed at :479,
  `expect(key).toHaveText(npubs.mic)`.
- `solo-workspace.spec.mjs:159` "Solo chat Activity opens the agent's
  profile locally, restores per conversation and retires on plugin
  disable", branch peon/dnd-stable-transform, run 41757728323 attempt 1
  (webkit 5/14),
  https://github.com/block/buzz-app/actions/runs/41757728323/job/126338025674
  The test closed the agent's Profile and pressed Enter on the focused
  Activity trigger. "Profile details" never appeared. Since #1729 on
  main, Enter opens the "Working now" popover, and its "View … activity"
  row opens the Profile through the same `openLink` path.

## Cause

Closing a routed panel tab removes it locally. `dismissContent` then
calls `navigator.dismiss`. The browser driver retires the visit and
calls `history.go(-1)`, and that return lands on a later popstate.
Until it lands, the navigator still shows the Profile visit that is
being retired. `afterClose` focuses the opener synchronously, so the
opener can be activated within that window.

The reopen runs `open()` and then `visitPanel`. `visitPanel` sees the
navigator already on that Profile's detail and returns without
navigating, so the panel opens only locally. When the return lands,
the route effect enters the channel visit. `enterVisit` restores the
channel visit's saved selection, and that hides the reopened panel. A
reopen of a different panel fares no better. Its visit is pushed on top
of the retiring one, where the late return removes it.

Both traces show this sequence. In the profiles run the close click
finished at 80128 ms. Space ran at 80148-80193 while the URL still held
the Mic detail. By 80383 the URL was the channel and the dock was
closing again. In the Solo run the close finished at 251783. Enter ran
at 251808-251872 while the URL still held the agent's activity detail.
By 251999 the URL was the channel and the dock was hidden.

The dock is not the cause. `PanelDock` keeps a reopened subtree through
its exit (`PanelDock.test.tsx`), and `inert` covers only the dock, not
the openers. Its exit motion just overlaps the pending return.

## Fix

A reopen before the return lands now supersedes the close:

- `dismissContent` records a pending return when its dismissal retires
  the current visit and the navigator hasn't moved yet. A synchronous
  driver or a replacing dismissal moves it at once, so nothing is
  recorded for them.
- While that return is pending, `visitPanel` doesn't navigate. It
  records the panel as the reopen.
- When the return lands on the dismissal's fallback, the route effect
  checks whether that panel is still selected. If it is, the effect
  reselects it over the restored selection and opens it, which gives it
  its own visit. If it has closed again, or the return landed somewhere
  else, nothing is revived.
- A later close before the return lands, such as closing the thread
  beneath a closed Profile, retires more visits and moves where the
  return lands. The record follows the latest fallback and keeps the
  reopen.
- While its return is pending, the route effect no longer reapplies the
  retiring visit's detail. A re-render in that window would otherwise
  reopen the panel that was just closed, and the deferral would then
  keep it. `open` changes identity on a close whenever a panel sits in
  the main pane, so such a re-render can happen.

Thread and other explicit navigation during a pending return are not
deferred. #2113 lists that as a remaining limit. #2113's close-focus
handoff doesn't overlap these hunks, and its head merges cleanly with
this change.

Two cases in `profiles.spec.mjs` hold `history.go` and pause the dock's
exit while a Profile closes. One reopens Mic by click and by Space. The
other closes a thread's Profile and then the thread, and opens Mic.
Each case asserts that:

- the Profile shows while the return is held
- the navigator stays on the closed visit and exactly one return is held
- a fresh Mic visit lands after the release
- Back returns to the channel and closes the panel

The journeys are unchanged, and no assertions are loosened.

## Validation

Local Linux workstation, Playwright 1.63.0, `--no-deps --retries=0`:

- The new cases without the fix: 4/4 failed. The reopen case failed
  at :406 (no fresh visit). The thread case failed at :442: the click
  stacked a visit on the closed one while the return was held. With the
  fix but without the latest-fallback step, the thread case failed 2/2
  at :452. With the fix: all pass.
- Adversarial ordering, using temporary, uncommitted changes: both
  journeys delayed `history.go` calls from click or key handlers by
  250 ms, 6 repeats per engine. Without the fix: 9/24 failed. Seven
  were the CI signatures: profiles at the shifted :479 (3), and Solo's
  Profile never visible (4). Two were at the thread mention, where a
  thread opened while the delayed return was still pending, which is
  the non-panel limit above. With the fix: 24/24 passed, and 24/24
  again on the final code.
- Natural contention: both journeys pinned to 4 CPUs shared with 4 busy
  loops, 2 workers, 10 repeats per engine. With the fix: 40/40 passed.
  The unfixed code wasn't run under natural contention.
- Full `profiles.spec.mjs` and `solo-workspace.spec.mjs`, both
  engines: 48/48 passed.
- The 38 browser specs that close panel tabs, both engines, 12
  workers: 495 passed, 1 skipped, 42 failed. The failures were mostly
  `page.goto` and click timeouts while the test server had stopped
  serving. All 42 passed when rerun with 6 workers.
- Vitest for channels, panels, navigation and channel-navigation: 87
  files, 1539 tests. Five files timed out while sharing the machine
  with that browser run, and all five (248 tests) passed when rerun
  alone.
- `tsc --noEmit`, `biome check` and `git diff --check` are clean.

A green run doesn't prove the flake is gone. Hosted CI covers broad
validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: mtoohey <mtoohey@squareup.com>
(cherry picked from commit 503f82aba732070adbc133398f5c6f83735d9a53)
@matt2e
matt2e force-pushed the mtoohey/flaky-panel-reopen-during-close branch from a86ca65 to 3e0b48d Compare November 5, 2026 06:54
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