Description
When the TextSelectionBackground modifier is active on macOS, a specific combination of GeometryReader, backgroundPreferenceValue, and @Environment(TextSelectionModel.self) in AppKitTextSelectionView can trigger an infinite SwiftUI layout invalidation loop that pegs one CPU core at 100% indefinitely.
I observed this in an app that embeds Textual for Markdown chat rendering. The process ran at 99.6% CPU for 3 days 18 hours before I caught it (37+ hours of accumulated CPU time). Memory remained stable at ~2GB — it's purely CPU-bound re-rendering.
Root cause analysis
Using macOS sample <pid> 5, 100% of 3,680 CPU samples were in this call chain:
CFRunLoop → SwiftUI flushObservers → NSHostingView.beginTransaction
→ Update.ensure → ViewGraphRootValueUpdater.updateGraph
→ GraphHost.flushTransactions → AG::Subgraph::update
→ AG::Graph::UpdateStack::update
→ GeometryReader.Child.updateValue ← layout trigger
→ TextSelectionBackground.body(content:) ← backgroundPreferenceValue
→ AppKitTextSelectionView.init(layout:origin:) ← creates new view
→ Environment.init(TextSelectionModel) ← invalidates layout → loop
The cycle:
TextSelectionBackground.body() uses .backgroundPreferenceValue(Text.LayoutKey.self) containing a GeometryReader
- The
GeometryReader creates AppKitTextSelectionView(layout:origin:) on each evaluation
AppKitTextSelectionView declares @Environment(TextSelectionModel.self) which subscribes to Observation tracking
- The observation subscription creates a new dependency in the AttributeGraph
- SwiftUI detects the dependency change → invalidates the subgraph → re-triggers the
GeometryReader
- Back to step 2 — infinite loop
Suggested fix direction
Move the @Environment(TextSelectionModel.self) read out of the GeometryReader to break the feedback cycle. For example, read it in TextSelectionBackground and pass it as a parameter to AppKitTextSelectionView:
struct TextSelectionBackground: ViewModifier {
@Environment(TextSelectionModel.self) private var textSelectionModel: TextSelectionModel?
func body(content: Content) -> some View {
content
.backgroundPreferenceValue(Text.LayoutKey.self) { value in
if let anchoredLayout = value.first {
GeometryReader { geometry in
AppKitTextSelectionView(
layout: anchoredLayout.layout,
origin: geometry[anchoredLayout.origin],
textSelectionModel: textSelectionModel // passed in, not read from @Environment
)
}
}
}
}
}
This eliminates the observation dependency inside the GeometryReader, breaking the invalidation cycle.
Relation to PR #14
PR #14 ("Reduce layout-cycle warnings in selection flow and table rendering") addressed related layout cycles in TextSelectionInteraction and TextSelectionModel by:
- Initializing
TextSelectionModel once (not lazily during layout)
- Marking
layoutCollection and coordinator as @ObservationIgnored
However, PR #14 did not modify TextSelectionBackground.swift or AppKitTextSelectionView.swift, leaving the GeometryReader + @Environment feedback loop intact.
Environment
- macOS 26.2 (Darwin 25.2.0, Apple Silicon)
- Swift 6.2
- Textual 0.3.1
- SwiftUI (via NSHostingView in a menu-bar app)
Checklist
Description
When the
TextSelectionBackgroundmodifier is active on macOS, a specific combination ofGeometryReader,backgroundPreferenceValue, and@Environment(TextSelectionModel.self)inAppKitTextSelectionViewcan trigger an infinite SwiftUI layout invalidation loop that pegs one CPU core at 100% indefinitely.I observed this in an app that embeds Textual for Markdown chat rendering. The process ran at 99.6% CPU for 3 days 18 hours before I caught it (37+ hours of accumulated CPU time). Memory remained stable at ~2GB — it's purely CPU-bound re-rendering.
Root cause analysis
Using macOS
sample <pid> 5, 100% of 3,680 CPU samples were in this call chain:The cycle:
TextSelectionBackground.body()uses.backgroundPreferenceValue(Text.LayoutKey.self)containing aGeometryReaderGeometryReadercreatesAppKitTextSelectionView(layout:origin:)on each evaluationAppKitTextSelectionViewdeclares@Environment(TextSelectionModel.self)which subscribes to Observation trackingGeometryReaderSuggested fix direction
Move the
@Environment(TextSelectionModel.self)read out of the GeometryReader to break the feedback cycle. For example, read it inTextSelectionBackgroundand pass it as a parameter toAppKitTextSelectionView:This eliminates the observation dependency inside the
GeometryReader, breaking the invalidation cycle.Relation to PR #14
PR #14 ("Reduce layout-cycle warnings in selection flow and table rendering") addressed related layout cycles in
TextSelectionInteractionandTextSelectionModelby:TextSelectionModelonce (not lazily during layout)layoutCollectionandcoordinatoras@ObservationIgnoredHowever, PR #14 did not modify
TextSelectionBackground.swiftorAppKitTextSelectionView.swift, leaving theGeometryReader+@Environmentfeedback loop intact.Environment
Checklist
mainbranch of this package.