TextKit 1 Fallback Triggers — Complete Catalog
Use when debugging or preventing TextKit 2 fallback to TextKit 1 — complete trigger catalog, detection, and recovery.
Use when debugging or preventing TextKit 2 fallback to TextKit 1 — complete trigger catalog, detection, and recovery.
Family: TextKit Runtime And Layout
Use this skill when the main question is why a TextKit 2 view entered compatibility mode or how to avoid doing that.
When to Use
Section titled “When to Use”textLayoutManagerunexpectedly becomesnil- Writing Tools loses inline behavior
- You need to audit fallback risk before touching a text view
Quick Decision
Section titled “Quick Decision”- Need a symptom-first debugger ->
/skill apple-text-textkit-diag - Need the exact fallback trigger catalog -> stay here
- Need to choose TextKit 1 on purpose ->
/skill apple-text-layout-manager-selection
Core Guidance
Section titled “Core Guidance”TextKit 2 falls back to TextKit 1 permanently and irreversibly on a given text view instance. Once textLayoutManager returns nil, there is no way back. This skill catalogs every known trigger.
The Fallback Mechanism
Section titled “The Fallback Mechanism”When triggered, the text view:
- Replaces
NSTextLayoutManagerwithNSLayoutManager textLayoutManagerreturnsnilpermanently- All cached TextKit 2 objects stop functioning
- View-based
NSTextAttachmentViewProviderattachments are instantly lost - Writing Tools degrades to panel-only mode
- Viewport-based layout optimization is lost
Category 1: Explicit NSLayoutManager Access (Most Common)
Section titled “Category 1: Explicit NSLayoutManager Access (Most Common)”| Trigger | Why It Causes Fallback |
|---|---|
textView.layoutManager | Forces TextKit 1 infrastructure creation |
textView.textContainer.layoutManager | Same — accesses TK1 layout manager |
textStorage.addLayoutManager(_:) | Adds TK1 layout manager to storage |
textStorage.removeLayoutManager(_:) | Manipulates TK1 layout manager list |
textContainer.replaceLayoutManager(_:) | Swaps in TK1 layout manager |
// ❌ TRIGGERS FALLBACK — even a read-only checkif textView.layoutManager != nil { ... }if let lm = textView.textContainer.layoutManager { ... }
// ✅ SAFE — check TextKit 2 firstif let tlm = textView.textLayoutManager { // TextKit 2 path} else { // Already in TextKit 1 — safe to use layoutManager let lm = textView.layoutManager}Category 2: Any Glyph-Based API
Section titled “Category 2: Any Glyph-Based API”TextKit 2 has zero glyph APIs. Any glyph access requires TextKit 1:
| API | TextKit 2 Alternative |
|---|---|
numberOfGlyphs | Enumerate layout fragments |
glyph(at:) | No equivalent — use Core Text directly |
glyphRange(for:) | enumerateTextLayoutFragments |
lineFragmentRect(forGlyphAt:) | textLineFragments[n].typographicBounds |
boundingRect(forGlyphRange:in:) | Union of layout fragment frames |
characterIndex(for:in:fractionOf...) | location(interactingAt:inContainerAt:) |
drawGlyphs(forGlyphRange:at:) | NSTextLayoutFragment.draw(at:in:) subclass |
drawBackground(forGlyphRange:at:) | Custom layout fragment |
shouldGenerateGlyphs delegate | No equivalent — customize at fragment level |
Category 3: Unsupported Attributes
Section titled “Category 3: Unsupported Attributes”| Attribute | Status | Notes |
|---|---|---|
| NSTextTable / NSTextTableBlock | Triggers fallback | AppKit-only. Apple’s TextEdit falls back for tables |
| NSTextList | Partially supported | Supported since iOS 17/macOS 14. Earlier versions may fall back |
| NSTextAttachment (TK1 cell API) | Can trigger fallback | attachmentBounds(for:proposedLineFragment:glyphPosition:characterIndex:) crashes on iOS 16.0. Use NSTextAttachmentViewProvider instead |
| NSTextAttachmentCell | Triggers fallback | TextKit 1 only protocol. Use NSTextAttachmentViewProvider for TextKit 2 |
Category 4: Multi-Container Layout
Section titled “Category 4: Multi-Container Layout”TextKit 2’s NSTextLayoutManager supports only ONE text container.
| Pattern | Fallback? |
|---|---|
Multiple NSTextContainer on one layout manager | Requires TextKit 1 |
| Multi-page / multi-column layout | Requires TextKit 1 |
| ”Wrap to Page” in TextEdit | Falls back to TextKit 1 |
Category 5: Printing
Section titled “Category 5: Printing”| OS Version | Printing Support |
|---|---|
| Before macOS 15 / iOS 18 | No printing in TextKit 2 — triggers fallback |
| macOS 15+ / iOS 18+ | Basic printing supported, limited pagination — NSTextLayoutManager still only supports a single NSTextContainer, so multi-page layout requires TextKit 1. Apple’s TextEdit still falls back to TextKit 1 for printing. |
Category 6: Framework-Internal Fallbacks
Section titled “Category 6: Framework-Internal Fallbacks”These happen without YOUR code accessing layoutManager:
- UIKit/AppKit framework internals sometimes access
layoutManagerinternally - Undocumented and varies between OS releases
- Apple recommends filing Feedback Assistant reports for these
- Third-party libraries accessing
layoutManageron your text view
Quote from STTextView author: “You never know what might trigger that fallback, and the cases are not documented and will vary from release to release.”
Category 7: NSTextView-Specific (macOS)
Section titled “Category 7: NSTextView-Specific (macOS)”| Trigger | Notes |
|---|---|
| Quick Look preview of attachments | Bug in macOS 14 and earlier |
drawInsertionPoint(in:color:turnedOn:) override | Doesn’t trigger fallback but silently stops working under TextKit 2 |
Any NSTextField accessing field editor’s layoutManager | Falls back ALL field editors in that window |
| Printing (before macOS 15) | Automatic fallback for print layout |
Field Editor Cascade (macOS Critical Gotcha)
Section titled “Field Editor Cascade (macOS Critical Gotcha)”macOS uses a shared NSTextView as the field editor for ALL NSTextField instances in a window. If ANY field triggers a TextKit 1 fallback on the field editor, every text field in that window loses TextKit 2.
// ❌ One bad field editor access breaks ALL fields in the windowlet fieldEditor = window.fieldEditor(true, for: someTextField) as? NSTextViewlet lm = fieldEditor?.layoutManager // Fallback — now ALL fields are TextKit 1This cascade is especially dangerous with third-party libraries that inspect the field editor.
Detection (macOS):
NotificationCenter.default.addObserver( forName: NSTextView.willSwitchToNSLayoutManagerNotification, object: nil, queue: .main // nil = any text view, catches field editor) { notification in print("⚠️ \(notification.object) switching to TK1") Thread.callStackSymbols.forEach { print($0) }}macOS 26 Changes
Section titled “macOS 26 Changes”NSTextViewAllowsDowngradeToLayoutManageruser default — set toNOto prevent fallback entirely (crashes instead of silently degrading)includesTextListMarkersproperty onNSTextListandNSTextContentStorage— controls whether list marker strings appear in attributed string contents. AppKit adopts TextKit 2 list behavior by default in macOS 26..layoutManageraccess on apps linked against macOS 26 SDK triggers a logged, tracked downgrade
What Does NOT Cause Fallback
Section titled “What Does NOT Cause Fallback”This is equally important — these are safe to use with TextKit 2:
NSTextStorage Is the Normal Backing Store
Section titled “NSTextStorage Is the Normal Backing Store”NSTextContentStorage wraps NSTextStorage. This is the standard architecture.
// ✅ SAFE — accessing the backing store through content storagelet textStorage = textContentStorage.textStorage
// ✅ SAFE — editing through the content storagetextContentStorage.performEditingTransaction { textStorage?.replaceCharacters(in: range, with: newText)}
// ✅ SAFE — NSTextStorage subclass works with TextKit 2class MyStorage: NSTextStorage { ... }let contentStorage = NSTextContentStorage()contentStorage.textStorage = MyStorage()The distinction: “Fallback” means the layout system switches from NSTextLayoutManager to NSLayoutManager. The storage layer (NSTextStorage) is ALWAYS present — it’s the backing store for both systems.
Safe Properties and Methods
Section titled “Safe Properties and Methods”| Property/Method | Safe? | Notes |
|---|---|---|
textView.textLayoutManager | ✅ | Returns nil if already TK1 |
textView.textStorage (UITextView) | ✅ | Direct storage access is fine |
textContainer.exclusionPaths | ✅ | Supported since iOS 16 |
textContainerInset | ✅ | |
typingAttributes | ✅ | |
selectedRange / selectedTextRange | ✅ | |
All UITextViewDelegate methods | ✅ | |
| Standard attributed string attributes | ✅ | font, color, paragraph style, etc. |
NSTextContentStorage.performEditingTransaction | ✅ | Preferred edit wrapper |
NSTextStorage.beginEditing/endEditing | ✅ | When wrapped in transaction |
NSTextStorage Subclass with TextKit 2
Section titled “NSTextStorage Subclass with TextKit 2”A custom NSTextStorage subclass works with TextKit 2 when:
- Used as the backing store of
NSTextContentStorage - All edits go through
performEditingTransaction - The four primitives are correctly implemented
- You never access
layoutManageron the text view
// ✅ Custom backing store with TextKit 2class RopeTextStorage: NSTextStorage { // ... implement 4 primitives with edited() calls}
let contentStorage = NSTextContentStorage()contentStorage.textStorage = RopeTextStorage()// The text view uses NSTextLayoutManager — no fallbackCannot do: Custom NSTextContentManager subclass (without NSTextStorage) — causes crashes. Custom NSTextElement subclasses beyond NSTextParagraph — triggers runtime assertions.
How to Detect Fallback
Section titled “How to Detect Fallback”UIKit (iOS)
Section titled “UIKit (iOS)”// Runtime checkif textView.textLayoutManager == nil { print("⚠️ TextKit 1 mode (fallback occurred or was never TK2)")}
// Symbolic breakpoint (Xcode)// Symbol: _UITextViewEnablingCompatibilityMode// Action: Log message with backtrace to find the triggerAppKit (macOS)
Section titled “AppKit (macOS)”// NotificationsNotificationCenter.default.addObserver( forName: NSTextView.willSwitchToNSLayoutManagerNotification, object: textView, queue: .main) { _ in print("⚠️ About to fall back — check call stack")}
NotificationCenter.default.addObserver( forName: NSTextView.didSwitchToNSLayoutManagerNotification, object: textView, queue: .main) { _ in print("⚠️ Fell back to TextKit 1")}Console Log
Section titled “Console Log”The system logs: "UITextView <addr> is switching to TextKit 1 compatibility mode because its layoutManager was accessed"
How to Opt Out (Use TextKit 1 from Start)
Section titled “How to Opt Out (Use TextKit 1 from Start)”If you NEED TextKit 1, don’t create a TextKit 2 view and let it fall back — that wastes initialization:
// ✅ CORRECT — explicit TextKit 1 from startlet textView = UITextView(usingTextLayoutManager: false)
// ✅ CORRECT — manual TextKit 1 setuplet storage = NSTextStorage()let layoutManager = NSLayoutManager()storage.addLayoutManager(layoutManager)let container = NSTextContainer(size: CGSize(width: 300, height: .greatestFiniteMagnitude))layoutManager.addTextContainer(container)let textView = UITextView(frame: .zero, textContainer: container)Recovery from Fallback
Section titled “Recovery from Fallback”There is no recovery on the same instance. To get back to TextKit 2:
- Create a NEW text view with TextKit 2
- Transfer the text content (attributedText)
- Replace the old view in the hierarchy
- Re-wire delegates and observers
Fallback Improvement Timeline
Section titled “Fallback Improvement Timeline”| OS | TextKit 2 Improvement |
|---|---|
| iOS 15 / macOS 12 | TextKit 2 introduced (opt-in) |
| iOS 16 / macOS 13 | Default for all text controls; compatibility mode added |
| iOS 17 / macOS 14 | NSTextList support; CJK line-breaking improvements |
| iOS 18 / macOS 15 | Printing support added |
| macOS 26 | includesTextListMarkers property; NSTextViewAllowsDowngradeToLayoutManager user default |
Trend: Each OS release supports more features in TextKit 2, reducing fallback triggers. But multi-container layout and text tables remain TextKit 1 only.
Documentation Scope
Section titled “Documentation Scope”This page documents the apple-text-fallback-triggers reference skill. Use it when the subsystem is already known and you need mechanics, behavior, or API detail.
Related
Section titled “Related”apple-text-textkit-diag: Use when debugging broken text — stale layout, editing crashes, fallback, Writing Tools issues, or rendering artifacts.apple-text-layout-manager-selection: Use when choosing between TextKit 1 and TextKit 2, evaluating migration risk, or comparing NSLayoutManager vs NSTextLayoutManager.apple-text-audit: Use when reviewing Apple text code for TextKit fallback risk, editing lifecycle bugs, deprecated APIs, or Writing Tools breakage.
Full SKILL.md source
---name: apple-text-fallback-triggersdescription: Use when debugging or preventing TextKit 2 fallback to TextKit 1 — complete trigger catalog, detection, and recoverylicense: MIT---
# TextKit 1 Fallback Triggers — Complete Catalog
Use this skill when the main question is why a TextKit 2 view entered compatibility mode or how to avoid doing that.
## When to Use
- `textLayoutManager` unexpectedly becomes `nil`- Writing Tools loses inline behavior- You need to audit fallback risk before touching a text view
## Quick Decision
- Need a symptom-first debugger -> `/skill apple-text-textkit-diag`- Need the exact fallback trigger catalog -> stay here- Need to choose TextKit 1 on purpose -> `/skill apple-text-layout-manager-selection`
## Core Guidance
TextKit 2 falls back to TextKit 1 **permanently and irreversibly** on a given text view instance. Once `textLayoutManager` returns `nil`, there is no way back. This skill catalogs every known trigger.
## The Fallback Mechanism
When triggered, the text view:1. Replaces `NSTextLayoutManager` with `NSLayoutManager`2. `textLayoutManager` returns `nil` permanently3. All cached TextKit 2 objects stop functioning4. View-based `NSTextAttachmentViewProvider` attachments are **instantly lost**5. Writing Tools degrades to panel-only mode6. Viewport-based layout optimization is lost
## Category 1: Explicit NSLayoutManager Access (Most Common)
| Trigger | Why It Causes Fallback ||---------|----------------------|| `textView.layoutManager` | Forces TextKit 1 infrastructure creation || `textView.textContainer.layoutManager` | Same — accesses TK1 layout manager || `textStorage.addLayoutManager(_:)` | Adds TK1 layout manager to storage || `textStorage.removeLayoutManager(_:)` | Manipulates TK1 layout manager list || `textContainer.replaceLayoutManager(_:)` | Swaps in TK1 layout manager |
```swift// ❌ TRIGGERS FALLBACK — even a read-only checkif textView.layoutManager != nil { ... }if let lm = textView.textContainer.layoutManager { ... }
// ✅ SAFE — check TextKit 2 firstif let tlm = textView.textLayoutManager { // TextKit 2 path} else { // Already in TextKit 1 — safe to use layoutManager let lm = textView.layoutManager}```
## Category 2: Any Glyph-Based API
TextKit 2 has **zero glyph APIs**. Any glyph access requires TextKit 1:
| API | TextKit 2 Alternative ||-----|----------------------|| `numberOfGlyphs` | Enumerate layout fragments || `glyph(at:)` | No equivalent — use Core Text directly || `glyphRange(for:)` | `enumerateTextLayoutFragments` || `lineFragmentRect(forGlyphAt:)` | `textLineFragments[n].typographicBounds` || `boundingRect(forGlyphRange:in:)` | Union of layout fragment frames || `characterIndex(for:in:fractionOf...)` | `location(interactingAt:inContainerAt:)` || `drawGlyphs(forGlyphRange:at:)` | `NSTextLayoutFragment.draw(at:in:)` subclass || `drawBackground(forGlyphRange:at:)` | Custom layout fragment || `shouldGenerateGlyphs` delegate | No equivalent — customize at fragment level |
## Category 3: Unsupported Attributes
| Attribute | Status | Notes ||-----------|--------|-------|| **NSTextTable / NSTextTableBlock** | Triggers fallback | AppKit-only. Apple's TextEdit falls back for tables || **NSTextList** | Partially supported | Supported since iOS 17/macOS 14. Earlier versions may fall back || **NSTextAttachment (TK1 cell API)** | Can trigger fallback | `attachmentBounds(for:proposedLineFragment:glyphPosition:characterIndex:)` crashes on iOS 16.0. Use `NSTextAttachmentViewProvider` instead || **NSTextAttachmentCell** | Triggers fallback | TextKit 1 only protocol. Use `NSTextAttachmentViewProvider` for TextKit 2 |
## Category 4: Multi-Container Layout
**TextKit 2's NSTextLayoutManager supports only ONE text container.**
| Pattern | Fallback? ||---------|-----------|| Multiple `NSTextContainer` on one layout manager | Requires TextKit 1 || Multi-page / multi-column layout | Requires TextKit 1 || "Wrap to Page" in TextEdit | Falls back to TextKit 1 |
## Category 5: Printing
| OS Version | Printing Support ||------------|-----------------|| Before macOS 15 / iOS 18 | **No printing in TextKit 2** — triggers fallback || macOS 15+ / iOS 18+ | Basic printing supported, limited pagination — `NSTextLayoutManager` still only supports a single `NSTextContainer`, so multi-page layout requires TextKit 1. Apple's TextEdit still falls back to TextKit 1 for printing. |
## Category 6: Framework-Internal Fallbacks
**These happen without YOUR code accessing layoutManager:**
- UIKit/AppKit framework internals sometimes access `layoutManager` internally- Undocumented and **varies between OS releases**- Apple recommends filing Feedback Assistant reports for these- Third-party libraries accessing `layoutManager` on your text view
**Quote from STTextView author:** *"You never know what might trigger that fallback, and the cases are not documented and will vary from release to release."*
## Category 7: NSTextView-Specific (macOS)
| Trigger | Notes ||---------|-------|| Quick Look preview of attachments | Bug in macOS 14 and earlier || `drawInsertionPoint(in:color:turnedOn:)` override | Doesn't trigger fallback but **silently stops working** under TextKit 2 || Any NSTextField accessing field editor's `layoutManager` | Falls back ALL field editors in that window || Printing (before macOS 15) | Automatic fallback for print layout |
### Field Editor Cascade (macOS Critical Gotcha)
macOS uses a **shared `NSTextView`** as the field editor for ALL `NSTextField` instances in a window. If ANY field triggers a TextKit 1 fallback on the field editor, **every text field in that window loses TextKit 2**.
```swift// ❌ One bad field editor access breaks ALL fields in the windowlet fieldEditor = window.fieldEditor(true, for: someTextField) as? NSTextViewlet lm = fieldEditor?.layoutManager // Fallback — now ALL fields are TextKit 1```
This cascade is especially dangerous with third-party libraries that inspect the field editor.
**Detection (macOS):**```swiftNotificationCenter.default.addObserver( forName: NSTextView.willSwitchToNSLayoutManagerNotification, object: nil, queue: .main // nil = any text view, catches field editor) { notification in print("⚠️ \(notification.object) switching to TK1") Thread.callStackSymbols.forEach { print($0) }}```
### macOS 26 Changes
- `NSTextViewAllowsDowngradeToLayoutManager` user default — set to `NO` to prevent fallback entirely (crashes instead of silently degrading)- `includesTextListMarkers` property on `NSTextList` and `NSTextContentStorage` — controls whether list marker strings appear in attributed string contents. AppKit adopts TextKit 2 list behavior by default in macOS 26.- `.layoutManager` access on apps linked against macOS 26 SDK triggers a logged, tracked downgrade
## What Does NOT Cause Fallback
This is equally important — these are **safe** to use with TextKit 2:
### NSTextStorage Is the Normal Backing Store
**NSTextContentStorage wraps NSTextStorage. This is the standard architecture.**
```swift// ✅ SAFE — accessing the backing store through content storagelet textStorage = textContentStorage.textStorage
// ✅ SAFE — editing through the content storagetextContentStorage.performEditingTransaction { textStorage?.replaceCharacters(in: range, with: newText)}
// ✅ SAFE — NSTextStorage subclass works with TextKit 2class MyStorage: NSTextStorage { ... }let contentStorage = NSTextContentStorage()contentStorage.textStorage = MyStorage()```
**The distinction:** "Fallback" means the layout system switches from `NSTextLayoutManager` to `NSLayoutManager`. The storage layer (NSTextStorage) is ALWAYS present — it's the backing store for both systems.
### Safe Properties and Methods
| Property/Method | Safe? | Notes ||----------------|-------|-------|| `textView.textLayoutManager` | ✅ | Returns nil if already TK1 || `textView.textStorage` (UITextView) | ✅ | Direct storage access is fine || `textContainer.exclusionPaths` | ✅ | Supported since iOS 16 || `textContainerInset` | ✅ | || `typingAttributes` | ✅ | || `selectedRange` / `selectedTextRange` | ✅ | || All `UITextViewDelegate` methods | ✅ | || Standard attributed string attributes | ✅ | font, color, paragraph style, etc. || `NSTextContentStorage.performEditingTransaction` | ✅ | Preferred edit wrapper || `NSTextStorage.beginEditing`/`endEditing` | ✅ | When wrapped in transaction |
### NSTextStorage Subclass with TextKit 2
A custom NSTextStorage subclass **works with TextKit 2** when:1. Used as the backing store of `NSTextContentStorage`2. All edits go through `performEditingTransaction`3. The four primitives are correctly implemented4. You never access `layoutManager` on the text view
```swift// ✅ Custom backing store with TextKit 2class RopeTextStorage: NSTextStorage { // ... implement 4 primitives with edited() calls}
let contentStorage = NSTextContentStorage()contentStorage.textStorage = RopeTextStorage()// The text view uses NSTextLayoutManager — no fallback```
**Cannot do:** Custom `NSTextContentManager` subclass (without NSTextStorage) — causes crashes. Custom `NSTextElement` subclasses beyond `NSTextParagraph` — triggers runtime assertions.
## How to Detect Fallback
### UIKit (iOS)
```swift// Runtime checkif textView.textLayoutManager == nil { print("⚠️ TextKit 1 mode (fallback occurred or was never TK2)")}
// Symbolic breakpoint (Xcode)// Symbol: _UITextViewEnablingCompatibilityMode// Action: Log message with backtrace to find the trigger```
### AppKit (macOS)
```swift// NotificationsNotificationCenter.default.addObserver( forName: NSTextView.willSwitchToNSLayoutManagerNotification, object: textView, queue: .main) { _ in print("⚠️ About to fall back — check call stack")}
NotificationCenter.default.addObserver( forName: NSTextView.didSwitchToNSLayoutManagerNotification, object: textView, queue: .main) { _ in print("⚠️ Fell back to TextKit 1")}```
### Console Log
The system logs: `"UITextView <addr> is switching to TextKit 1 compatibility mode because its layoutManager was accessed"`
## How to Opt Out (Use TextKit 1 from Start)
If you NEED TextKit 1, don't create a TextKit 2 view and let it fall back — that wastes initialization:
```swift// ✅ CORRECT — explicit TextKit 1 from startlet textView = UITextView(usingTextLayoutManager: false)
// ✅ CORRECT — manual TextKit 1 setuplet storage = NSTextStorage()let layoutManager = NSLayoutManager()storage.addLayoutManager(layoutManager)let container = NSTextContainer(size: CGSize(width: 300, height: .greatestFiniteMagnitude))layoutManager.addTextContainer(container)let textView = UITextView(frame: .zero, textContainer: container)```
## Recovery from Fallback
**There is no recovery on the same instance.** To get back to TextKit 2:
1. Create a NEW text view with TextKit 22. Transfer the text content (attributedText)3. Replace the old view in the hierarchy4. Re-wire delegates and observers
## Fallback Improvement Timeline
| OS | TextKit 2 Improvement ||----|----------------------|| iOS 15 / macOS 12 | TextKit 2 introduced (opt-in) || iOS 16 / macOS 13 | Default for all text controls; compatibility mode added || iOS 17 / macOS 14 | NSTextList support; CJK line-breaking improvements || iOS 18 / macOS 15 | Printing support added || macOS 26 | `includesTextListMarkers` property; `NSTextViewAllowsDowngradeToLayoutManager` user default |
**Trend:** Each OS release supports more features in TextKit 2, reducing fallback triggers. But multi-container layout and text tables remain TextKit 1 only.
## Related Skills
- Use `/skill apple-text-textkit-diag` for broader debugging around fallback symptoms.- Use `/skill apple-text-layout-manager-selection` when compatibility mode pressure means TextKit 1 may be the right explicit choice.- Use `/skill apple-text-audit` when you want repository findings ranked by severity.