From 35df86bfa54f18054e86dda8c1530fc5519bb549 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Wed, 4 Nov 2026 23:51:54 +0000 Subject: [PATCH 1/7] fix(messages): keep focus a person moved before a message reveal lands Opening a conversation at a message reveals that message by scrolling to its row and focusing it. The reveal waits for the thread to load. If the person clicks into the composer and starts typing first, the reveal still moves focus to the row once the thread is ready. Keys typed after that go to the row instead of the editor. In WebKit the move also clears the editor's selection, so text being inserted at that moment is dropped. The commit "test(inbox): wait for the entry reveal before filling an opened conversation" traced the WebKit flake in `inbox-archive.spec.mjs:658` (run 41752428902, https://github.com/block/buzz-app/actions/runs/41752428902/job/126320899490) to this. That commit made the test wait for the reveal and left the product behavior alone. `useMessageReveal` focuses the row the first time it reveals a request without checking where focus is. Only a later focus, after the row was replaced, checks first, and then only recovers focus that fell to the body. ThreadPanel's own focus handoff for a reopened thread already follows a "don't steal focus" rule ("Focus the person has since placed anywhere ... is not stolen"), but it runs when the request arrives. The reveal runs later, after the target loads. The user-visible rule: a reveal no longer takes focus from a control the person moved to after opening the conversation. The row is still scrolled into view, marked as the arrival, and the navigation still completes. Only the focus move is skipped. The hook now records, for each request, the focused element when the request arrives (the opener, or nothing once the opener unmounted) and whether the person presses a key or pointer after that. The first focus yields only when both hold: - focus is on something other than the body, the scroller or an element containing it (such as `main`), or the opener - the person pressed a key or pointer since the request arrived Otherwise the row takes focus as before. The later recovery focus is unchanged. The input condition is there because the app also hands off focus between the request and the reveal. Closing the search dialog focuses `main` or the conversation's composer (`conversationFocusTarget`), and closing Members returns focus to its trigger. The click that closes the dialog lands before the request arrives, so none of these count as the person moving focus. Without the condition, a reveal yielded to those handoffs. Journeys that expect the revealed row to take focus then failed (see Validation), and in a member channel the result would depend on whether the dialog's close finished before the thread loaded. The condition keeps those outcomes as they were. A click or Tab into the composer, or typing in a composer the app focused, still counts as the person's. `use-message-reveal.test.tsx` adds three cases: - with no interaction, the row takes focus - a composer the person focused by pointer or key before the reveal lands keeps focus, while the row is still scrolled into view, completed and marked - a control the app focused without input still loses focus to the row No browser test changes. The `revealed()` helper in `inbox-archive.spec.mjs` is unchanged and still waits for the row to take focus. Local Linux workstation (8-CPU quota), Playwright 1.63.0, `--no-deps --retries=0`: - `use-message-reveal.test.tsx`: 26/26 passed. Against the unfixed hook, both input cases fail, because the row takes focus from the composer. The other two new cases pass on both, since they pin behavior that must not change. - Audit, using a temporary, uncommitted `console.error` in the branch where the reveal yields. The `app` fixture fails a journey on any console error, so every journey that takes the new branch fails at teardown. - The rule without the input condition, the first 571 tests of the full Chromium suite: two journeys yielded, both to app handoffs, and both failed their row-focus assertion. In `global-search.spec.mjs:138` focus was on `main` after the search dialog closed. In `channel-members.spec.mjs:237` focus had returned to "Manage members" after the Members dialog closed. That led to the input condition. - The final rule, every browser spec that asserts focus on a message row plus the search, thread, Solo, inbox, channel-members and profiles specs (83 files), Chromium, 5 workers: 458 passed, 2 skipped and 1 failed, with no yields. The failure, `message-actions.spec.mjs:15` (`animated: false`), also fails 2/2 on this workstation without this change. - The final rule, both engines, 4 workers: `inbox-archive.spec.mjs` in full, plus `global-search`, `channel-members`, `thread-opening` and `navigation-thread-history`: 86/86 passed, with no yields. - The user-visible case, typing into the composer before the reveal lands, is covered by the unit cases. No browser journey was added for it. - Vitest for `src/features/messages`: 92 files, 2622 tests passed. - `tsc --noEmit`, `biome check --error-on-warnings` 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 Signed-off-by: mtoohey (cherry picked from commit 5a65e9c91766b005c0dd99e46ce0136e0a06181a) Signed-off-by: Matt Toohey --- .../messages/use-message-reveal.test.tsx | 52 ++++++++++++++++++- src/features/messages/use-message-reveal.ts | 49 ++++++++++++++--- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/features/messages/use-message-reveal.test.tsx b/src/features/messages/use-message-reveal.test.tsx index 883c43f56d..bfb5b353bc 100644 --- a/src/features/messages/use-message-reveal.test.tsx +++ b/src/features/messages/use-message-reveal.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { useRef } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import styles from "./Messages.module.css"; @@ -98,18 +104,21 @@ function mount( +