Skip to content

Defer keystroke-time focus refresh off the input event tap#320

Closed
FuJacob wants to merge 1 commit into
mainfrom
perf/input-lag-investigation
Closed

Defer keystroke-time focus refresh off the input event tap#320
FuJacob wants to merge 1 commit into
mainfrom
perf/input-lag-investigation

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 27, 2026

Summary

Typing lagged in large or Chromium-based text fields because Cotabby ran a full Accessibility snapshot resolve synchronously inside the CGEvent tap, before macOS delivered each keystroke. The InputMonitor tap calls handleInputEvent synchronously and every text mutation called focusModel.refreshNow() inline, so the resolve latency was added to each character. That cost grows with the field's text and AX subtree (per-candidate value reads, candidate-discovery and deep-geometry walks), which is why it showed up most when editing inside existing text. This defers the per-keystroke refresh off the tap so it never blocks keystroke delivery, while keeping the snapshot fresh for acceptance and generation.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build
# ** BUILD SUCCEEDED **

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build-for-testing
# ** TEST BUILD SUCCEEDED **

swiftlint lint --quiet Cotabby/App/Coordinators/SuggestionCoordinator.swift Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
# exit 0 (only a pre-existing line-length warning on an untouched log line; no new violations)

This is a timing/threading change with no pure-logic delta, so existing state-transition tests are unaffected and no test asserts the previous synchronous-refresh behavior. Runtime "feel" still wants a manual check by typing into a heavy field (e.g. a long Gmail draft) to confirm the lag is gone.

Linked issues

None.

Risk / rollout notes

  • Behavior change on the input critical path: the per-keystroke focus refresh now runs on the next main-actor turn (scheduleNonBlockingFocusRefresh) instead of synchronously inside the tap. I deferred it rather than deleting it specifically so the snapshot stays fresh for Tab-acceptance reconciliation, since acceptSuggestion reads focusModel.snapshot without its own refresh. Freshness is backstopped by the existing 80ms poll and the post-debounce refresh in generateFromCurrentFocus, which re-reads the snapshot anyway.
  • Coalesced via a single isFocusRefreshScheduled flag so continuous typing collapses to at most one pending resolve rather than a backlog.
  • Does not change the cost of the AX resolve itself (still run by the 80ms poll and post-debounce). A follow-up to cut that cost (cache candidate discovery in editableDescendantCandidates, avoid the double-resolve when the field signature changes per keystroke) is possible if this does not fully clear the lag in the heaviest fields.

Greptile Summary

This PR fixes keystroke input lag in large or Chromium-based text fields by deferring the per-keystroke Accessibility snapshot resolve off the synchronous CGEvent tap path. Previously, focusModel.refreshNow() ran inline inside the tap callback before macOS delivered each character, adding full AX-resolve latency to every keystroke.

  • Introduces scheduleNonBlockingFocusRefresh(), which dispatches the AX resolve to the next main-run-loop turn via DispatchQueue.main.async, replacing three direct refreshNow() call sites in SuggestionCoordinator+Input.swift.
  • Adds an isFocusRefreshScheduled coalescing flag on SuggestionCoordinator so continuous rapid typing collapses to at most one pending AX resolve rather than queuing a backlog.

Confidence Score: 4/5

Safe to merge; the deferred refresh reliably runs on the next main-loop iteration before any human Tab press, and the 80ms poll backstops freshness for acceptance.

The change is narrowly scoped to timing: the AX resolve still happens on the main actor, all state mutations remain single-threaded, and the coalescing flag is accessed only from @MainActor-isolated code. The two observations — DispatchQueue.main.async vs Task { @mainactor in … } for strict-concurrency hygiene, and the isFocusRefreshScheduled flag not being cleared in stop() — are both edge-case quality notes rather than present defects.

The stop() method in SuggestionCoordinator+Lifecycle.swift is worth a second look: it doesn't reset isFocusRefreshScheduled, so a rapid stop-then-restart cycle could leave the first post-restart keystroke's refresh silently coalesced away until the stale queued block drains.

Important Files Changed

Filename Overview
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Replaces three synchronous focusModel.refreshNow() calls with scheduleNonBlockingFocusRefresh(), and adds the new method that defers the AX resolve via DispatchQueue.main.async with a coalescing flag.
Cotabby/App/Coordinators/SuggestionCoordinator.swift Adds the isFocusRefreshScheduled: Bool coalescing flag; single-line addition with no other behavioral changes.

Sequence Diagram

sequenceDiagram
    participant macOS
    participant InputMonitor as InputMonitor (tap)
    participant Coordinator as SuggestionCoordinator
    participant MainQueue as Main Queue (async)
    participant FocusModel as FocusModel

    macOS->>InputMonitor: CGEvent tap fires (synchronous)
    InputMonitor->>Coordinator: handleInputEvent(event)
    alt event is acceptance (Tab)
        Coordinator->>Coordinator: acceptCurrentSuggestion()
        Coordinator->>FocusModel: reads snapshot (no refresh)
    else event.shouldSchedulePrediction (text mutation)
        Note over Coordinator: OLD: focusModel.refreshNow() inline — blocks keystroke delivery
        Coordinator->>Coordinator: scheduleNonBlockingFocusRefresh()
        alt "isFocusRefreshScheduled == false"
            Coordinator->>Coordinator: "isFocusRefreshScheduled = true"
            Coordinator->>MainQueue: DispatchQueue.main.async
        else "isFocusRefreshScheduled == true (coalesced)"
            Coordinator-->>Coordinator: guard return (skip duplicate)
        end
        Coordinator->>Coordinator: schedulePrediction()
    end
    Coordinator-->>InputMonitor: return
    InputMonitor-->>macOS: return (keystroke delivered instantly)
    macOS->>macOS: Delivers keystroke to focused app
    MainQueue->>Coordinator: async block fires (next run-loop turn)
    Coordinator->>Coordinator: "isFocusRefreshScheduled = false"
    Coordinator->>FocusModel: refreshNow() (AX resolve off tap path)
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Defer keystroke-time focus refresh off t..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

The CGEvent tap runs handleInputEvent synchronously before macOS delivers the keystroke, and every text mutation called focusModel.refreshNow() inline. That full AX snapshot resolve scales with the field's text and AX subtree (per-candidate value reads, candidate-discovery and deep-geometry walks), so typing into large or Chromium-based fields added felt latency to each character, worst when editing in the middle of existing text.

Defer the refresh to the next main-actor turn via scheduleNonBlockingFocusRefresh(), coalesced so fast typing can't queue a backlog of resolves. The keystroke is never blocked; the snapshot still refreshes before the user's next event (e.g. Tab acceptance) and before the post-debounce generateFromCurrentFocus, which re-reads it anyway. The 80ms poll remains the freshness backstop.
@FuJacob FuJacob closed this May 27, 2026
Comment on lines +173 to +179
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The flag is reset to false before refreshNow() is called, which is the right order for allowing re-coalescing. However, in Swift 6 strict-concurrency mode DispatchQueue.main.async closures are not automatically @MainActor-isolated, so the compiler may warn about accessing self.isFocusRefreshScheduled and self.focusModel from a non-isolated context. The existing schedulePostInsertionRefresh uses the same pattern, so this is consistent with the codebase, but annotating the closure or switching to Task { @MainActor [weak self] in … } would silence any strict-concurrency diagnostics if the project ever enables them.

Suggested change
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}
Task { @MainActor [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant