Keeping Keyboard Focus Honest Across Tabs and Split Panes in a WKWebView App
Overlay fakes a browser. The sidebar looks like tabs, a tab can be split into panes, and every pane is a WKWebView that I attach and detach as you move around. A real browser also tells each page whether it's being looked at and whether it has the keyboard, through visibilitychange, window focus / blur and document.hasFocus(). Pages lean on those more than you'd think: video players pause, chat apps decide whether to send a notification, keyboard shortcuts decide whether to fire.
I had never checked what Overlay actually tells pages. So one evening I sat down to find out, and it turned out the answer was "mostly the right thing about visibility, and a mess about focus".
A page that reports everything
Reading AppKit code tells you what you meant to happen. To see what happens, I wrote a small probe page. It listens for focus, blur, visibilitychange, pagehide, pageshow and keydown, and POSTs each one to a Python server on localhost the moment it fires. Once a second it also sends a heartbeat with document.visibilityState, document.hasFocus(), how many requestAnimationFrame callbacks ran and how many ticks a 100 ms setInterval managed.
Then I opened three copies in Overlay: tab C, and a split tab with panes A and B. A script drove the app with synthetic clicks and key presses, wrote a marker line before each step, and pressed x after each step so I could see which page the keystroke actually landed on. Eighteen steps: click each pane, switch panes from the sidebar and from the pane badge, switch tabs, press ⌘L then Esc, switch to Finder and back, hide and show the panel, cover it with another window.
I ran the same sequence twice more in a bare harness, a minimal non-activating NSPanel with two web views and nothing else, once with and once without the isKeyWindow override the real panel has. That comparison ended up mattering.
Visibility was fine
This part matched Safari, and I didn't change it. Only panes actually on screen are visible; every pane of a split tab is visible at once. Background tabs are hidden, requestAnimationFrame stops completely and the 100 ms timer drops to about two ticks a second. Hiding the panel with the global shortcut flips pages to hidden within about 100 ms. Covering the panel completely with another window flips them too, a bit more slowly: somewhere between 0.15 s and 0.9 s in my runs, and those numbers include launching the helper that drew the covering window, so treat them as rough. pagehide and pageshow only fire on real navigations, and freeze / resume never fire because WebKit doesn't implement the Page Lifecycle API.
Focus was not
Three different failures showed up.
After switching to a tab from the sidebar, or with ⌘⇧], or after typing a URL into the new-tab page and pressing Return, the x went nowhere. No page got a keydown. You'd have to click into the page before space would scroll it.
In a split, clicking the badge on pane B moved the blue highlight to B, but the keystroke still went into A.
And the strange one. Switch away from a split tab and back, and the pane that had focus before would fire focus and report hasFocus() === true, while the keyboard was not in it. Click the other pane after that and both pages reported hasFocus() === true at the same time.
Two causes
The first two are one bug. WKWebView becomes first responder when you click it, and at no other time. My content container rebuilds or re-highlights panes when the active tab changes, and it never touched the first responder. The highlight followed the model; the keyboard stayed wherever AppKit last left it, which after a rebuild is usually the window itself.
The third took the harness to understand. When a view that is currently first responder gets removeFromSuperview, AppKit does not call resignFirstResponder on it. WebKit never hears that it lost focus, keeps its "I'm focused" state, and when the view goes back into a window it re-announces focus. Nothing in AppKit agrees, so the next click elsewhere doesn't take focus away from it either. I could reproduce it in the bare harness with and without the isKeyWindow override, so it has nothing to do with that hack. It's what happens to any WKWebView detached while focused.
The fix
Three pieces, all small.
Before the container detaches anything, if the first responder lives inside the content area, I call makeFirstResponder(nil) so the web view resigns the normal way. After the rebuild, if that same view is still in the active pane, it gets focus back. That second step matters more than I expected: a middle-click that opens a link in a background tab also rebuilds the tree, and without the restore you'd lose your place in the form you were typing into.
When the active pane changes, focus moves to it. A JavaScript dialog sitting on top of the page wins over the page, so Esc and Return reach the dialog. The decision of whether to move focus at all is a pure function in the core package with a small truth table: if the first responder is already in the active pane, leave it; if it's in a text field outside the content area, like the address bar, leave it, because a page calling window.open can switch tabs while you're typing a URL; if a modal card is up, leave it; otherwise move it. Pages that aren't web views, like the new-tab page, manage their own focus, so they only get focus cleared when it's stuck in a different pane.
The address bar needed its own hook. Return and Esc both end editing with makeFirstResponder(nil), and on Return that happens asynchronously, on the next run loop turn, because the field editor has to be torn down first. My first version handed focus back to the page before that line ran, and it was cleared again straight away. The callback now fires after it.
None of this calls makeKey or activates the app. Overlay's whole premise is that summoning the panel doesn't steal your keyboard from the app you're in, and moving the first responder inside the panel doesn't change that. If the panel isn't the key window, nothing leaves it.
After the fix I ran the same eighteen steps on the new build. Every step had exactly one page with hasFocus() === true, and every x landed in the highlighted pane.
The one I left alone
The panel overrides isKeyWindow to return true whenever it's visible. It's there because WebKit drops mouse-move events for pages whose window isn't key, which kills :hover for a panel that normally isn't key. Measured earlier: zero mousemove events without the override, about 60 a second with it.
The cost is that while the panel is on screen, pages never learn you went to another app. No blur, hasFocus() stays true. For most sites that's harmless. For chat apps that use window focus to decide whether to notify you or mark messages read, it might mean a missed notification while you're working in your editor with Overlay open beside it. I haven't tested that site by site.
I did find a workable middle ground in the harness: report key only while the pointer is inside the panel, and when that flips, post didResignKey / didBecomeKey with the panel itself as the object. WebKit re-reads the state and sends blur or focus. Posting the same notification from an unrelated window does nothing. It relies on WebKit behavior nobody documents, and another part of Overlay listens for the panel's resign-key notification, so I'm holding it until someone actually reports a missed notification.
A new shortcut, and a storage bug it exposed
Since focus now follows the active pane, it was worth adding a way to move between panes from the keyboard: ⌘⌥] and ⌘⌥[, next to ⌘⇧] and ⌘⇧[ for tabs. Adding it turned up a small trap. Clearing a shortcut in settings used to delete its key from the settings file, so "key missing" meant "the user cleared it". Existing users' files obviously don't have the new keys, so the new shortcuts would have arrived already cleared. Clearing now writes an empty string, and a short list of actions added after launch fall back to their defaults when the key is missing. The original shortcuts still read a missing key as cleared, so someone who removed ⌘\ because it clashed with 1Password doesn't get it back after updating.
What this doesn't make Overlay
It's still WebKit with Safari's rules. Background tabs are throttled the way Safari throttles them, and there's no Page Lifecycle API. If a page depends on Chrome's freeze / resume semantics, a Chromium-based browser is the better host for it. The focus work only makes Overlay's own tabs and panes behave the way a browser's do. It doesn't make Overlay a browser.