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

Open
#225 0 comments 0 reactions 0 assignees View on GitHub

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_FOCUSED from a real first-responder observation — windowDidChangeFirstResponder / a KVO on the window's firstResponder, or becomeFirstResponder/resignFirstResponder on the hosted views — rather than from the mouse-down hit test. That fixes (1) and (2) together, and it makes Runtime.focusView's own effect observable, which the hit-test path structurally cannot.
  • Observe NSWindowDidResignKeyNotification beside the windowDidBecomeKey: that already exists, and route it to the same setViewKeyboardActive path 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.