fix(engine): silence PERF logs by default, fix "modifying state during view update"

PerfTrace was opt-out (MD_PERF=0 to silence) so every Debug build
printed a PERF line per keystroke unconditionally. Flipped to opt-in
(MD_PERF=1 to enable) - still fully available for future perf work,
just quiet by default.

"Modifying state during view update": onCodeBlockSelectionChange,
onSelectedTextChange, and onCommentAnchorRectsChange could all fire
synchronously from inside NativeTextViewWrapper.updateNSView's own
call stack - a programmatic edit (pendingInlineReplacement/
pendingTextInsertion/pendingTextRangeReplacement) re-enters
textViewDidChangeSelection/textDidChange synchronously (AppKit resets
selection on edit), which is still a SwiftUI view update in progress.
Calling straight into the embedder's @State setter there is exactly
what trips the warning. Routed all three through new
fireCodeBlockSelectionChange/fireSelectedTextChange/
fireCommentAnchorRectsChange helpers on the coordinator that defer
one runloop tick via DispatchQueue.main.async - same technique
NativeTextViewWrapper already uses to clear its own pending*
bindings, just centralized instead of ad-hoc per call site.

322/322 tests passing.
This commit is contained in:
2026-08-21 00:56:37 +01:00
parent 5b876d7085
commit 1580510cfb
5 changed files with 30 additions and 8 deletions
@@ -9,7 +9,9 @@
// which costs grow with file size instead of staying constant. The whole point: // which costs grow with file size instead of staying constant. The whole point:
// type in a short file, then a long one, and compare `total` for the same edit. // type in a short file, then a long one, and compare `total` for the same edit.
// //
// Toggle: set the env var MD_PERF=0 in the run scheme to silence. // Toggle: set the env var MD_PERF=1 in the run scheme to enable.
// Off by default even in Debug opt-in, not opt-out, so a normal
// debug run stays quiet.
// Debug-only the whole thing compiles out in Release. // Debug-only the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites). // Remove before shipping (this file + the `PerfTrace.` call sites).
// //
@@ -18,7 +20,7 @@ import Foundation
enum PerfTrace { enum PerfTrace {
#if DEBUG #if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] == "1"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously /// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke periodic spikes that pollute the PERF /// on every 64th keystroke periodic spikes that pollute the PERF
@@ -15,7 +15,7 @@ import AppKit
extension NativeTextViewCoordinator { extension NativeTextViewCoordinator {
func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) {
guard let textContainer = textView.textContainer else { guard let textContainer = textView.textContainer else {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -24,7 +24,7 @@ extension NativeTextViewCoordinator {
// no per-call full-token filter. // no per-call full-token filter.
cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices
} else if cachedCodeBlockTokens.isEmpty { } else if cachedCodeBlockTokens.isEmpty {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -91,6 +91,6 @@ extension NativeTextViewCoordinator {
) )
} }
onCodeBlockSelectionChange?(selections) fireCodeBlockSelectionChange(selections)
} }
} }
@@ -16,7 +16,7 @@ import AppKit
extension NativeTextViewCoordinator { extension NativeTextViewCoordinator {
func updateCommentAnchorRects(textView: NSTextView) { func updateCommentAnchorRects(textView: NSTextView) {
guard !commentAnchorQueries.isEmpty else { guard !commentAnchorQueries.isEmpty else {
onCommentAnchorRectsChange?([]) fireCommentAnchorRectsChange([])
return return
} }
let nsText = textView.string as NSString let nsText = textView.string as NSString
@@ -28,6 +28,6 @@ extension NativeTextViewCoordinator {
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue } let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
results.append(CommentAnchorRect(id: query.id, rect: rect)) results.append(CommentAnchorRect(id: query.id, rect: rect))
} }
onCommentAnchorRectsChange?(results) fireCommentAnchorRectsChange(results)
} }
} }
@@ -362,7 +362,7 @@ extension NativeTextViewCoordinator {
// selection at all. // selection at all.
if !isRebuildingDocument { if !isRebuildingDocument {
let selRange = tv.selectedRange() let selRange = tv.selectedRange()
onSelectedTextChange?(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil) fireSelectedTextChange(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil)
} }
// Raw mode: plain source no reveal, snap-back, or inline previews. // Raw mode: plain source no reveal, snap-back, or inline previews.
if configuration.rawSourceMode { return } if configuration.rawSourceMode { return }
@@ -87,6 +87,26 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var onSelectedTextChange: ((String?) -> Void)? var onSelectedTextChange: ((String?) -> Void)?
var commentAnchorQueries: [CommentAnchorQuery] = [] var commentAnchorQueries: [CommentAnchorQuery] = []
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
/// These three callbacks can fire from inside `NativeTextViewWrapper.updateNSView`
/// itself (a programmatic edit re-enters `textViewDidChangeSelection`/
/// `textDidChange` synchronously see `isRebuildingDocument` above), which is a
/// SwiftUI view update already in progress on the call stack. Calling straight into
/// an embedder's `@State` setter there trips "Modifying state during view update"
/// deferring one runloop tick is enough to land outside it, same as how
/// `NativeTextViewWrapper` already clears its `pending*` bindings.
func fireCodeBlockSelectionChange(_ selections: [CodeBlockSelection]) {
DispatchQueue.main.async { [onCodeBlockSelectionChange] in onCodeBlockSelectionChange?(selections) }
}
func fireSelectedTextChange(_ text: String?) {
DispatchQueue.main.async { [onSelectedTextChange] in onSelectedTextChange?(text) }
}
func fireCommentAnchorRectsChange(_ rects: [CommentAnchorRect]) {
DispatchQueue.main.async { [onCommentAnchorRectsChange] in onCommentAnchorRectsChange?(rects) }
}
var didInitialFormatting: Bool = false var didInitialFormatting: Bool = false
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
var didEnsureLayoutForCurrentDocument: Bool = false var didEnsureLayoutForCurrentDocument: Bool = false