From d7e8e12f84e8b7eefd38996a663331f9ee9d8792 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 15 Jun 2026 18:18:43 +0100 Subject: [PATCH] Fix state-during-view-update faults in text selection With text selection enabled, SwiftUI logged non-fatal faults on layout passes: onChange(of: Layout) action tried to update multiple times per frame. onChange(of: AnyTextLayoutCollection) action tried to update multiple times per frame. Modifying state during view update, this will cause undefined behavior. `AppKitTextSelectionView` stored selection rectangles in `@State` and seeded them from two `onChange(initial: true)` handlers (`selectedRange` and `layout`), both writing the same state during the initial view update. Derive the rectangles as an `@Observable`-tracked computed property instead: no `@State`, no `onChange`, no state mutation during the update, while still recomputing whenever the selected range or layout changes. `TextSelectionModel.setLayoutCollection` re-wrote the observed `selectedRange` during reconciliation even when the reconciled value was unchanged; as the overlay's GeometryReader/preferences converge this runs several times per frame. Only assign when the value actually changes. Fixes #22. Co-Authored-By: Claude Opus 4.8 --- .../AppKit/AppKitTextSelectionView.swift | 26 +++++++++++-------- .../Shared/TextSelectionModel.swift | 11 ++++++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift index 2c793a89..0b9adab8 100644 --- a/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift +++ b/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift @@ -11,7 +11,6 @@ struct AppKitTextSelectionView: View { @Environment(TextSelectionModel.self) private var textSelectionModel: TextSelectionModel? - @State private var selectionRects: [TextSelectionRect] = [] private let layout: Text.Layout private let origin: CGPoint @@ -22,7 +21,8 @@ } var body: some View { - Group { + let selectionRects = self.selectionRects + return Group { if selectionRects.isEmpty { Color.clear } else { @@ -37,18 +37,22 @@ } } } - .onChange(of: textSelectionModel?.selectedRange, initial: true, updateSelectionRects) - .onChange(of: layout, initial: true, updateSelectionRects) } - private func updateSelectionRects() { - if let textSelectionModel, - let selectedRange = textSelectionModel.selectedRange - { - selectionRects = textSelectionModel.selectionRects(for: selectedRange, layout: layout) - } else { - selectionRects = [] + /// Selection rectangles for the current range within this layout. + /// + /// Derived in `body` rather than stored in `@State` and seeded from an + /// `onChange(initial: true)`: that initial action wrote state during the first + /// view update, which SwiftUI flags ("Modifying state during view update", and + /// previously "tried to update multiple times per frame" when both the + /// `selectedRange` and `layout` handlers fired on the same frame). As an + /// `@Observable`-tracked computation it still recomputes whenever the selected + /// range or `layout` changes, with no state mutation. + private var selectionRects: [TextSelectionRect] { + guard let textSelectionModel, let selectedRange = textSelectionModel.selectedRange else { + return [] } + return textSelectionModel.selectionRects(for: selectedRange, layout: layout) } } #endif diff --git a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift index b7baae00..8881231d 100644 --- a/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift +++ b/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift @@ -60,11 +60,18 @@ return } - // Try to reconcile the selected text range - self.selectedRange = layoutCollection.reconcileRange( + // Try to reconcile the selected text range. Only write when it actually + // changes: as the overlay's GeometryReader/preferences converge, this runs + // several times per frame with the same result, and a redundant write to the + // observed `selectedRange` is what SwiftUI flags as "onChange(of: + // AnyTextLayoutCollection) action tried to update multiple times per frame". + let reconciled = layoutCollection.reconcileRange( selectedRange, from: oldLayoutCollection ) + if reconciled != self.selectedRange { + self.selectedRange = reconciled + } } func setCoordinator(_ coordinator: TextSelectionCoordinator?) {