Skip to content

fix(desktop): hold a deep-linked message against bottom settles - #8742

Open
matt2e wants to merge 1 commit into
mainfrom
flakey-tests-deep-link-hold
Open

matt2e wants to merge 1 commit into
mainfrom
flakey-tests-deep-link-hold

Conversation

@matt2e

@matt2e matt2e commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A product fix for the most frequent unfixed desktop E2E flake, message-target-highlight.spec.ts:79. A deep-linked message now holds its place against bottom settles until the reader scrolls. This is one of six PRs split from the reviewed flakey-tests branch (siblings below).

Cause

The failure videos (run 40983029157, Smoke shard 6) show that the thread panel opening is a precondition, not the trigger. A later floor report moved the target: TimelineMessageList's onScroll reported within 32 px of the bottom, during or after the older-page load that only a mounted[2] target sets off. onVirtualizerAtBottomStateChange(true) reset the anchor to at-bottom. Then the split-panel settle, the viewport-resize settle or the next append settle pinned the timeline to the floor. Which Virtua layout pass emits the floor report is still unidentified.

Change

Accepting a virtual route target sets targetHoldRef in useAnchoredScroll. Only the two targetMessageId paths set it, via holdTarget. The public scrollToMessage, which the oldest-unread pill and find-in-channel use, doesn't hold, and it replaces any earlier hold. While the hold is set:

  • The split-panel and viewport-resize settles are skipped.
  • A floor report still updates isAtBottom and the new-message count, but keeps the message anchor, so live appends don't settle either.
  • A commit that changes the newest message sets isAtBottom false and clears virtualizerAtBottomRef. It adds the rows after the previous newest one to the new-message count. So the "Jump to latest" / "N new messages" pill still appears for rows below the fold (Review-Proven Rule 6). A history page that fills the gap below a cold target raises no pill, and neither does deleting the newest message.
  • Viewport reflow re-centres the target through virtualScrollToMessage. It runs from the resize observer and when the split panel opens or closes.

The hold is released by reader scroll input: a wheel, a touch drag, a pointerdown on the scroller itself, or a scroll key. Those listeners already cancel bottom intent in useVirtualizedBottomSettle, and this PR shares them as listenForReaderScroll. Jump to latest, the reader's own send and a channel change also release it. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. onTargetReached does not release the hold.

Known limits

  • Programmatic scrolls (focus moves, scrollTop writes) don't release the hold, but a find or unread-pill jump replaces it. A resize after such a scroll re-centres the target.
  • ChannelPane's composer-height settle still calls scrollToBottom within 32 px of the floor, which releases the hold.
  • Live messages that arrive under a hold near the floor aren't followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count.
  • Under the hold, only rows after the previous newest message count. A same-second row that sorts just ahead of it raises no pill until a later row lands.
  • Agent review raised two optional gaps, and scrolling recovers from both:
    • If the previous newest message is deleted and a new row arrives in the same deferred commit, 0 rows are counted.
    • A thread reply sent with "also send to channel" doesn't arm the bottom intent, so it shows as "1 new message" instead of being followed.

Related issue

None found.

Testing

useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize and append a live message. It asserts no bottom settle, a re-centre on the panel opening and on the resize, and the pill on the append. Further cases cover:

  • each release path;
  • a find jump on a held timeline;
  • a descendant pointerdown keeping the hold;
  • a gap-fill page and deleting the newest message, neither of which raises the pill.

The split-panel and viewport-resize policy tables each gain a held row.

Mutations were run over the lifecycle file and both policy tables (29 tests, all pass intact). Each of the 20 mutations fails at least one test:

Mutation Tests that fail
Hold never set (the old behaviour) 12
Split-panel settle ignores the hold 13
Viewport-resize settle ignores the hold 13
Floor report resets a held anchor 12
No split-panel re-centre 12
No viewport-resize re-centre 12
Reader input doesn't release the hold 6
Held append raises no pill 2

The commit message lists the other 12, each failing 1 or 2 tests.

pnpm typecheck and pnpm check pass. All 46 tests in the files that import the touched modules pass.

Only hosted CI can show whether the spec now holds in Smoke shard 6 and in Flake Check's repeats, because Playwright was not run locally. It also has to show that scroll-history.spec.ts and the other timeline specs keep bottom-follow working.

Pushed with git push --no-verify, because the pre-push suites fail when the laptop is under load.

Readiness and human check

An agent review of the combined flakey-tests branch (b432453af) found no blockers. This changes desktop app behaviour, so it needs a human in-app check before buzz-review-completed is added. Suggested check, in just dev against a channel with a few pages of history:

  1. Open a message link (a mention or search hit) to a message several screens up, once in the same channel and once from another channel. The message should flash and stay on screen after older history loads, including when the thread panel opens or closes, or the window is resized.
  2. With the target still held, have someone post in the channel. The timeline stays put, and the pill reads "1 new message".
  3. Wheel-scroll to the bottom, or click Jump to latest. The next message that arrives is followed.

Siblings

🤖 Generated with Claude Code

message-target-highlight.spec.ts:79 is the most frequent unfixed
desktop flake. It failed in all 7 main runs whose SHA carries every
earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899,
40980404335, 40980513346, 40983029157), and in 26 jobs since those
fixes landed, on main and on PRs #8235, #8239, #8241, #8666, #8680,
attempts failed. Only the two cases that pick mounted[2] fail: "same
channel, row loaded off screen" (15 jobs) and "other channel, row
further up" (11 jobs). The target scrolls in and flashes, then the
timeline lands on the newest message, so `stillOnScreen` at :179 reads
false (twice it was `flashedOnScreen` at :153).

Cause

The triage note blamed the thread panel opening. The failure videos
from run 40983029157 (Smoke shard 6) show that is a precondition, not
the trigger:
- Other channel: the panel opens at +0.36 s, the target flashes at
  +0.7 s, the older page prepends by +1.04 s, the target is still on
  screen at +1.47 s, and the timeline sits at the exact floor by
  +1.97 s.
- Same channel: the panel and the flash land in the same frame. The
  snap follows about 0.5 s later, with the load-older spinner still
  showing.

The panel opening never moved the target. A later floor report did:
TimelineMessageList's onScroll reporting within 32 px of the bottom,
during or after the older-page load that only a mounted[2] target sets
off. onVirtualizerAtBottomStateChange(true) reset the anchor to
at-bottom and set virtualizerAtBottomRef. With the panel open, the
split-panel settle, the viewport-resize settle or the next append
settle then pinned the timeline to the floor. The exact-floor landing
points at our own settleAtBottom rather than restored scroll. Which
Virtua layout pass emits the floor report is still unidentified.

Change

Accepting a virtual route target now sets targetHoldRef in
useAnchoredScroll. Only the two targetMessageId paths set it, on first
mount and in the later target effect, by passing holdTarget to
scrollToMessage. The public scrollToMessage, which MessageTimeline's
oldest-unread pill and find-in-channel stepping call, doesn't hold. It
also replaces an earlier hold, so a find jump after a deep link
follows the floor as on main. While the hold is set:
- shouldSettleForSplitPanel and shouldSettleVirtualizedViewportResize
  return false;
- a floor report still updates isAtBottom and the new-message count,
  but keeps the message anchor, so live appends don't settle either;
- a commit that changes the newest message sets isAtBottom false,
  clears virtualizerAtBottomRef and adds the rows after the previous
  newest one to the new-message count. Virtua reports bottom state
  only from scroll events, and an append below the viewport emits
  none. Without this, a floor report left isAtBottom true, so
  MessageTimeline hid the Jump to latest / N new pill while rows piled
  up out of sight (Review-Proven Rule 6). A realistic trigger is a
  mention or search hit on one of the last few messages in a busy
  channel: scrollToIndex clamps at the floor and emits that report.
  Clearing virtualizerAtBottomRef means a later release doesn't snap
  past the new rows either. The hold only acts on an arrival, so a
  reader at the floor with nothing new sees no pill. Counting rows
  rather than the length change matters: a cold target is merged in
  older than the head window, and the history page that fills the gap
  keeps both ends of the list. classifyTimelineMessageDelta calls that
  an append, but nothing arrived below, so it raises no pill. Neither
  does deleting the newest message;
- viewport reflow re-centres the target through
  virtualScrollToMessage, from the viewport-resize observer and when
  the split panel opens or closes. Rows rewrap around the target
  without a scroll event, so the target can drift, and a floor report
  from before the reflow can keep isAtBottom true while the newest
  rows drop below the fold. The re-centring scroll refreshes the
  bottom state. Virtua's scrollToIndex cancels any earlier one still
  in flight, so a burst of resizes only restarts its measure loop.
  The split-panel call fires only on a transition, not on mount or
  when the virtualizer API registers.

The hold is released by reader scroll input (a wheel, a touch drag, a
pointerdown on the scroller itself, a scroll key aimed at it), Jump to
latest, the reader's own send, and a channel change. The input
listeners are the ones that already cancel bottom intent in
useVirtualizedBottomSettle, now shared as listenForReaderScroll. A
release at the floor restores the at-bottom anchor, so the timeline
follows live messages again. onTargetReached does not release it.

The artifacts never show the panel pushing the target off screen, so
skipping the settle is the fix aimed at the CI failure. The re-centring
keeps the target's place and the pill right after a reflow.

Known limits:
- Programmatic scrolls (focus moves, scrollTop writes) don't release
  the hold. A find or unread-pill jump replaces it. A resize after
  such a scroll (Tab focus into a lower row, say) re-centres the
  target, away from the focused row.
- ChannelPane's composer-height settle still calls scrollToBottom
  within 32 px of the floor, which releases the hold.
- Live messages that arrive while the hold is set near the floor are
  not followed until reader input, Jump to latest or the reader's own
  send. The pill shows them, with their count.
- Under the hold, only rows after the previous newest message count.
  A live message that sorts just ahead of it (a same-second timestamp)
  raises no pill until a later row lands.

Unit before/after

useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a
route target, clear it, open the split panel, report the floor, resize
the viewport and append a live message. It asserts no bottom settle,
and that the panel opening and the resize each re-centre the target.
Then it asserts the append shows the pill (isAtBottom false, one new
message). A scrollbar pointerdown after that releases the hold, and a
resize neither settles nor re-centres. One case per release path
asserts the resize settle resumes and the re-centring stops. One of
them is a find jump: a public scrollToMessage on a held timeline, then
a floor report. A descendant pointerdown (row interaction) keeps the
hold, and a resize still re-centres. Reader input at the floor resumes
following a live append. Under a hold, a history page filling the gap
below the target, and then deleting the newest message, leave
isAtBottom true and the count at 0. A commit that adds more history
and one live row counts 1. The split-panel and viewport-resize policy
tables gain a held row.

Mutations over the lifecycle file and both policy tables (29 tests,
all pass intact):
- hold never set on accept (the old behaviour): 12 fail, including
  the CI-shaped hold case
- split-panel settle ignores the hold: 13 fail
- viewport-resize settle ignores the hold: 13 fail
- floor report resets a held anchor: 12 fail (the reset anchor is
  never re-centred)
- reader input does not release: 6 fail
- Jump to latest, own send, channel change don't release: 1 each
- release skips the at-bottom resync: 1 fails
- a held append doesn't raise the pill: 2 fail
- a held append leaves virtualizerAtBottomRef set: 1 fails
- every scrollToMessage holds (this commit's first draft): 1 fails
- a public jump keeps an earlier hold: 1 fails
- the held-append branch as first drafted (any non-prepend growth,
  counted by length): 1 fails, the gap-fill case
- a newest-id gate but a length-delta count: 1 fails
- a missing previous newest counts every row: 1 fails
- no rowsBelow > 0 guard: 1 fails
- rows counted from the previous newest, off by one: 2 fail
- no split-panel re-centre: 12 fail
- no viewport-resize re-centre: 12 fail
- re-centre ignores the hold: 1 fails

pnpm typecheck and pnpm check pass. All 46 tests in the files that
import the touched modules pass.

Only hosted CI can show

Playwright was not run locally. Whether the spec now holds in Smoke
shard 6 and in Flake Check's repeats is unverified, and so is the
unidentified Virtua floor report. scroll-history.spec.ts and the
other timeline specs need the full desktop smoke run to show the
hold doesn't regress bottom-follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@github-actions

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 1eb67bb525d66448b56d79f98c4ba0e2188b44c5...a66580caee77265a1c3d6022d1e07366531764a0
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: LOW

One reliability regression can hide the new-message affordance after a deep link.

Findings

[LOW] Detect live arrivals when the newest message id stays unchanged

  • Category: Reliability
  • Location: desktop/src/features/messages/ui/useAnchoredScroll.ts:785 (source)
  • Description: After a held route target receives a bottom report, isAtBottom remains true while the hold prevents bottom settlement. Live messages are sorted by timestamp and reverse id, so a same-second arrival can be inserted before the existing newest message. The last id then stays unchanged, newLatestArrived is false, and this branch never updates the bottom state or unread count. MessageTimeline admits the row while semantically at bottom, leaving no buffered pending count to show the pill either.
  • Impact: New content can extend below the viewport while Jump to latest and the new-message count remain hidden until a later tail change or reader scroll.
  • Recommendation: Detect fresh live arrivals independently of the last message id while excluding history backfills. Add lifecycle coverage for a same-second insertion under a held target after a bottom report.

Notes

  • Review used read-only source and history inspection. Builds and tests were not executed.

Generated by Codex Security Review |
Requested by: @matt2e |
Workflow run

@jedwards27 jedwards27 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.

🤖 :bot: Jude’s code review agent — REQUEST CHANGES recommended for head a66580caee77265a1c3d6022d1e07366531764a0.

I found two author-actionable defects in the deep-link hold / bottom-settle behavior. I’m posting this as a review comment per the repository’s agent-review policy, but the verdict content is request-changes.

Findings

1. Space activation on focused row controls releases the deep-link hold

listenForReaderScroll treats the space key as reader scroll intent for any keydown whose target is inside the scroller, excluding only editable fields (desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts). The new held-target release path reuses that listener (desktop/src/features/messages/ui/useAnchoredScroll.ts). Message row actions such as Reply are ordinary focusable buttons inside the scroller (desktop/src/features/messages/ui/MessageActionBar.tsx, rendered from MessageRow.tsx).

Failure shape:

  1. A deep link opens an offscreen target and the PR holds that target in place.
  2. A virtualizer floor report arrives while held, so semantic bottom state can become true while the anchor is preserved only by the target hold.
  3. A keyboard user tabs to an in-row action like Reply and presses Space, which is normal button activation, not reader-scroll ownership.
  4. The window keydown listener releases targetHoldRef; with the floor ref already true, the next panel/open/append/resize can settle the timeline back to bottom and pull the deep-linked target away.

User impact: keyboard users can lose the exact message they deep-linked to by activating a row control with Space. Mouse activation does not take the same path unless the user actually interacts with the scroller background/scrollbar.

Author action: do not classify activation keys on interactive descendants as reader scroll. Ignore Space/activation semantics from activatable controls (button, a[href], [role="button"], menu/menuitem controls, inputs/selects/textareas/contenteditable, etc.) while preserving real scroll-key release when focus is on the scroller or non-interactive content. Add a regression for held target + floor report + Space on focused row action not releasing/recentering, and a positive case that PageUp/End on the scroller still releases.

2. Same-second live arrivals can be hidden with no recovery pill while a target is held

Timeline ordering permits a new same-second event to sort before the previous newest row while leaving the previous last id unchanged (desktop/src/shared/lib/eventOrder.ts, used by timeline sorts). useAnchoredScroll already documents that newLatestArrived misses that shape, but the new held-target pill restoration is gated on newLatestArrived && targetHoldRef.current, then counts only rows after the previous last id. A floor report while held can set isAtBottom(true) and clear the count while preserving only the held anchor. The jump/new-message pill renders only when the reader is not at bottom or the buffer has pending rows.

Failure shape:

  1. Reader opens a deep link; target is held.
  2. The bad floor report arrives, so state says “at bottom” while the target remains held.
  3. A same-second live row inserts below the held target but before the previous newest row.
  4. newLatestArrived is false, so the hold branch never flips isAtBottom back to false and never increments the new-message count.
  5. New content is below the held target, but there is no “Jump to latest” / new-message recovery affordance until a later tail-changing row or reader input.

A temporary lifecycle regression at the reviewed head reproduced this: after rendering held target state, adding [selected, same-second-live, latest] kept isAtBottom === true and left newMessageCount hidden. The probe was removed afterward.

Author action: do not gate held-target new-message affordance restoration on tail-id change alone. Count newly added live rows that render below the held anchor even when the previous last id remains last. If the hook’s current {id}-only message list cannot distinguish same-second live inserts from cold-history gap fills, pass the needed ordering/source metadata into this seam and add a lifecycle regression for this shape.

Verification / status

  • Live PR head/base before submission: a66580caee77265a1c3d6022d1e07366531764a0 / 1eb67bb525d66448b56d79f98c4ba0e2188b44c5.
  • Authenticated reviewer / PR author: jedwards27 / matt2e.
  • Exact-head CI was green/path-skipped at final refresh; these are source-level behavior defects, not CI-red findings.
  • Local/lane validation included targeted node tests for touched scroll modules, pnpm typecheck, pnpm check, pnpm test, pnpm build:e2e, and the Playwright mock message-target-highlight.spec.ts; those passing checks do not cover the two failure shapes above.

Verification owner: author for fixes and regressions; I can re-review once the head moves.

@jedwards27 jedwards27 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.

🤖 :bot: Jude’s code review agent review for PR #8742 at a66580caee77265a1c3d6022d1e07366531764a0

I found two author-actionable Medium defects. I’m posting this as a review comment per the repo’s agent-review policy, but my verdict is: do not merge until both are fixed. Dungeon says the scroll monster gets another phase.

Validation/context I checked at this head:

  • Base/head pinned: base 1eb67bb525d66448b56d79f98c4ba0e2188b44c5, head a66580caee77265a1c3d6022d1e07366531764a0.
  • PR checks currently have no failing or pending required results: 41 passing, 32 skipped; merge remains review-gated.
  • Review coverage traced the virtualized deep-link hold, bottom-settle/floor reports, timeline ordering, row controls, and existing desktop regression evidence. Existing green checks do not cover the two scenarios below.

Finding 1 — Medium: Space activation on focused row controls releases the held deep-link target

listenForReaderScroll treats " " as scroll intent for any keydown whose target is inside the scroller, excluding only editable controls (desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts:4-19, :44-55). The new held-target release path reuses that listener (desktop/src/features/messages/ui/useAnchoredScroll.ts:886-899).

That means a keyboard user who deep-links to an offscreen message can tab to an in-row control, such as the Reply button (desktop/src/features/messages/ui/MessageActionBar.tsx:598-607, rendered from MessageRow.tsx:569-608), and press Space for normal button activation. Before the button action runs, the window capture keydown listener classifies the Space press as reader scroll, clears targetHoldRef, and—if a prior virtualizer floor report set virtualizerAtBottomRef true—rewrites the anchor to at-bottom (useAnchoredScroll.ts:892-897; floor report path at :1009-1020). The next thread-panel open, resize, or append is then allowed to bottom-settle the timeline and pull the deep-linked target away again. Mouse activation does not take this same release path, so this is a modality-specific accessibility/product break.

Author action: don’t treat activation keys on interactive descendants as reader scroll. Ignore Space/activation semantics from activatable descendants (button, a[href], [role="button"], menu/menuitem controls, inputs/selects/textareas/contenteditable, etc.) while preserving real scroll-key release when focus is on the scroller or non-interactive content. Add a regression for: held target + floor report + Space on a focused row action does not release/recenter-to-bottom; PageUp/End on the scroller still releases.

Finding 2 — Medium: same-second live arrivals below a held target can be hidden with no recovery pill

Timeline ordering allows a new same-second event to insert before the previous newest row while leaving the last id unchanged (desktop/src/shared/lib/eventOrder.ts:14-25, used by desktop/src/features/messages/lib/messageQueryKeys.ts:34-37). The hook even documents that newLatestArrived misses this shape (desktop/src/features/messages/ui/useAnchoredScroll.ts:690-697).

The new held-target pill restoration is still gated on newLatestArrived && targetHoldRef.current (useAnchoredScroll.ts:785-800). A virtualizer floor report while held sets isAtBottom(true) and clears newMessageCount while preserving only the held anchor (useAnchoredScroll.ts:1009-1020). The scroll-to-latest affordance renders only when !isAtBottom || bufferedTimeline.pendingCount > 0 (desktop/src/features/messages/ui/MessageTimeline.tsx:883-913), and the buffer freezes the full list whenever it is told the reader is at bottom (desktop/src/features/messages/ui/useBufferedTimelineMessages.ts:80-83).

Failure scenario: deep link is held; the floor report marks the reader “at bottom”; then a live event with the same created_at second sorts between the held target and the previous latest row. Because the tail id did not change, the held-target restoration branch never runs, isAtBottom stays true, newMessageCount stays zero, pendingCount stays zero, and the new content below the held target has no “Jump to latest” / “1 new message” recovery affordance until some later event or reader input. That violates the recovery affordance this PR is trying to preserve.

Author action: do not gate held-target new-message affordance restoration on tail-id change alone. Count newly added live rows that render below the held anchor even when the previous last id remains last. If the current { id }-only seam cannot distinguish same-second live inserts from cold-history gap fills, pass the needed ordering/source metadata into this seam and add a lifecycle regression for this exact shape.

matt2e added a commit that referenced this pull request Oct 30, 2026
…rt (#8741)

## Summary

Two RunnerTests fixes from the 23–30 Oct flake audit, both in
`mobile/ios/RunnerTests/RunnerTests.swift`. Both are test-only. This is
one of six PRs split from the reviewed `flakey-tests` branch (siblings
below).

### `testLargeTitleTextFadesWithScrollAndCompactTitleCanReverse` (3
runs)

- **Runs:** 40748163526 (job 123028155856), 40826294064 (#8679, job
123290415194, passed on attempt 2) and 40921441156 (#8694, job
123617149320), all in Mobile (iOS), attempt 1. None of the branches
touches the navigation bar. Signature:
`XCTAssertLessThan(visibleOpacity(of: centeredTitle), 0.99)` read
0.9999499, 1.0 and 1.0.
- **Cause:** collapsing adds a 0.18 s `CABasicAnimation` on the compact
title mask's opacity (`IosNavigationBar.swift` `applyScroll`). The test
slept 60 ms and expected the presentation opacity to be mid-fade. The
presentation layer follows wall-clock media time. When the Flutter app
in the test host keeps the main thread busy, `Task.sleep` resumes after
the fade has finished.
- **Change:** drop the 60 ms sleep and the mid-fade samples. Right after
`scroll(to: boundary)`, which runs the handler synchronously, check that
the mask's model opacity is 1. Also check that it carries a
`"titleFade"` opacity `CABasicAnimation` from 0 to 1, lasting 0.1–0.5 s.
Nothing turns the run loop before the check, so the animation is still
attached however long the main thread stalls. After the scroll back, a
new `XCTAssertNil` checks that reversing removes the fade. The later 240
ms waits and their end-state checks are unchanged.
- **Mutations that should fail it:**
- Delete `compactTitleMask.add(fade, forKey: "titleFade")`: the
`XCTUnwrap` throws.
- Delete `removeAllAnimations()`: the new `XCTAssertNil` fails. The old
test missed this.
  - Set `fade.duration = 0.001`: the band's lower bound fails.

### `testNativeRemovalConfirmationUsesDestructiveAlertAndCancelsOnce` (1
run)

- **Run:** 40950548609 (#8433, Dart only, job 123717768224), attempt 1.
Signature: `fulfillment(of: [completed], timeout: 3)` expired.
- **Cause:** the busy test-host main thread that #8678 handled for other
classes. The cancel reply comes from UIKit's dismissal completion, after
the dismiss animation runs on that thread.
- **Change:** raise the wait to 10 s, matching #8678's
`callbackTimeout`. The assertion is unchanged. Dropping `resolve` from
the dismissal completion still fails it, and so does resolving before
`alert.dismiss`.

### Related issue

None found. **Conflicts with #8568** ("Adapt iPhone Duo layouts, rail
navigation, and native sheets"). #8568 rewrites the fade test into a
loop but keeps the 60 ms sleep and the mid-fade samples. It already
conflicts with `main`, and this PR adds `RunnerTests.swift` to its
conflicts. #8665 only adds a voice-note test.

### Testing

Not compiled against the app or run locally, because Xcode and
RunnerTests bog this machine down. `swiftc -parse` accepts the file. The
new assertions, copied into a stub `XCTestCase`, typecheck with `swiftc
-typecheck` against the iOS simulator SDK and XCTest (Swift 5 mode). The
full file needs Flutter and the Buzz module, so it wasn't typechecked as
a whole. **The Mobile (iOS) lane is the check.**

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 31, 2026
…kspace (#8744)

## Summary

Units 6–9 and 14 of the 23–30 Oct desktop Playwright flake audit, in two
commits. The specs now settle before they measure or act, and
remote-owned-mentions clears its draft reliably. Everything is
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### Unit 6: measuring before an animation finishes

Each spec now calls `waitForAnimations(page)` before measuring.

- `onboarding.spec.ts:1487` (#8715, Integration shard 1; the centring
check at :1545 receives 5.13): `showPage("owned")` slides content in
from `translateX(48px)` over 650 ms. The spec reads its three boxes in
separate `boundingBox` round trips, so they can come from different
points of the slide.
- `onboarding.spec.ts:2289` (main 40746677244 and 40925172215, and
#8666; the spacing check at :2485 receives 4.255): the avatar dialog has
a 200 ms zoom-in and a 250 ms height transition.
- `projects-v3-screenshots.spec.ts:280`, at :893 (main 39643531998,
40759209995 and 40969909906, plus four runs with fractional menu-edge
values): the context rail narrows from 380 to 280 px over 200 ms.

### Unit 7: `fill()` racing a caret move the app makes a frame later

- `mention-recipients.spec.ts:376` and `:97` (main 40969965981 and
40969858676): edit mode focuses the end of the composer in a
`requestAnimationFrame`. If that frame lands mid-`fill()`, the edit is
appended instead of replacing the text. **Change:** use #8682's
`replaceComposerText` at both call sites.
- `workflow-local-controls.spec.ts:151` (main 40926393897 and
40957616146, and #8239): `selectVariable` restores the caret in a
`requestAnimationFrame` after the spec's poll has passed. **Change:**
wait two animation frames before filling.

### Unit 8: clicks that hit the wrong element

- `profile.spec.ts:1128`, at :1543 (#8523): stacked Sonner success
toasts intercept the pointer. **Change:** a local `pressActionRow`
helper presses the five action rows with `toBeEnabled`, focus and Enter.
- `config-bridge-screenshots.spec.ts:314` (main 40967147375, and #8235):
`.last()` resolved while avatar fallbacks were still filling in on 200
ms timers, so it opened nadia's panel instead of Charlie's. **Change:**
pick Charlie's row by its text, and check the panel's name before
opening Runtime.

### Unit 9: the `createWorkflow` copies (preventive)

- `navigation.spec.ts:153` (7 failures on main, none since #8681; 14
first-attempt failures in Smoke shard 7): an unscoped
`getByLabel("Message text")` can fill the trigger's filter while
`AnimatePresence` keeps it mounted. **Change:** wait for the step
inspector, then fill inside it, as `workflows.spec` already does. The
wait now lives in one `addSendMessageStep` helper in
`tests/helpers/workflows.ts`, which `navigation.spec.ts` and
`workflows.spec.ts` (`createWorkflow`, :716, :820) all call.
- `workflow-reaction-picker.spec.ts:203-204` is left out. Its Reaction
Added trigger has no control labelled "Message text".

### Unit 14 (second commit): `remote-owned-mentions.spec.ts:566`

- **Run:** main 40998438103 (Smoke shard 9, job 123877347301). It passed
on retry and failed 1 of the 6 attempts it got in the window.
`sourceRecord()` at :673 returned thread A's draft instead of null.
- **Cause:** on ProseMirror, `fill("")` selects through the DOM, then
presses Delete. If ProseMirror hasn't seen the selection yet, it blocks
the Delete. Navigating to B then saves A's live text as its draft. This
is not a product bug: had the editor really been empty, the cleanup
would have cleared A's draft.
- **Change:** delete the text with one Backspace per character from the
end, then wait for the composer to read empty. The guard assertions are
unchanged.

### Related issue

None found. Sibling #8743 also edits `navigation.spec.ts`, in a separate
hunk. The two merge cleanly in either order. No open PR touches the
tests changed here. #6134, #8187, #5778, #8622, #7452, #7812, #8244 and
#3342 edit other tests in the same files.

### Testing

Not run locally, because Playwright bogs this machine down. Flake Check
will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass,
and biome's 3 warnings are in untouched files. Ad hoc `tsc` runs over
the touched specs and the helper found the same pre-existing errors as
on their `main` copies.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Oct 31, 2026
)

## Summary

Two CI-workflow fixes from the 23–30 Oct flake audit. This is one of six
PRs split from the reviewed `flakey-tests` branch (siblings below). Each
PR applies on its own to `main`.

### Hermit tool downloads (5 runs, 1 on main)

- **Runs:** 40635610550 and 40941660945, in Detect Changed Paths
(`node`). The selection step printed nothing for 9–10 min, the job hit
its cap, and every gate failed as cancelled. Three more, 40373404267 on
main (PostgreSQL Tests and Desktop E2E Integration 1/3, `pgschema`),
40373003333 (Mobile Android, `cmake`) and 40373467575 (Desktop Build
macOS, `just`), got 504s during GitHub's release-asset outage on 10-26,
~17:54–18:00 UTC.
- **Cause:** jobs start with an empty Hermit cache, so a tool's first
call downloads it, with no deadline. Hermit v0.54.1 tries three times, a
second apart, then gives up.
- **Change:** a new `scripts/ci-hermit-retry.sh` runs a no-op tool
command with a 90 s deadline per attempt, three attempts and 15 s × n
backoff. The deadline is plain bash: job control gives each attempt its
own process group, which gets TERM and then KILL. macOS runners have no
`timeout(1)` and may run bash 3.2. A "Fetch Hermit tools" step calls it
for:
- `node` and `just` in Detect Changed Paths, where one stall cancels
every gate. One `sh -c` invocation covers both, so the worst case is 345
s, inside the 10-minute cap. Two invocations would take 690 s.
- `pgschema` in PostgreSQL Tests, Desktop E2E Integration and Backend
Integration (via `--help`, since it has no `--version`).
  - `just` and `cmake` in the Android lane.
  - `just` in Desktop Build (macOS).

The test, `scripts/ci-hermit-retry.test.mjs`, joins Detect Changed
Paths' `node --test` list, and the script joins the rust and mobile path
filters. One stalled attempt costs ~105 s, which fits every job's cap.
- A killed attempt doesn't break the retry. Hermit downloads to a temp
file and unpacks to a temp dir, and renames each into place only once
it's complete. Its lock is a kernel flock.

### sccache storage failures (2 runs)

- **Runs:** 40355289475 (Desktop E2E Relay postgres-tests, 10-26,
"Egress is over the account limit") and 40714034945 (relay-binaries,
10-28, "send http request").
- **Cause:** the sccache server checks its GHA cache storage at startup
and won't start if that fails. Cargo's first `sccache rustc -vV` spawns
it, so the build fails. `SCCACHE_IGNORE_SERVER_IO_ERROR` doesn't help
here: in v0.16.0 it is read only when a running server's compile
response fails.
- **Change:** a "Start sccache" step runs `sccache --start-server`. It
outputs `wrapper=sccache`, or an empty wrapper and a warning if the
server won't start. The three build steps take `RUSTC_WRAPPER` from it,
and cargo treats an empty `RUSTC_WRAPPER` as no wrapper.
- The fallback doesn't fail the job later, in sccache-action's post
step. At the pinned fc920bf, that step runs `sccache --show-stats` and
the JSON form, and fails the job if either exits non-zero. With no
server running, v0.16.0's `--show-stats` doesn't start one. It returns
zero stats from the client side and exits 0. So the step keeps its stats
annotation, and `disable_annotations` stays unset.

### Related issue

None found. **Overlaps #8213** ("ci: fix empty root-workspace Rust
caches and merge test-unit nextest runs"). #8213 already conflicts with
`main`, and this PR adds a conflict in `.github/workflows/ci.yml` (path
filters).

### Testing

Verified locally:
- With a fresh Hermit state dir, `node` was SIGKILLed mid-download, then
mid-unpack while holding the lock, then stopped with TERM by the
script's own 1 s deadline. Each retry produced a working v24.15.0, at
clean-unpack speed. The `sh -c` form fetched both tools, and a 1 s
deadline on `sh -c 'sleep 37 && ...'` stopped `sh` and its child,
leaving no orphan. Tested under macOS bash 3.2 and Debian bash 5.2.
- sccache v0.16.0 (aarch64-apple-darwin) ran against a GHA cache whose
`ACTIONS_RESULTS_URL` refused connections, in READ_ONLY and READ_WRITE
modes. `--start-server` exited 2 ("Server startup failed: cache storage
failed to read"). Both `--show-stats` forms then exited 0, with zero
stats and valid JSON. `--stop-server` exited 2, which shows no server
had started.

Only hosted CI can show:
- the script under the runners' real shells, and that healthy runs only
pay a few seconds;
- whether ~3 min of spaced attempts rides out the next outage. The 10-26
outage lasted ~6 min, longer than this covers;
- that the explicit `--start-server` behaves like cargo's implicit start
against the real GHA cache. The fallback branch needs a storage outage
to run in CI.

Not covered here: first fetches of other tools (rustup, uv, pnpm) still
have no deadline. A per-job Hermit package cache is the follow-up.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
codex-review — a66580ca Deployed Oct 30, 2026 by matt2e via Run Codex Security Review #10900
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants