fix(focus): a browser page that has focus but not the keyboard takes the keys from its cmux window

Merged
LA
2d ago
+1588-163

Summary

Keys typed into a web page right after it loads from the address bar were lost (P0 browser-click-then-type; journey kit cp/reopen-video-kit@4ff1d5e5ce6). Typing "fieldkeysok" into a focused text field left "fok", and the page logged a click no one sent. Two causes, both fixed here:

  1. Link hints took the letters (the root cause, STATUS 7518 / 7543). After an address-bar load, Chromium reports a letter typed into the focused field as one no editable field took: CEF's focus_on_editable_field is stale (CEFShim/src/shim_client.mm:660; filed as #32573, owner PL-cef-tab). KeyRouter.routePageKey then runs the single-key page binding: f starts link hints. The session took the next letters as hint keys, picked the only target (the field) and clicked it; then o, k reached the field.
    Fix, for the two cases of f (lead review):
    • The page answers first. A link-hint session first asks the page whether a text field has the keyboard. It reads only the focused element (LinkHintSession.focusScript). The check looks through shadow roots and same-origin frames. It counts text inputs, a textarea, a select, contenteditable and designMode. A focused cross-origin frame counts as editing. The 500 ms bound covers this question only. While the page answers, every key typed is held (LinkHintPendingKeys), and a key-up waits behind its key-down.
    • A text field has the keyboard (case 1): every held key goes to the page once, in the order typed. The field shows the whole word.
    • No text field has the keyboard (case 2, link hints start): the held keys run through the hint session at once, and later keys are handled live. Only after that does the session collect the hint targets (collectScript, about 45 ms on Wikipedia). A hint letter never reaches the page. Escape ends the session. A key that is no hint key ends it, and it and every key after it go to the page. A click or a lost focus ends the session; the hint letters typed before it are dropped, as before this PR. This part is unchanged from before this PR: the dispatcher gives a plain f to the page first (KeyRouter.isPageKey), and cmux starts hints only from CEF's OnKeyEvent (CEFShim/src/shim_client.mm:644), which runs for a key the renderer did NOT consume. So a page shortcut on f that handles its key runs alone, and hints do not start.
    • No key lost, none doubled. If the page gives no answer within 500 ms, or the call fails, or the person clicks before the answer, every held key goes to the page once, in order. Chromium sends a key the page left unhandled back through NSApp.sendEvent. Such a key, which the page already had, is never a session's (LinkHintSeenKeys: a fixed ring of event time and key code; a synthetic key by its object). noteKeyDown looks up the CefNSWindow class on each key (objc_lookUpClass, no allocation), so the check works when Chromium loads after the first key. It returns at once for a key outside a child window; a key in a child window does one class lookup, and only a Chromium page window's key is noted. A key sent back is marked (LinkHintReplays), so no session takes it again.
    • LinkHintController moved from CmuxNextApp to CmuxNextBrowser (app_lines -132). Its words stay in the app (LinkHintStrings). Test seams (focusAnswer, targetsAnswer, send) let LinkHintControllerTests run the controller itself.
      Window hints keep the plain collectScript.
  2. The page window did not get the keyboard. cmux gives a Chromium page focus through CEF SetFocus(true) and relied on it to make the page's child window key. After an address-bar load it did not: the cmux window kept the keys with itself as first responder. Now CEFTab.grantFocus, the grant every path uses (direct, at browser attach, when the page shows), hands the keys from the cmux window that shows the page to the page window when CEF moved none. Still one key change per request (#24913); never from another window or an inactive app; a hidden page is never granted.

Why this layer: Chrome has one key window per browser window that forwards keys to the focused renderer. With in-process CEF the page is its own child window that must itself be key, and SetFocus is cmux's one call to make it so; cmux now checks that call's result where it makes it. With the Chromium Host (pages as views) there is no page window and nothing changes.

Cost on the typing path: none per key. The hand-over runs once per focus grant (one scan of the cmux window's child windows). The page script runs only when f/F starts link hints, never per key.

Testing

Fleet, package-test-lane.sh suite, 5 runs each, proof-step.py. A step counts only with "Test run with N tests", N > 0. Each red is the test before its fix:

Observed / Not run

  • Observed so far: the diagnostic round (STATUS 7518): the app's own key path (no CuaDriver) and CuaDriver lose the same keys on a fresh page; a restored page gets all of them; the page window is key and Chromium's view is its first responder in all cases. Base journey red on dogfood 44dc8431.
  • History of this PR: 9d7ac0a and 323d248 did not compile their test target (a mutating call inside #expect), and d17dbcb did not compile (a string literal); fixed in c62a2e4 and 5706562. v5 looped without end (keys sent back were held again); v6 doubled the first letters (a key the page already had came back through the intercept); v7 could drop a key typed before the session but dispatched after it. The first version (ChildPageFocus hand-over) covered only the direct grant (review FIX); 637d3fc did not compile (missing import AppKit); 1972dd8 did not compile (WindowHintController switch); a version that took no key before the labels showed sent hint letters to the page (lead review: a link-hints regression; reverted to holding them).
  • Known, not fixed here (review 6603845634, follow-up PR): a pick decided when the labels come is lost, and its labels stay, if a click or a new f lands during the labels' draw (a few ms); the bound can end a newer session in a very small window; a nested start from a delivered key during start's own cancel. The fuzz test does not cover the seen-key ring, key rollover across the answer, or clicks after the labels show.
  • Not run: a click into the page (CuaDriver cannot deliver a verified click into a page window on the minis without a screen-recording grant). The journey covers typing after focus only.
  • Cycle B of reopen-browser-plain-keys (keys typed before the restored page exists) may be a separate defect; it is reported per round.

Changelog

Fixed: Keys you type into a web page right after it loads from the address bar reach the page, also when the word starts with "f"

Proof

No capture: key routing; the proof is the tests and the journeys.