Skip to content

Infinite SwiftUI layout loop in TextSelectionBackground + GeometryReader + @Environment #26

Description

@justinthericoftime

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:

  1. TextSelectionBackground.body() uses .backgroundPreferenceValue(Text.LayoutKey.self) containing a GeometryReader
  2. The GeometryReader creates AppKitTextSelectionView(layout:origin:) on each evaluation
  3. AppKitTextSelectionView declares @Environment(TextSelectionModel.self) which subscribes to Observation tracking
  4. The observation subscription creates a new dependency in the AttributeGraph
  5. SwiftUI detects the dependency change → invalidates the subgraph → re-triggers the GeometryReader
  6. 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

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions