Text Layout Invalidation
Use when text layout stays stale, metrics do not refresh, or the user needs the exact invalidation model in TextKit 1 or TextKit 2. Reach for this when the problem is layout recalculation and ensureLayout-style mechanics, not broader rendering or storage architecture.
Use when text layout stays stale, metrics do not refresh, or the user needs the exact invalidation model in TextKit 1 or TextKit 2. Reach for this when the problem is layout recalculation and ensureLayout-style mechanics, not broader rendering or storage architecture.
Family: TextKit Runtime And Layout
Use this skill when the main question is why text layout or rendering did not refresh when expected.
Keep this file for the invalidation model, forced layout, and comparison tables. For symptom-based debugging, symbolic breakpoints, profiling, and viewport controller deep patterns, use debugging-patterns.md.
When to Use
Section titled “When to Use”- Layout is stale after edits.
- You need to know what actually invalidates layout.
- You are comparing TextKit 1 and TextKit 2 invalidation behavior.
Quick Decision
Section titled “Quick Decision”- Need a symptom-first debugger ->
/skill apple-text-textkit-diag - Need the invalidation model itself -> stay here
- Need storage/editing lifecycle background ->
/skill apple-text-storage
Core Guidance
Section titled “Core Guidance”TextKit 1 Invalidation Model
Section titled “TextKit 1 Invalidation Model”What Invalidates Layout
Section titled “What Invalidates Layout”| Trigger | Invalidates | Automatic? |
|---|---|---|
| Character edit in NSTextStorage | Glyphs + layout in edited range | Yes (via processEditing) |
| Attribute change in NSTextStorage | Layout in changed range | Yes (via processEditing) |
| Text container size change | All layout in container | Yes |
| Exclusion path change | All layout in container | Yes |
invalidateGlyphs(forCharacterRange:) | Glyphs for range | Manual call |
invalidateLayout(forCharacterRange:) | Layout for range | Manual call |
Invalidation Flow
Section titled “Invalidation Flow”NSTextStorage edit → processEditing() → NSLayoutManager.processEditing(for:edited:range:changeInLength:invalidatedRange:) → Marks glyphs invalid in affected range → Marks layout invalid in affected range → Defers actual recomputation (lazy)Layout is rebuilt lazily — only when something queries the invalidated range (e.g., display, hit testing, rect calculation).
Forcing Layout (TextKit 1)
Section titled “Forcing Layout (TextKit 1)”let layoutManager: NSLayoutManager
// Entire container (expensive for large documents)layoutManager.ensureLayout(for: textContainer)
// Specific character rangelayoutManager.ensureLayout(forCharacterRange: range)
// Specific glyph rangelayoutManager.ensureLayout(forGlyphRange: glyphRange)
// Specific rect in container (most efficient for visible content)layoutManager.ensureLayout(forBoundingRect: visibleRect, in: textContainer)Forcing Glyph Generation
Section titled “Forcing Glyph Generation”layoutManager.ensureGlyphs(forCharacterRange: range)layoutManager.ensureGlyphs(forGlyphRange: glyphRange)Manual Invalidation
Section titled “Manual Invalidation”// Invalidate glyphs (forces regeneration)layoutManager.invalidateGlyphs( forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
// Invalidate layout only (keeps glyphs, re-lays out)layoutManager.invalidateLayout( forCharacterRange: range, actualCharacterRange: nil)
// Invalidate display (just redraw, no layout recalc)layoutManager.invalidateDisplay(forCharacterRange: range)layoutManager.invalidateDisplay(forGlyphRange: glyphRange)What Does NOT Invalidate Layout
Section titled “What Does NOT Invalidate Layout”- Setting temporary attributes (
setTemporaryAttributes) — visual only, no layout change - Reading layout information (bounding rects, line fragments) — read-only queries
- Changing the text view’s frame without changing the text container size
- Scrolling the text view
TextKit 2 Invalidation Model
Section titled “TextKit 2 Invalidation Model”What Invalidates Layout
Section titled “What Invalidates Layout”| Trigger | Invalidates | Automatic? |
|---|---|---|
Edit via performEditingTransaction | Elements + layout fragments | Yes |
invalidateLayout(for: NSTextRange) | Layout fragments in range | Manual call |
| Rendering attribute change | Visual only (no layout fragments) | Partial |
| Text container size change | All layout fragments | Yes |
Invalidation Flow
Section titled “Invalidation Flow”performEditingTransaction { textStorage.replaceCharacters(...)} → NSTextContentStorage regenerates affected NSTextParagraph elements → NSTextLayoutManager invalidates layout fragments for changed elements → NSTextViewportLayoutController re-layouts visible fragments → Delegate callbacks: willLayout → configureRenderingSurface × N → didLayoutForcing Layout (TextKit 2)
Section titled “Forcing Layout (TextKit 2)”let textLayoutManager: NSTextLayoutManager
// Ensure layout for a range (EXPENSIVE — avoid for large ranges)textLayoutManager.ensureLayout(for: textRange)
// Enumerate with layout guaranteetextLayoutManager.enumerateTextLayoutFragments( from: location, options: [.ensuresLayout]) { fragment in return true}
// Trigger viewport re-layout (preferred for visible content)textLayoutManager.textViewportLayoutController.layoutViewport()Manual Invalidation
Section titled “Manual Invalidation”// Invalidate layout for rangetextLayoutManager.invalidateLayout(for: textRange)
// Invalidate rendering (visual only, no layout recalc)textLayoutManager.invalidateRenderingAttributes(for: textRange)What Does NOT Invalidate Layout
Section titled “What Does NOT Invalidate Layout”- Setting rendering attributes — visual only overlay
- Reading layout fragments — read-only
- Scrolling (viewport controller handles this automatically)
TextKit 1 vs TextKit 2 Invalidation Comparison
Section titled “TextKit 1 vs TextKit 2 Invalidation Comparison”| Aspect | TextKit 1 | TextKit 2 |
|---|---|---|
| Scope | Can be full-document | Always viewport-scoped |
| Granularity | Glyph + layout | Element + fragment |
| Lazy | Yes (computed on query) | Yes (computed on viewport update) |
| ensureLayout cost | O(range_size) | O(range_size) — avoid for large ranges |
| Full-doc layout | ensureLayout(for: container) | Don’t do this — viewport only |
| Visual-only overlay | Temporary attributes | Rendering attributes |
| Overlay invalidates layout? | No | No |
| Edit wrapper | beginEditing()/endEditing() | performEditingTransaction { } |
What Rebuilds Text Storage
Section titled “What Rebuilds Text Storage”Text storage (NSTextStorage) is rebuilt when:
- Direct mutations —
replaceCharacters(in:with:),setAttributes(_:range:), etc. - Setting
text/attributedTexton text view — Replaces entire storage content - User typing — Inserts characters at cursor
- Paste/drop — Inserts attributed content
- Undo/redo — Restores previous state
Text storage is NOT rebuilt by:
- Layout invalidation (layout is separate from storage)
- Temporary/rendering attribute changes
- Container geometry changes
- Scrolling
What Rebuilds Text Elements (TextKit 2)
Section titled “What Rebuilds Text Elements (TextKit 2)”NSTextContentStorage regenerates NSTextParagraph elements when:
- Text storage edit within
performEditingTransaction— Affected paragraphs regenerated - Entire text storage replacement — All elements regenerated
Elements are NOT regenerated by:
invalidateLayout(for:)— Only layout fragments, not elements- Rendering attribute changes
- Viewport scrolling
Forcing a Complete Re-Render
Section titled “Forcing a Complete Re-Render”TextKit 1
Section titled “TextKit 1”// Nuclear option: invalidate everythinglet fullRange = NSRange(location: 0, length: textStorage.length)layoutManager.invalidateGlyphs(forCharacterRange: fullRange, changeInLength: 0, actualCharacterRange: nil)layoutManager.invalidateLayout(forCharacterRange: fullRange, actualCharacterRange: nil)
// Or trigger via text storage (preferred)textStorage.beginEditing()textStorage.edited(.editedAttributes, range: fullRange, changeInLength: 0)textStorage.endEditing()TextKit 2
Section titled “TextKit 2”// Invalidate all layouttextLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
// Then trigger viewport updatetextLayoutManager.textViewportLayoutController.layoutViewport()Common Patterns
Section titled “Common Patterns”Syntax Highlighting After Edit
Section titled “Syntax Highlighting After Edit”// TextKit 1: In NSTextStorageDelegatefunc textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { guard editedMask.contains(.editedCharacters) else { return } // Re-highlight affected range (extend to paragraph boundaries) let paragraphRange = (textStorage.string as NSString).paragraphRange(for: editedRange) highlightSyntax(in: paragraphRange, textStorage: textStorage)}Deferred Layout Update
Section titled “Deferred Layout Update”// TextKit 1: Don't query layout during editingtextStorage.beginEditing()// ... multiple edits ...textStorage.endEditing()// NOW it's safe to query layoutlet rect = layoutManager.usedRect(for: textContainer)Content Size Calculation
Section titled “Content Size Calculation”// TextKit 1layoutManager.ensureLayout(for: textContainer)let usedRect = layoutManager.usedRect(for: textContainer)let contentSize = CGSize(width: usedRect.width + textContainer.lineFragmentPadding * 2, height: usedRect.height + textView.textContainerInset.top + textView.textContainerInset.bottom)Common Pitfalls
Section titled “Common Pitfalls”- Querying layout during editing — Layout may not be valid between
beginEditing()andendEditing(). - Full-document ensureLayout in TextKit 2 — Defeats the viewport optimization. Only ensure layout for visible ranges.
- Expecting rendering attributes to invalidate layout — They don’t. They’re visual-only overlays.
- Not wrapping TextKit 2 edits in transaction — Direct NSTextStorage edits without
performEditingTransactionmay not trigger proper element regeneration. - Invalidating layout after every keystroke — Layout invalidation happens automatically through the text storage editing lifecycle. Manual invalidation is only needed for non-storage changes.
Going Deeper
Section titled “Going Deeper”Read debugging-patterns.md in this skill directory for:
- Symptom decision tree for stale layout diagnosis
- Symbolic breakpoints for invalidation tracking
- os_signpost instrumentation for profiling invalidation cost
- Viewport controller deep patterns (fragment recycling, scroll performance)
- Common bugs by symptom with fixes
Documentation Scope
Section titled “Documentation Scope”This page documents the apple-text-layout-invalidation router skill. The router maps broad Apple text questions to narrower specialist skills when the right subsystem is not obvious yet.
Related
Section titled “Related”apple-text-textkit-diag: Use when the user starts with a broken Apple text symptom such as stale layout, fallback, crashes in editing, rendering artifacts, missing Writing Tools, or large-document slowness. Reach for this when debugging misbehavior, not when reviewing code systematically or looking up APIs.apple-text-storage: Use when the user is working on NSTextStorage, NSTextContentStorage, or NSTextContentManager and needs the text-model architecture, subclassing rules, delegate hooks, or processEditing lifecycle. Reach for this when the storage layer is the focus, not general TextKit choice or symptom-first debugging.apple-text-textkit2-ref: Use when the user is already on TextKit 2 and needs exact NSTextLayoutManager, NSTextContentManager, NSTextContentStorage, viewport layout, fragment, rendering-attribute, or migration details. Reach for this when the stack choice is already made and the task is reference-level TextKit 2 mechanics, not stack selection or generic text-system debugging.
Sidecar Files
Section titled “Sidecar Files”skills/apple-text-layout-invalidation/debugging-patterns.md
Full SKILL.md source
---name: apple-text-layout-invalidationdescription: Use when text layout stays stale, metrics do not refresh, or the user needs the exact invalidation model in TextKit 1 or TextKit 2. Reach for this when the problem is layout recalculation and ensureLayout-style mechanics, not broader rendering or storage architecture.license: MIT---
# Text Layout Invalidation
Use this skill when the main question is why text layout or rendering did not refresh when expected.
Keep this file for the invalidation model, forced layout, and comparison tables. For symptom-based debugging, symbolic breakpoints, profiling, and viewport controller deep patterns, use [debugging-patterns.md](debugging-patterns.md).
## When to Use
- Layout is stale after edits.- You need to know what actually invalidates layout.- You are comparing TextKit 1 and TextKit 2 invalidation behavior.
## Quick Decision
- Need a symptom-first debugger -> `/skill apple-text-textkit-diag`- Need the invalidation model itself -> stay here- Need storage/editing lifecycle background -> `/skill apple-text-storage`
## Core Guidance
## TextKit 1 Invalidation Model
### What Invalidates Layout
| Trigger | Invalidates | Automatic? ||---------|------------|------------|| Character edit in NSTextStorage | Glyphs + layout in edited range | Yes (via processEditing) || Attribute change in NSTextStorage | Layout in changed range | Yes (via processEditing) || Text container size change | All layout in container | Yes || Exclusion path change | All layout in container | Yes || `invalidateGlyphs(forCharacterRange:)` | Glyphs for range | Manual call || `invalidateLayout(forCharacterRange:)` | Layout for range | Manual call |
### Invalidation Flow
```NSTextStorage edit → processEditing() → NSLayoutManager.processEditing(for:edited:range:changeInLength:invalidatedRange:) → Marks glyphs invalid in affected range → Marks layout invalid in affected range → Defers actual recomputation (lazy)```
**Layout is rebuilt lazily** — only when something queries the invalidated range (e.g., display, hit testing, rect calculation).
### Forcing Layout (TextKit 1)
```swiftlet layoutManager: NSLayoutManager
// Entire container (expensive for large documents)layoutManager.ensureLayout(for: textContainer)
// Specific character rangelayoutManager.ensureLayout(forCharacterRange: range)
// Specific glyph rangelayoutManager.ensureLayout(forGlyphRange: glyphRange)
// Specific rect in container (most efficient for visible content)layoutManager.ensureLayout(forBoundingRect: visibleRect, in: textContainer)```
### Forcing Glyph Generation
```swiftlayoutManager.ensureGlyphs(forCharacterRange: range)layoutManager.ensureGlyphs(forGlyphRange: glyphRange)```
### Manual Invalidation
```swift// Invalidate glyphs (forces regeneration)layoutManager.invalidateGlyphs( forCharacterRange: range, changeInLength: 0, actualCharacterRange: nil)
// Invalidate layout only (keeps glyphs, re-lays out)layoutManager.invalidateLayout( forCharacterRange: range, actualCharacterRange: nil)
// Invalidate display (just redraw, no layout recalc)layoutManager.invalidateDisplay(forCharacterRange: range)layoutManager.invalidateDisplay(forGlyphRange: glyphRange)```
### What Does NOT Invalidate Layout
- Setting temporary attributes (`setTemporaryAttributes`) — visual only, no layout change- Reading layout information (bounding rects, line fragments) — read-only queries- Changing the text view's frame without changing the text container size- Scrolling the text view
## TextKit 2 Invalidation Model
### What Invalidates Layout
| Trigger | Invalidates | Automatic? ||---------|------------|------------|| Edit via `performEditingTransaction` | Elements + layout fragments | Yes || `invalidateLayout(for: NSTextRange)` | Layout fragments in range | Manual call || Rendering attribute change | Visual only (no layout fragments) | Partial || Text container size change | All layout fragments | Yes |
### Invalidation Flow
```performEditingTransaction { textStorage.replaceCharacters(...)} → NSTextContentStorage regenerates affected NSTextParagraph elements → NSTextLayoutManager invalidates layout fragments for changed elements → NSTextViewportLayoutController re-layouts visible fragments → Delegate callbacks: willLayout → configureRenderingSurface × N → didLayout```
### Forcing Layout (TextKit 2)
```swiftlet textLayoutManager: NSTextLayoutManager
// Ensure layout for a range (EXPENSIVE — avoid for large ranges)textLayoutManager.ensureLayout(for: textRange)
// Enumerate with layout guaranteetextLayoutManager.enumerateTextLayoutFragments( from: location, options: [.ensuresLayout]) { fragment in return true}
// Trigger viewport re-layout (preferred for visible content)textLayoutManager.textViewportLayoutController.layoutViewport()```
### Manual Invalidation
```swift// Invalidate layout for rangetextLayoutManager.invalidateLayout(for: textRange)
// Invalidate rendering (visual only, no layout recalc)textLayoutManager.invalidateRenderingAttributes(for: textRange)```
### What Does NOT Invalidate Layout
- Setting rendering attributes — visual only overlay- Reading layout fragments — read-only- Scrolling (viewport controller handles this automatically)
## TextKit 1 vs TextKit 2 Invalidation Comparison
| Aspect | TextKit 1 | TextKit 2 ||--------|-----------|-----------|| **Scope** | Can be full-document | Always viewport-scoped || **Granularity** | Glyph + layout | Element + fragment || **Lazy** | Yes (computed on query) | Yes (computed on viewport update) || **ensureLayout cost** | O(range_size) | O(range_size) — avoid for large ranges || **Full-doc layout** | `ensureLayout(for: container)` | **Don't do this** — viewport only || **Visual-only overlay** | Temporary attributes | Rendering attributes || **Overlay invalidates layout?** | No | No || **Edit wrapper** | `beginEditing()`/`endEditing()` | `performEditingTransaction { }` |
## What Rebuilds Text Storage
Text storage (`NSTextStorage`) is rebuilt when:
1. **Direct mutations** — `replaceCharacters(in:with:)`, `setAttributes(_:range:)`, etc.2. **Setting `text`/`attributedText` on text view** — Replaces entire storage content3. **User typing** — Inserts characters at cursor4. **Paste/drop** — Inserts attributed content5. **Undo/redo** — Restores previous state
Text storage is **NOT** rebuilt by:- Layout invalidation (layout is separate from storage)- Temporary/rendering attribute changes- Container geometry changes- Scrolling
## What Rebuilds Text Elements (TextKit 2)
`NSTextContentStorage` regenerates `NSTextParagraph` elements when:
1. **Text storage edit within `performEditingTransaction`** — Affected paragraphs regenerated2. **Entire text storage replacement** — All elements regenerated
Elements are **NOT** regenerated by:- `invalidateLayout(for:)` — Only layout fragments, not elements- Rendering attribute changes- Viewport scrolling
## Forcing a Complete Re-Render
### TextKit 1
```swift// Nuclear option: invalidate everythinglet fullRange = NSRange(location: 0, length: textStorage.length)layoutManager.invalidateGlyphs(forCharacterRange: fullRange, changeInLength: 0, actualCharacterRange: nil)layoutManager.invalidateLayout(forCharacterRange: fullRange, actualCharacterRange: nil)
// Or trigger via text storage (preferred)textStorage.beginEditing()textStorage.edited(.editedAttributes, range: fullRange, changeInLength: 0)textStorage.endEditing()```
### TextKit 2
```swift// Invalidate all layouttextLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
// Then trigger viewport updatetextLayoutManager.textViewportLayoutController.layoutViewport()```
## Common Patterns
### Syntax Highlighting After Edit
```swift// TextKit 1: In NSTextStorageDelegatefunc textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int) { guard editedMask.contains(.editedCharacters) else { return } // Re-highlight affected range (extend to paragraph boundaries) let paragraphRange = (textStorage.string as NSString).paragraphRange(for: editedRange) highlightSyntax(in: paragraphRange, textStorage: textStorage)}```
### Deferred Layout Update
```swift// TextKit 1: Don't query layout during editingtextStorage.beginEditing()// ... multiple edits ...textStorage.endEditing()// NOW it's safe to query layoutlet rect = layoutManager.usedRect(for: textContainer)```
### Content Size Calculation
```swift// TextKit 1layoutManager.ensureLayout(for: textContainer)let usedRect = layoutManager.usedRect(for: textContainer)let contentSize = CGSize(width: usedRect.width + textContainer.lineFragmentPadding * 2, height: usedRect.height + textView.textContainerInset.top + textView.textContainerInset.bottom)```
## Common Pitfalls
1. **Querying layout during editing** — Layout may not be valid between `beginEditing()` and `endEditing()`.2. **Full-document ensureLayout in TextKit 2** — Defeats the viewport optimization. Only ensure layout for visible ranges.3. **Expecting rendering attributes to invalidate layout** — They don't. They're visual-only overlays.4. **Not wrapping TextKit 2 edits in transaction** — Direct NSTextStorage edits without `performEditingTransaction` may not trigger proper element regeneration.5. **Invalidating layout after every keystroke** — Layout invalidation happens automatically through the text storage editing lifecycle. Manual invalidation is only needed for non-storage changes.
## Going Deeper
Read `debugging-patterns.md` in this skill directory for:
- Symptom decision tree for stale layout diagnosis- Symbolic breakpoints for invalidation tracking- os_signpost instrumentation for profiling invalidation cost- Viewport controller deep patterns (fragment recycling, scroll performance)- Common bugs by symptom with fixes
## Related Skills
- Use `/skill apple-text-textkit-diag` for broader troubleshooting.- Use `/skill apple-text-storage` when invalidation questions are really about editing lifecycle.- Use `/skill apple-text-textkit2-ref` for direct API details around layout fragments and viewport behavior.