mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
fix(chat): coalesce composer onChange writes to stop typing lag (#67)
Typing in the chat composer became unusably laggy because `updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`. Two state writes inside one `onChange(of: text)` handler tripped SwiftUI's "action tried to update multiple times per frame" warning, and each redundant write forced a full body re-eval — visible as the slow-HID stalls and the main-thread layout churn the reporter captured in sampling. Two changes: - Compute the new selection up front and write only the deltas. Same semantics; no spurious mutations. - Short-circuit the whole handler when the user is composing normal text (no `/` prefix) and the menu is already hidden — the common case. Stops paying for `SlashCommandMenu.filter` on every keystroke of regular prose. - Replace `.onChange(of: commands.map(\.id))` with `.onChange(of: commands.count)`. The mapped form allocated a fresh `[String]` on every body re-eval; counting is one int read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -200,7 +200,12 @@ struct RichChatInputBar: View {
|
|||||||
.onChange(of: text) { _, _ in
|
.onChange(of: text) { _, _ in
|
||||||
updateMenuState()
|
updateMenuState()
|
||||||
}
|
}
|
||||||
.onChange(of: commands.map(\.id)) { _, _ in
|
// Watch `commands.count` rather than `commands.map(\.id)` — the
|
||||||
|
// mapped form allocates a fresh `[String]` on every body
|
||||||
|
// re-eval (i.e. every keystroke), which is wasted work even
|
||||||
|
// when the array compares equal. The count proxy fires when
|
||||||
|
// the agent advertises new commands.
|
||||||
|
.onChange(of: commands.count) { _, _ in
|
||||||
updateMenuState()
|
updateMenuState()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCompressSheet) {
|
.sheet(isPresented: $showCompressSheet) {
|
||||||
@@ -358,17 +363,37 @@ struct RichChatInputBar: View {
|
|||||||
|
|
||||||
private func updateMenuState() {
|
private func updateMenuState() {
|
||||||
let shouldShow = shouldShowMenu
|
let shouldShow = shouldShowMenu
|
||||||
|
|
||||||
|
// Common case: user is composing normal text and the menu is
|
||||||
|
// already hidden. Skip the filter computation + state writes
|
||||||
|
// entirely so onChange stays cheap. Without this guard typing
|
||||||
|
// recomputes `filteredCommands` on every keystroke even when
|
||||||
|
// the menu can't possibly appear.
|
||||||
|
guard shouldShow || showMenu else { return }
|
||||||
|
|
||||||
|
// Compute desired selection, then only write what changed.
|
||||||
|
// SwiftUI emits "onChange action tried to update multiple
|
||||||
|
// times per frame" when an onChange handler mutates more than
|
||||||
|
// one piece of state per frame; the warning correlates with
|
||||||
|
// unusable typing lag because each redundant write triggers
|
||||||
|
// another body re-eval.
|
||||||
|
let count = filteredCommands.count
|
||||||
|
let newSelection: Int
|
||||||
|
if count == 0 {
|
||||||
|
newSelection = 0
|
||||||
|
} else if selectedIndex >= count {
|
||||||
|
newSelection = count - 1
|
||||||
|
} else if selectedIndex < 0 {
|
||||||
|
newSelection = 0
|
||||||
|
} else {
|
||||||
|
newSelection = selectedIndex
|
||||||
|
}
|
||||||
|
|
||||||
if shouldShow != showMenu {
|
if shouldShow != showMenu {
|
||||||
showMenu = shouldShow
|
showMenu = shouldShow
|
||||||
}
|
}
|
||||||
// Re-clamp selection whenever the filtered list may have shrunk.
|
if newSelection != selectedIndex {
|
||||||
let count = filteredCommands.count
|
selectedIndex = newSelection
|
||||||
if count == 0 {
|
|
||||||
selectedIndex = 0
|
|
||||||
} else if selectedIndex >= count {
|
|
||||||
selectedIndex = count - 1
|
|
||||||
} else if selectedIndex < 0 {
|
|
||||||
selectedIndex = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user