vercel-labs / vercel-labs/native
macOS: view focus is inferred from input events, not observed — a page `autofocus` is never seen, focus-out lags a keystroke, and `windowDidResignKey` is unobserved
Nobody has claimed this yet.
- Dominant language
- Zig
- Stars
- 7.7k
- Forks
- 314
- Avg merge
- 5h
- Merged PRs (30d)
- 13
Description
macOS: view focus is discovered from the mouse and from nothing else. A page autofocus is never seen, focus-out is noticed one keystroke late, and a window losing key while the app stays active is invisible.
Three symptoms, one cause: appkit_host.m never observes the AppKit facts that would answer "who has focus". It infers them from input events instead.
The context these surfaced in is the layout the SDK now supports directly: a canvas, <terminal> elements, and a WebView in one window, with focus crossing between a WKWebView and the canvas constantly. The good news first, since it framed the investigation: cursor authority over a layered WebView, click-in/click-back focus, keystroke routing, and dead-key IME on all three surfaces all work correctly, and the cursor case works deliberately, with the rationale written in the source (resetCursorRects subtracting higher-layer occluders, setSurfaceCursor refusing to clobber a covered point). This issue is about the one seam that is inferred rather than observed.
1. Focus into a view has exactly one discovery path, and it is a mouse-down
src/platform/macos/appkit_host.m:9083-9106 — the whole mechanism:
self.viewFocusEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
(NSEventMaskLeftMouseDown | NSEventMaskRightMouseDown | NSEventMaskOtherMouseDown)
handler:^NSEvent *(NSEvent *event) {
...
NSView *hit = [content hitTest:point];
while (hit && ![hit isKindOfClass:[NativeSdkWebView class]]) hit = hit.superview;
if (![hit isKindOfClass:[NativeSdkWebView class]]) return event;
...
.kind = NATIVE_SDK_APPKIT_EVENT_VIEW_FOCUSED,
NATIVE_SDK_APPKIT_EVENT_VIEW_FOCUSED (appkit_host.h:36) is emitted from that block and nowhere else. So anything that moves focus into a WebView without a mouse-down is never seen by the runtime: a page autofocus, a JS .focus(), a restored form, a programmatic Runtime.focusView, a Tab. The runtime's model simply stays wrong until the user clicks.
2. Focus out is inferred from the next canvas input, so the model lags AppKit by one keystroke
The other half runs on a different mechanism: setFocusedView is called when the canvas receives its next pointer_down or key_down (src/runtime/gpu_surface_events.zig:240 and :274). Nothing observes the first responder actually changing.
Tabbing out of the page, measured:
| After | Runtime believes | AppKit's first responder |
|---|---|---|
| tab 1 | web focused | canvas — the page has already reported window.blur |
| tab 2 | canvas focused | canvas |
For exactly one event the runtime's focus model is wrong, and it self-corrects only because that next key event reaches the canvas and re-runs setFocusedView. Nothing visibly breaks in a simple layout, but anything an app renders from view.focused — a pane highlight, an active-pane border — will flicker, and an app that routed a command on view.focused would route that one command to the wrong pane.
3. windowDidResignKey is not observed anywhere
NSWindowDelegate's windowDidBecomeKey: is implemented (appkit_host.m:1095) and re-emits frame and resize. There is no windowDidResignKey: in the file. The only loss-of-focus signal is application-level — applicationDidBecomeActive: / applicationDidResignActive: (:9260, :9265, registered at :9245-9246).
So a window losing key while the app stays active — a second window, a panel, a sheet — is invisible to the app and to the rendering correction alike. The application-level half does work: with another app frontmost the canvas correctly re-renders unfocused, because setAppActive(false) → setViewKeyboardActive and canvas_widget_display.zig:237 gates the painted focus flag on keyboard_active and focused. It is the window-level half that has no observer.
(Related, and worth a doc line even if nothing changes: because that correction runs on keyboard_active rather than focused, view.focused stays true while another application is frontmost. Reading focused as "has the keyboard" is wrong; keyboard_active and focused is the real predicate. That is not obvious from the field name.)
The ask
These are one fix, not three: observe focus instead of inferring it.
- Emit
VIEW_FOCUSEDfrom a real first-responder observation —windowDidChangeFirstResponder/ a KVO on the window'sfirstResponder, orbecomeFirstResponder/resignFirstResponderon the hosted views — rather than from the mouse-down hit test. That fixes (1) and (2) together, and it makesRuntime.focusView's own effect observable, which the hit-test path structurally cannot. - Observe
NSWindowDidResignKeyNotificationbeside thewindowDidBecomeKey:that already exists, and route it to the samesetViewKeyboardActivepath that application deactivation uses.
The mouse-down monitor can stay as-is in the meantime — it is not wrong, it is just not the whole story.
Verified against ea98365, macOS 26.5.2.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/platform/macos/appkit_host.m at the view focus monitor and window delegate methods, then trace setFocusedView in src/runtime/gpu_surface_events.zig and the painted focus flag in canvas_widget_display.zig. Compare the existing application activation and windowDidBecomeKey paths with the requested first-responder and window-resignation observations. Done means programmatic, keyboard, and window focus changes update the runtime without waiting for input.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, objective-c, zig
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100