Skip to content

perf(ios): confirm fill focus from the tapped input before the keyboard - #3906

Merged
thymikee merged 4 commits into
mainfrom
perf/ios-fill-runner-cost
Oct 22, 2026
Merged

thymikee merged 4 commits into
mainfrom
perf/ios-fill-runner-cost

Conversation

@thymikee

@thymikee thymikee commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

After a keyboard-down fill's focus tap, stabilization asked for the keyboard first. During presentation that query blocks and finds nothing: 0.57–3.77 s in 8 of 12 fills on perf run 39679264668.

Now the tapped input's ResolvedTextEntryInput answers first. When it reports hasKeyboardFocus, that read becomes the post-focus read: one snapshot gives focus, the refresh point, and the TextEntryReader's first input. Keyboard and app-wide checks stay as fallbacks; runner.log names the evidence. Builds on #3882; 5 runner files.

Validation

Main 4d5c38498c vs d9065c5ebd (production code of 954ce832cd), own iPhone 17 Pro / iOS 27.0 simulator, interleaved, ms median (p95):

Fill n runner post-tap focus
Test app field-name 6 2367 (2956) → 2370 (2988) 74 (85) → 57 (71)
Same, cold boot 6 2141 (3110) → 2019 (2216) 150 (650) → 40 (50)
Catalog search 6 2222 (2243) → 2228 (2296) 132 (137) → 122 (127)
Bluesky username 9 1780 (1808) → 1734 (1805) 54 (63) → 39 (42)

Main queried the keyboard after the tap in all 39 keyboard-down fills, clears included; head never.

  • 120 runner tests pass; the order test fails if the keyboard is asked first. Value reads: 17 (pin ≤ 18); 19 without the hand-off or reader seeding.
  • check:xctest-selection and check:affected --run --head 954ce832cd passed, exactCommit=true.
  • Unverified: secure-text and WKWebView inputs reporting their own focus (else fallback).

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

After a focus tap with no keyboard up, stabilization asked for the keyboard
first. While a software keyboard is presented, that query blocks and then
finds none: 0.68 s on an iPhone 17 Pro simulator and 0.57-3.77 s on CI,
before the app-wide focus query confirmed focus anyway and the refresh
point read the input again.

Read the tapped input first and accept its own hasKeyboardFocus, which is
stronger evidence than the keyboard's arrival; that read's frame serves the
refresh point. The keyboard and the app-wide focus query stay as fallbacks.
The runner log names the evidence that confirmed focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

Copy link
Copy Markdown
Member Author

Where a fill's runner time goes

Measured on my own iPhone 17 Pro / iOS 27.0 simulator (test app, warm), plus the runner log of CI perf run 39679264668 at main before #3882. Phase durations in ms come from AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE; sub-steps come from XCTest activity timestamps.

Phase Local CI What it is
Point lookup ~270 600–1,400 4 per-type queries + one snapshot per candidate
Focus tap ~410 ~600 one XCTest event, including its idle wait
Post-tap focus check 50–160; +680 cold 150–4,800 keyboard query blocked 570–3,770 ms in 8 of 12 CI fills; fixed here
Initial resolve 0 with #3882 400–900 removed by #3882
Clear (non-empty field) 1,250–1,340 1,700–2,200 caret-end tap ~450 + 24 delete keys ~650 + re-read
Type first char (warmup) 280–360 600–1,100 one typeText activity
Warmup read-back 60–125 270–400
Type the rest 350–470 500–830 ~17 ms per key locally
Verify 290–370 400–650 200 ms stability window + ~4 resolutions

There are no fixed sleeps beyond the 20 ms polls. Two kinds of cost remain:

  • XCTest event activities: 3 per fill into an empty field, 5 into a non-empty one.
  • Per-query app snapshots: 70–90 ms each on CI.

Considered and not shipped

  • Drop the warmup peel (one event, ~0.3 s local / ~0.7 s CI). It protects app-owned React Native values; no read-back proves it unnecessary.
  • Lower the 24-key delete floor (~0.2 s on short values). The constant has no recorded rationale, and an under-delete turns success into TEXT_ENTRY_MISMATCH.
  • Merge the caret-end tap into the focus tap (~0.45 s). It moves the focus tap point onto clear buttons and accessories.
  • Reuse the post-focus frame for the caret tap. It widens the stale-frame window during the keyboard-avoidance scroll.
  • Drop exists before snapshot(). XCTest's snapshot waits about 2 s on a missing element.
  • Select-all clear. Already rejected in docs/evidence/ios-fill-clear-latency-2026-10-19.json.

Pre-existing issue (both revisions)

fill '' on a non-empty field left a prefix (gr, grace) once in 6 runs. The caret-end tap landed mid-text. The fill reports TEXT_ENTRY_MISMATCH; it does not succeed silently.

Depends on #3882

Hand the read that confirmed focus to the entry as its post-focus read. On #3882's stack, that lowered the fixture's value-read count for a hidden-keyboard fill from 22 to 19, pinned by a test. This PR passes only that read's frame to the refresh point.

🤖 Generated with Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the focus-from-the-tapped-input idea. At fcc403f, the keyboard-down fill route is the right one to make common, but the fix needs one change before it can merge. keyboardFocusedInputFrame (RunnerTests+TextEntryReadiness.swift#L156) adds a second exists-then-snapshot read path. On main, #3882 (4d5c384) removed that pattern, and ResolvedTextEntryInput(reading:) now owns the post-focus read and hands it to TextEntryReader as postFocusRead. The new early return at TextEntryFocus.swift 159-166 builds the target without postFocusRead, so a straightforward conflict resolution makes TextEntryReader.input() resolve the input again. That is one extra full-app query (about 90 ms on Bluesky-class screens) on every keyboard-down fill, and it undoes part of the gain and part of #3882's read reuse. No test on main pins the read count for this branch, because testFillReadsEachResolvedFieldThroughOneSnapshot taps with the keyboard already up. The rule: the read that confirms focus is the post-focus read, and it has one owner. Have the input-focus check return ResolvedTextEntryInput(reading: target) when snapshotHasKeyboardFocus(read.snapshot) holds. Carry that value in TextEntryStabilization in place of inputFrame, and pass it through focusedTextEntryTarget(…, read:) so it feeds both refreshPoint and postFocusRead. Then delete keyboardFocusedInputFrame. A read-count assertion for the keyboard-down case (22 to 19, as your comment describes) would pin this.

Not blocking, and fine to take or leave: keyboardFocusConfirmed already snapshots the target as expected, so a miss on the new check snapshots the target twice per poll and one read could serve both; the five-case TextEntryFocusEvidence enum with inputFrame as a parallel optional could carry the read in the case payload (for example case inputReportsKeyboardFocus(ResolvedTextEntryInput)) so the two cannot disagree; testFillThatRaisesTheSoftwareKeyboardTypesAndReadsBack skips on the keyboard only after its assertions pass, and it would also pass without the fix, so moving the skip before the fill or asserting the evidence would make it a real regression test; and the device timings in the comments in TextEntryFocus.swift could stay in the PR body, which also says this is independent of #3882 even though they conflict.

Could the same result come from something smaller on main? In stabilizeTextInputBeforeTyping, read ResolvedTextEntryInput(reading: target) once per poll. If its snapshot has keyboard focus, return it as the stabilization's read. Otherwise pass the same snapshot to keyboardFocusConfirmed after the keyboard check. Then focusTextInputAt calls focusedTextEntryTarget(element, read:) for every confirmed exit, which removes keyboardFocusedInputFrame, inputFrame and the separate early return. This needs the rebase onto #3882 first.

All 6 checks pass at fcc403f, but the PR has merge conflicts with main, so CI has not run the combined code. The conflicts are in TextEntryFocus.swift and TextEntryLatencyTests.swift. Before merge, rebase onto main and make the focus-confirming read the postFocusRead. Then rerun a keyboard-down fill on an iOS simulator, for example the test app field-name and then the Bluesky logged-out username. runner.log should show AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=input-reports-keyboard-focus, with no keyboard query after the tap and no extra input resolution before the first post. The fixture value-read count must not exceed the base. I did not run the Swift tests or a simulator, and I have not verified whether secure-text and WKWebView inputs report keyboard focus on their own snapshot. A miss there only falls back to the old checks, so it is a performance question, not a correctness one.

Re-express the input-first focus confirmation in #3882's read model. The
tapped input's read is a ResolvedTextEntryInput; when its snapshot reports
keyboard focus, that same read gives the refresh point and seeds the
TextEntryReader as the focus step's post-focus read. keyboardFocusedInputFrame
and the frame-only refresh path are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.54 MB 5.54 MB +1.8 kB
Package (unpacked) 5.54 MB 5.54 MB +1.8 kB
Package (download) 1.68 MB 1.68 MB +321 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 42.7 ms 43.2 ms +0.5 ms
CLI --help 85.9 ms 88.5 ms +2.6 ms

@thymikee

Copy link
Copy Markdown
Member Author

The read-reuse defect from the earlier review (#3906 (comment)) is now fixed in the merge: the keyboard-down early return hands the confirming read on as the post-focus read at TextEntryFocus.swift:153-154. Two things remain before merge at 3244fec. All 16 checks pass, and I found no other code problem in the diff, which only touches iOS runner Swift.

No test fails if that hand-off is dropped. The new stabilization test only checks that stabilized.focusRead is populated, and testFillThatRaisesTheSoftwareKeyboardTypesAndReadsBack asserts the route and value, not the read count (see RunnerTests+TextEntryLatencyTests.swift:98). If someone removes if let read = stabilized.focusRead { ... }, the code falls through to focusedTextEntryTarget(element), which reads the input again, and every test stays green. That would bring back one extra full-app query (about 90 ms on Bluesky-class screens) per keyboard-down fill. The rule is that on every confirmed focus exit, the read that confirmed focus is the post-focus read, and focusTextInputAt is the one place that lists those exits. Please add a keyboard-down counterpart of testFillReadsEachResolvedFieldThroughOneSnapshot: launch with --agent-device-text-entry-counts-value-reads and the soft keyboard, keep the keyboard down, run the coordinate fill through executeTypeCommand, and assert the value-read count. It should pass at the measured count and fail with the branch removed.

The live validation is also out of date. The PR body table was measured at fcc403f against base e3ed027, before #3882 landed and before focusRead replaced inputFrame, so it does not cover the merged route. This is a device-facing change, so please run keyboard-down fills on an iOS simulator at 3244fec or later: the test app field-name, then the Bluesky logged-out username. Post the runner.log lines showing evidence=input-reports-keyboard-focus, no keyboard query after the tap, and no extra input read before the first type dispatch. Also post the head vs main runner-ms medians and the counted value reads, which should not exceed main. Then update the PR body validation to that head and drop "independent of #3882".

I did not run the Swift unit tests or a simulator. I also could not tell whether the keyboard-down tests ran or skipped in CI, because the simulator may keep the software keyboard down. It is not verified whether secure-text and WKWebView inputs report keyboard focus on their own snapshot. If they do not, they fall back to the old checks, which only costs performance. No conflicts are known.

thymikee and others added 2 commits October 22, 2026 13:22
The tapped input's read rides in TextEntryFocusEvidence.inputReportsKeyboardFocus,
so the evidence and the post-focus read cannot disagree. One read per poll
serves both the input's own focus check and the app-wide focus query's
expected snapshot. The keyboard-raising fill test skips before it asserts and
pins the fixture's value-read count at 18 (17 measured, 19 when the entry
resolves the input again). Device timings move from comments to the PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing the focus step's hand-off and dropping the read's seeding both
raise the fixture count from 17 to 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

thymikee commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

Both reviews are addressed at 954ce83: 3244fec merges main, d9065c5 applies the notes, and 954ce83 fixes one test comment.

Blocking: the focus-confirming read is the post-focus read. stabilizeTextInputBeforeTyping reads ResolvedTextEntryInput(reading: target) once per poll. When the read reports hasKeyboardFocus, it returns .inputReportsKeyboardFocus(read), and focusTextInputForTextEntry passes that read to focusedTextEntryTarget(_:read:requestedPoint:), which sets the refresh point and postFocusRead. keyboardFocusedInputFrame and inputFrame are deleted.

Read-count pin. testFillThatRaisesTheSoftwareKeyboardReusesTheReadThatConfirmedFocus keeps the keyboard down, fills through executeTypeCommand, and asserts at most 18 value reads.

  • Head logged 17 in 3 runs and passed a temporary ≤ 17 pin in 6 more. Main also logged 17, because its keyboard-appeared exit reads once and seeds the reader.
  • The test fails with 19 when your branch is removed, and also with 19 when the read does not seed the reader.
  • The keyboard-up pin testFillReadsEachResolvedFieldThroughOneSnapshot stays at 17.

Take-or-leave notes.

  • Taken: the read is the case payload, so the evidence and the read cannot disagree.
  • Taken: the poll's read is also keyboardFocusConfirmed's expected snapshot.
  • Taken: the fill test skips before it asserts.
  • Taken: the device timings moved from the code comments to the PR body.
  • Not taken: the fallback exits do not hand on the poll's read. That read predates the keyboard query, which can block for seconds while the layout moves, and the clear's caret tap uses the reader's first read. Those exits still read once after confirmation, as on main.

Live evidence. Measurements ran at d9065c5, whose production code matches 954ce83, on my own iPhone 17 Pro / iOS 27.0 simulator. The PR body has the full table. One Bluesky logged-out username fill at head shows the focus read, the evidence line, and then the first type dispatch:

t = 5.24s Tap Application 'xyz.blueskyweb.app' -> (213.0, 217.8)
t = 5.58s Checking existence of `"loginUsernameInput" TextField`
t = 5.60s Find the "loginUsernameInput" TextField
AGENT_DEVICE_RUNNER_TEXT_ENTRY_FOCUS evidence=input-reports-keyboard-focus
t = 5.62s Type 'a' into "loginUsernameInput" TextField
  • Head logged evidence=input-reports-keyboard-focus on all 39 keyboard-down fills (test app, cold boot, catalog, Bluesky) and never queried the keyboard after the tap. Main queried it on all 39.
  • Runner median, main vs head: field-name 2367 vs 2370 ms, Bluesky 1780 vs 1734 ms.
  • Secure-text and WKWebView inputs are still unverified. If they do not report focus on their own snapshot, they fall back to the old checks.

🤖 Generated with Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

This PR is ready at 954ce83. Both asks from the earlier review are done: the keyboard-down read-count pin now runs through executeTypeCommand, so a dropped hand-off breaks it, and the live evidence is refreshed at the production-identical d9065c5. All 16 checks pass at 954ce83, the last change is a test comment only, and there are no conflicts. Nothing else needs to happen before merge.

Not blocking, take or leave: the comment at RunnerTests+TextEntryLatencyTests.swift#L121 says the pin has "one read of headroom", but one ResolvedTextEntryInput(reading:) costs two counted queries (exists + snapshot), so the headroom is one query. If the keyboard appears before the field reports focus, the poll read is followed by a second read in focusedTextEntryTarget. That makes 19 and fails the <= 18 pin. Rewording the comment to say this, or skipping the pin when the logged evidence is not input-reports-keyboard-focus, would fix it.

I did not run the Swift unit tests or a simulator. The 19-reads claim comes from tracing the two counted queries, not from a mutation run. I can't tell whether CI's simulator keeps the software keyboard down; if it does not, both keyboard-down tests skip there and only local runs enforce the pin. The live timings and the 39-fill counts are from the author's own iPhone 17 Pro / iOS 27.0 simulator, and only one runner.log excerpt (Bluesky) is quoted. I have not verified that secure-text and WKWebView inputs report keyboard focus on their own snapshot. A miss there falls back to the old checks, so it costs only speed.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 22, 2026
@thymikee
thymikee merged commit f3d2619 into main Oct 22, 2026
16 checks passed
@thymikee
thymikee deleted the perf/ios-fill-runner-cost branch October 22, 2026 13:36
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-22 13:36 UTC

thymikee added a commit that referenced this pull request Oct 22, 2026
* origin/main:
  perf(ios): confirm fill focus from the tapped input before the keyboard (#3906)
thymikee added a commit that referenced this pull request Oct 22, 2026
* origin/main:
  perf(ios): confirm fill focus from the tapped input before the keyboard (#3906)
  feat(perf): time every iOS command against the fixture app (#3885)
  fix(ios): stop the XCTest-channel penalty from re-arming itself (#3896)
  test(android): pin that a child vanishing mid-read reads as gone (#3905)
  ci: skip device smoke lanes when a PR changes no lane input (#3904)
  fix(ios): verify deep-tree back on the sole screen (#3894)
  fix(ios): synthesize long presses and the gesture viewport on deep trees (#3893)
  perf(ios): read each resolved text input through one snapshot (#3882)
  fix(gate): take the host validation lock for heavy gates (#3900)
  fix(logs): keep an early iOS simulator logs stop from hanging the session (#3892)
  fix(apple): clean the runner derived path the runner uses (#3897)
  test(provider-scenarios): open deadline scenarios without the test IME (#3903)
  test(host-kit): stub os.homedir for the exec-trace late-close test (#3898)
  feat(test-app): add a Bluesky-depth heavy feed screen (#3884)
thymikee added a commit that referenced this pull request Oct 22, 2026
* test(ios): hold the fill read-count pin to the route it measures

The pin allowed 18 value reads for a fill whose focus tap raises the
software keyboard, calling the extra read headroom. One read costs two
counted queries, so the headroom was one query, and a keyboard that
arrives before the field reports focus legitimately makes 19. Skip the
pin unless runner.log records input-reports-keyboard-focus, and assert
the 17 that route makes.

Follow-up to #3906.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(ios): fail the fill pin on a missing focus line and skip multi-poll focus

Address the review of #3909. The focus step's log line now carries its poll
count, and the log marker and evidence name are shared with the test, so a
rename fails the test instead of skipping it. The pin skips unless the first
poll confirmed focus, where a second poll adds two queries by design.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant