Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions clients/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ Prefer migrating the parent to `@Observable` so the bridge becomes unnecessary (
- **Lazy containers for large collections.** Use `LazyVStack`, `LazyHStack`, `LazyVGrid` instead of eager equivalents when the item count is unbounded or large. In particular, avoid `VStack`/`HStack` inside a `ScrollView` for large or unbounded data-driven lists — eager loading kills scroll performance. Small, fixed-size lists where visibility-sensitive logic (e.g., `onAppear` pagination triggers) matters may use eager containers intentionally.
- **Keep modifier chains lean.** Every SwiftUI modifier wraps its content in a new view value. Long chains of redundant or duplicated modifiers deepen the view tree and increase diffing cost. Group related modifiers, remove redundant ones (e.g., don't set `.font` on every row when the parent already sets it), and extract heavily-modified subtrees into standalone `@ViewBuilder` methods.
- **Scope observation narrowly.** Only observe the specific properties a view needs. Prefer granular `@Observable` properties or `withObservationTracking` over observing an entire store that publishes on unrelated changes.
- **Memoize `sizeThatFits` (and sibling measurement calls) in `NSViewRepresentable` views placed in lazy containers.** Any `NSViewRepresentable` that hosts a TextKit stack (`NSLayoutManager` + `NSTextContainer`) and can appear inside a `LazyVStack` / `LazyHStack` / `List` cell must cache its last measurement. [`NSLayoutManager.ensureLayout(for:)`](https://developer.apple.com/documentation/uikit/nslayoutmanager/ensurelayout(for:)) is O(glyph count) and runs synchronously on the main thread, and SwiftUI calls [`sizeThatFits(_:nsView:context:)`](https://developer.apple.com/documentation/swiftui/nsviewrepresentable/sizethatfits(_:nsview:context:)) on every visible cell on every layout pass (scroll, resize, streaming edit). Cache at minimum on `(textStorage.length, proposalWidth)`; for attribute-sensitive paths (static measurement helpers, highlighted content) key on the `NSAttributedString` itself and fall through to [`NSAttributedString.isEqual(_:)`](https://developer.apple.com/documentation/foundation/nsattributedstring/isequal(_:)) for identity — `NSAttributedString.hash` alone admits collisions and surfaces as wrong-height cells. Invalidate at every text-storage mutation site (text apply, edit callbacks, async highlight/formatting completions). Reference: `SelectableTextView`, `HighlightedTextView.CodeTextView`, `VCodeView.VCodeTextView`.
- **Enable non-contiguous layout on TextKit 1 stacks in `NSViewRepresentable`.** When constructing an `NSLayoutManager` for an `NSTextView` hosted via `NSViewRepresentable`, set `layoutManager.allowsNonContiguousLayout = true`. The default (`false`) forces full-document glyph layout from character 0 on the main thread whenever a glyph range is queried — which happens the first time the text view is attached to its hosting view (`setDocumentView:` / `addSubview:` → `_setSuperview:` → `setNeedsDisplayInRect:` → `_glyphRangeForBoundingRect:`). Reference: [`NSLayoutManager.allowsNonContiguousLayout`](https://developer.apple.com/documentation/appkit/nslayoutmanager/allowsnoncontiguouslayout).
- **Prefer line-count math over `ensureLayout(for:)` for `sizeThatFits` when line wrapping is disabled and line height is pinned.** If the TextKit 1 stack uses an unbounded-width `NSTextContainer` (so lines never wrap) and the paragraph style pins `minimumLineHeight == maximumLineHeight`, the total height is exactly `lineCount * lineHeight + insets` — no glyph layout required. This avoids an O(glyph count) main-thread layout pass that SwiftUI would otherwise re-run on every cell during `LazyVStack` layout. Reference: [`NSLayoutManager.defaultLineHeight(for:)`](https://developer.apple.com/documentation/appkit/nslayoutmanager/defaultlineheight(for:)).
- **Memoize `sizeThatFits` (and sibling measurement calls) in `NSViewRepresentable` views placed in lazy containers.** Any `NSViewRepresentable` that hosts a TextKit stack (`NSLayoutManager` + `NSTextContainer`) and can appear inside a `LazyVStack` / `LazyHStack` / `List` cell must cache its last measurement. [`NSLayoutManager.ensureLayout(for:)`](https://developer.apple.com/documentation/uikit/nslayoutmanager/ensurelayout(for:)) is O(glyph count) and runs synchronously on the main thread, and SwiftUI calls [`sizeThatFits(_:nsView:context:)`](https://developer.apple.com/documentation/swiftui/nsviewrepresentable/sizethatfits(_:nsview:context:)) on every visible cell on every layout pass (scroll, resize, streaming edit). Cache at minimum on `(textStorage.length, proposalWidth)`; for attribute-sensitive paths (static measurement helpers, highlighted content) key on the `NSAttributedString` itself and fall through to [`NSAttributedString.isEqual(_:)`](https://developer.apple.com/documentation/foundation/nsattributedstring/isequal(_:)) for identity — `NSAttributedString.hash` alone admits collisions and surfaces as wrong-height cells. Invalidate at every text-storage mutation site (text apply, edit callbacks, async highlight/formatting completions). Reference: `SelectableTextView`. When line wrapping is disabled and line height is pinned (as in `VCodeView.VCodeTextView` and `HighlightedTextView.CodeTextView`), prefer the line-count math above over a cached `ensureLayout` call.
<details>
<summary><strong>Per-entity observation and dictionary patterns</strong></summary>

Expand Down Expand Up @@ -448,7 +450,8 @@ Swift's type checker has quadratic complexity with chained view modifiers. Compl
| `Timer.publish` / `Timer.scheduledTimer` for UI updates | Timer continues firing when the view is off-screen, wastes energy, and requires manual lifecycle management (invalidate, cancellable storage) | Use `TimelineView(.periodic(from: .now, by: interval))` for fixed-rate progress displays or `TimelineView(.animation)` for frame-rate animations. TimelineView auto-pauses when the view is off-screen and requires no manual teardown. |
| `DispatchQueue.main.sync` from `@MainActor` or main thread | Deadlocks. Ref: [Apple — DispatchQueue.sync](https://developer.apple.com/documentation/dispatch/dispatchqueue/sync(execute:)-3gef0) | Use `Thread.isMainThread` guard, or `await MainActor.run {}` from async contexts. Prefer thread-safe APIs that don't need the main thread. |
| CPU-bound work inside `@MainActor` type without offloading | Blocks UI (JSON decode, image resize, compression). Ref: [WWDC25 — Embracing Swift concurrency](https://developer.apple.com/videos/play/wwdc2025/268/) | Offload the expensive call via `Task.detached`. Keep the type on `@MainActor`. See § "@MainActor Isolation Boundaries". |
| Unguarded `NSLayoutManager.ensureLayout(for:)` in `NSViewRepresentable.sizeThatFits` inside `LazyVStack`/`List` cells | SwiftUI calls `sizeThatFits` on every visible cell on every layout pass; `ensureLayout` is O(glyphs) on the main thread. Caches keyed only on text content (not width) silently skip on same-text-new-width queries, running layout again. | Cache last measurement on `(textStorage.length, proposalWidth)` at minimum; use `NSAttributedString.isEqual(_:)` when attributes matter. Invalidate at every text-storage mutation site. See § "View Bodies and Rendering". |
| Unguarded `NSLayoutManager.ensureLayout(for:)` in `NSViewRepresentable.sizeThatFits` inside `LazyVStack`/`List` cells | SwiftUI calls `sizeThatFits` on every visible cell on every layout pass; `ensureLayout` is O(glyphs) on the main thread. Caches keyed only on text content (not width) silently skip on same-text-new-width queries, running layout again. | Cache last measurement on `(textStorage.length, proposalWidth)` at minimum; use `NSAttributedString.isEqual(_:)` when attributes matter. Invalidate at every text-storage mutation site. When line wrapping is off and line height is pinned, use `lineCount * lineHeight + insets` directly. See § "View Bodies and Rendering". |
| Default `NSLayoutManager.allowsNonContiguousLayout` (`false`) on TextKit 1 stacks in `NSViewRepresentable` | The first time the `NSTextView` is attached to its hosting view, AppKit's `setNeedsDisplayInRect:` asks the layout manager for a glyph range, which forces full-document layout from character 0 on the main thread. Multi-second hangs on large documents. | Set `layoutManager.allowsNonContiguousLayout = true` when constructing the TextKit 1 stack. See § "View Bodies and Rendering". |

</details>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ struct ComposerTextEditor: NSViewRepresentable {
// Reference: https://developer.apple.com/documentation/appkit/nstextview/1449309-layoutmanager
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
// Confine glyph generation to the requested bounding rect so that
// attaching the text view to its scroll view does not force full
// document layout on the main thread.
// https://developer.apple.com/documentation/appkit/nslayoutmanager/allowsnoncontiguouslayout
layoutManager.allowsNonContiguousLayout = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: NSSize(
width: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ private struct CodeTextView: NSViewRepresentable {
// TextKit 1 stack — matches the gutter's NSLayoutManager.defaultLineHeight computation
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
// Confine glyph generation to the requested bounding rect so that
// attaching the text view to its scroll view does not force full
// document layout on the main thread.
// https://developer.apple.com/documentation/appkit/nslayoutmanager/allowsnoncontiguouslayout
layoutManager.allowsNonContiguousLayout = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: NSSize(
width: CGFloat.greatestFiniteMagnitude,
Expand Down Expand Up @@ -271,6 +276,7 @@ private struct CodeTextView: NSViewRepresentable {
textView.onEscape = onEscape
textView.onCommandF = onCommandF
textView.string = text
context.coordinator.lineCount = VCodeView.countLines(in: text)

let scrollView = VCodeHorizontalScrollView()
scrollView.documentView = textView
Expand All @@ -287,7 +293,7 @@ private struct CodeTextView: NSViewRepresentable {
context.coordinator.parent = self
guard let textView = scrollView.documentView as? CodeNSTextView else { return }
if textView.string != text {
context.coordinator.invalidateMeasurementCache()
context.coordinator.lineCount = VCodeView.countLines(in: text)
let selectedRanges = textView.selectedRanges
textView.string = text
let length = (text as NSString).length
Expand All @@ -310,33 +316,20 @@ private struct CodeTextView: NSViewRepresentable {
nsView: VCodeHorizontalScrollView,
context: Context
) -> CGSize? {
guard let textView = nsView.documentView as? CodeNSTextView,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer,
let textStorage = textView.textStorage else { return nil }
guard let textView = nsView.documentView as? CodeNSTextView else { return nil }

let width = proposal.width ?? 400

// SwiftUI calls `sizeThatFits` for every cell on every `LazyVStack`
// layout pass. Returning the cached size when
// `(textStorage.length, width)` matches the last measurement avoids
// rerunning `ensureLayout`, which is O(n) in glyph count.
// `updateNSView` invalidates on text replacement and `textDidChange`
// invalidates on user edits.
let coordinator = context.coordinator
let length = textStorage.length
if length == coordinator.lastMeasuredLength,
width == coordinator.lastMeasuredWidth {
return CGSize(width: width, height: coordinator.lastMeasuredHeight)
}

layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
let height = usedRect.height + textView.textContainerInset.height * 2

coordinator.lastMeasuredLength = length
coordinator.lastMeasuredWidth = width
coordinator.lastMeasuredHeight = height
// Height is derived directly from line count and the pinned
// per-line height, bypassing `NSLayoutManager.ensureLayout(for:)`.
// The text container is unbounded horizontally, so lines never
// wrap; `paragraphStyle.minimumLineHeight == maximumLineHeight`
// clamps every line to the same `defaultLineHeight`. That makes
// the geometry exactly `lineCount * lineHeight + insets`, and
// removes an O(glyph count) main-thread layout pass that SwiftUI
// would otherwise re-run on every cell during `LazyVStack` layout.
let height = CGFloat(context.coordinator.lineCount) * VCodeView.lineHeight
+ textView.textContainerInset.height * 2
return CGSize(width: width, height: height)
}

Expand All @@ -347,25 +340,17 @@ private struct CodeTextView: NSViewRepresentable {
class Coordinator: NSObject, NSTextViewDelegate {
var parent: CodeTextView

// Last successful `sizeThatFits` measurement. Invalidated whenever
// the text storage is replaced (`updateNSView`, `textDidChange`).
var lastMeasuredLength: Int = -1
var lastMeasuredWidth: CGFloat = -1
var lastMeasuredHeight: CGFloat = 0
// Newline count for the current text. Drives `sizeThatFits`
// geometry directly, avoiding `NSLayoutManager.ensureLayout(for:)`.
var lineCount: Int = 1

init(parent: CodeTextView) {
self.parent = parent
}

func invalidateMeasurementCache() {
lastMeasuredLength = -1
lastMeasuredWidth = -1
lastMeasuredHeight = 0
}

func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
invalidateMeasurementCache()
lineCount = VCodeView.countLines(in: textView.string)
parent.text = textView.string
parent.onTextChange?(textView.string)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ public struct VSelectableTextView: NSViewRepresentable {
// Reference: https://developer.apple.com/documentation/appkit/nstextview/1449309-layoutmanager
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
// Confine glyph generation to the requested bounding rect so that
// attaching the text view to its hosting view does not force full
// document layout on the main thread.
// https://developer.apple.com/documentation/appkit/nslayoutmanager/allowsnoncontiguouslayout
layoutManager.allowsNonContiguousLayout = true
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: NSSize(
width: 0,
Expand Down
Loading