apple-text-fallback-triggers
Use when debugging or preventing TextKit 2 fallback to TextKit 1 — complete trigger catalog, detection, and recovery
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
textLayoutManagerunexpectedly becomesnil- 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:
- 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)
| 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 check
if textView.layoutManager != nil { ... }
if let lm = textView.textContainer.layoutManager { ... }
// ✅ SAFE — check TextKit 2 first
if 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
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)
| 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.
// ❌ One bad field editor access breaks ALL fields in the window
let fieldEditor = window.fieldEditor(true, for: someTextField) as? NSTextView
let 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):
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
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
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.
// ✅ SAFE — accessing the backing store through content storage
let textStorage = textContentStorage.textStorage
// ✅ SAFE — editing through the content storage
textContentStorage.performEditingTransaction {
textStorage?.replaceCharacters(in: range, with: newText)
}
// ✅ SAFE — NSTextStorage subclass works with TextKit 2
class 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:
- 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 2
class 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)
// Runtime check
if 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)
// Notifications
NotificationCenter.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:
// ✅ CORRECT — explicit TextKit 1 from start
let textView = UITextView(usingTextLayoutManager: false)
// ✅ CORRECT — manual TextKit 1 setup
let 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:
- 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
| 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-diagfor broader debugging around fallback symptoms. - Use
/skill apple-text-layout-manager-selectionwhen compatibility mode pressure means TextKit 1 may be the right explicit choice. - Use
/skill apple-text-auditwhen you want repository findings ranked by severity.