skills/txt-fallback-triggers/SKILL.md
Catalog every API access and content shape that flips a UITextView or NSTextView from TextKit 2 (NSTextLayoutManager) to TextKit 1 (NSLayoutManager) compatibility mode. Covers explicit layoutManager access, glyph APIs, multi-container layout, NSTextTable / NSTextTableBlock content, the macOS field-editor cascade, framework-internal fallbacks, detection notifications, and recovery. Use when textView.textLayoutManager unexpectedly returns nil, when Writing Tools degrades to panel-only, when scrolling collapses on large documents after a build, or when auditing third-party code or your own extensions for fallback risk before shipping. Trigger on 'why did Writing Tools go panel-only', 'TextKit 2 stopped working', 'scrolling collapses', or any unexplained `textLayoutManager == nil`, even when 'fallback' isn't named. Do NOT use for symptom-driven debugging — see txt-textkit-debug. Do NOT use for the TK1 vs TK2 picker decision — see txt-textkit-choice.
npx skillsauth add sitapix/apple-text txt-fallback-triggersInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill is the exhaustive catalog of what causes UITextView and NSTextView to abandon NSTextLayoutManager and revert to NSLayoutManager. Fallback is permanent on a given view instance — once textLayoutManager is nil, no API call will return it. The triggers below are clues, not guarantees: framework internals also fall back without your code on the stack, and the set of triggers shifts each OS release. Before claiming a specific trigger applies to a codebase, open the call site and verify the actual code path matches; before quoting an API signature here, fetch the current docs via Sosumi (sosumi.ai/documentation/<framework>/<api>).
When the system flips a text view to compatibility mode:
NSTextLayoutManager is replaced with NSLayoutManager.textView.textLayoutManager returns nil permanently for that instance.NSTextAttachmentViewProvider attachments are dropped — TextKit 1 cannot render them.The storage layer is unchanged. NSTextStorage is the backing store on both stacks, so attribute access, replaceCharacters, and beginEditing/endEditing keep working. "Fallback" specifically names the swap of the layout manager.
The single most common trigger. Any read or write of the TextKit 1 layout manager flips the view, even read-only checks:
// WRONG — every line below triggers fallback
if textView.layoutManager != nil { … }
let lm = textView.textContainer.layoutManager
textView.textContainer.replaceLayoutManager(NSLayoutManager())
textStorage.addLayoutManager(NSLayoutManager())
// CORRECT — branch on the TK2 manager first
if let tlm = textView.textLayoutManager {
// TextKit 2 path
} else {
// Already in TextKit 1; safe to use layoutManager now
}
textContainer.layoutManager is the same trigger as textView.layoutManager — the container holds a back-reference to the TextKit 1 layout manager and accessing it forces TextKit 1 infrastructure into existence.
Caching the layout manager in a helper (weak var lm = textView.layoutManager) flips the view at the moment that line runs. Categories and extensions that "remember" the layout manager do the same thing.
TextKit 2 has no glyph APIs at all. Anything glyph-shaped pulls in TextKit 1:
| TextKit 1 API | What to use instead on TextKit 2 |
|---|---|
| numberOfGlyphs, glyph(at:) | Enumerate NSTextLayoutFragment; drop to Core Text for true glyph access |
| glyphRange(forCharacterRange:actualCharacterRange:) | enumerateTextLayoutFragments(from:options:) |
| lineFragmentRect(forGlyphAt:effectiveRange:) | NSTextLineFragment.typographicBounds |
| boundingRect(forGlyphRange:in:) | Union of layoutFragmentFrame rects |
| characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:) | location(interactingAt:inContainerAt:) |
| drawGlyphs(forGlyphRange:at:) | Subclass NSTextLayoutFragment and override draw(at:in:) |
| drawBackground(forGlyphRange:at:) | Custom layout fragment subclass |
| shouldGenerateGlyphs delegate | No equivalent — customize at fragment level |
The cleanest signal that a code path is TextKit 1 only is the word "glyph" in the symbol name.
Some content shapes force the layout manager to swap regardless of what your code does:
NSTextTable / NSTextTableBlock (AppKit). Tables in the attributed string trigger fallback. Apple's TextEdit demonstrates this — opening a document with tables flips it to TextKit 1.NSTextList. Supported on TextKit 2 since iOS 17 / macOS 14. Earlier deployment targets still fall back. macOS 26 adds an includesTextListMarkers property on NSTextList and NSTextContentStorage that controls whether marker strings appear in attributed-string contents.NSTextAttachment cell APIs. attachmentBounds(for:proposedLineFragment:glyphPosition:characterIndex:) and NSTextAttachmentCell are TextKit 1 only. On iOS 16, the bounds API can crash on a TextKit 2 view. Use NSTextAttachmentViewProvider for TextKit 2.NSTextLayoutManager supports exactly one NSTextContainer. There is no plural form. Any layout that needs more than one container — multi-page, multi-column, linked text views — runs on TextKit 1.
// TextKit 1 only
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(container1)
layoutManager.addTextContainer(container2) // overflow target
TextEdit's "Wrap to Page" command falls back for this reason.
Before macOS 15 / iOS 18, TextKit 2 had no printing path at all and falling back was automatic when print layout ran. Since iOS 18 / macOS 15, basic printing exists, but NSTextLayoutManager still has only one container, so multi-page pagination still requires TextKit 1. Apple's TextEdit still falls back for printing as of recent releases.
Some fallbacks happen without any of your code on the stack:
layoutManager themselves. The set is undocumented and shifts release to release.layoutManager unconditionally. Audit dependencies, not just your own code.The blunt summary from the STTextView author: "You never know what might trigger that fallback, and the cases are not documented and will vary from release to release."
NSWindow shares a single NSTextView as the field editor for every NSTextField in the window. If any code path triggers fallback on that field editor — including looking at it for diagnostics — every text field in the window loses TextKit 2 simultaneously.
// WRONG — flips every NSTextField in the window to TK1
let fieldEditor = window.fieldEditor(true, for: someField) as? NSTextView
let lm = fieldEditor?.layoutManager
This is window-scoped and silent. Third-party libraries that introspect the field editor for keystroke handling are a frequent culprit.
Equally important. These are safe on TextKit 2:
textView.textLayoutManager — returns nil if the view has already fallen back, but reading it never causes fallback.textView.textStorage (UIKit) — direct attributed-string access is fine.textContainer.exclusionPaths — supported on TextKit 2 since iOS 16.textContainerInset, typingAttributes, selectedRange / selectedTextRange.UITextViewDelegate / NSTextViewDelegate callbacks.NSAttributedString.Key attributes — font, foreground color, paragraph style, link, attachment (when using NSTextAttachmentViewProvider).NSTextContentStorage.performEditingTransaction { … } and NSTextStorage.beginEditing() / endEditing() inside the transaction.NSTextStorage subclass used as the backing store of NSTextContentStorage. The storage layer is shared between stacks; subclassing it does not force TextKit 1.What is not safe and crashes rather than falling back:
NSTextContentManager subclass that doesn't wrap an NSTextStorage. Crashes during element generation in current SDKs.NSTextElement subclasses beyond NSTextParagraph. Triggers runtime assertions.UIKit:
if textView.textLayoutManager == nil {
// TextKit 1 mode (fell back, or was never TK2)
}
Symbolic breakpoint in Xcode on _UITextViewEnablingCompatibilityMode catches the moment a UITextView flips, with a backtrace pointing at the offending call.
AppKit notifications fire around the field-editor and other NSTextView fallbacks:
NotificationCenter.default.addObserver(
forName: NSTextView.willSwitchToNSLayoutManagerNotification,
object: nil, queue: .main
) { note in
print("about to fall back: \(String(describing: note.object))")
Thread.callStackSymbols.forEach { print($0) }
}
The system also logs "UITextView <addr> is switching to TextKit 1 compatibility mode because its layoutManager was accessed" to the console when fallback occurs.
macOS 26 adds NSTextViewAllowsDowngradeToLayoutManager as a user default. Setting it to NO causes the runtime to crash on attempted fallback rather than silently degrading — useful for shipping CI builds where any fallback should be a hard failure.
Production apps deliberately opt out of TextKit 2 on UITextView, treating fallback as a feature rather than a bug. The one-liner shipping editors use:
_ = textView.layoutManager // permanently force TextKit 1 on this instance
The motivation, from Apple DTS forum thread #729491 and several shipping editors (Runestone, STTextView users), is that TextKit 2 degrades hard above ~3k lines and is unusable around 10k. Krzyżanowski's August 2025 retrospective on four years of TextKit 2 lands on "unstable scrolling, unreliable height estimates" — even Apple's own TextEdit shows the symptoms. Forcing TK1 with the throwaway access lets the same UITextView scroll a million-character document smoothly.
If TextKit 1 is the right stack for the feature, prefer the explicit constructor over the throwaway access — it skips wasted TextKit 2 init:
// UIKit
let textView = UITextView(usingTextLayoutManager: false)
// textView.textLayoutManager == nil from the start; no wasted TK2 init
// Manual TK1 construction (custom views)
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
layoutManager.allowsNonContiguousLayout = true
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 300, height: .greatestFiniteMagnitude))
layoutManager.addTextContainer(container)
let textView = UITextView(frame: .zero, textContainer: container)
There is no way to recover TextKit 2 on the same instance once it has fallen back. The recovery procedure is:
UITextView / NSTextView with TextKit 2.attributedText (and selectedRange, typingAttributes, exclusion paths, container insets) over.On iOS 16, shrinking attributedText on a TextKit 2 UITextView leaves a blank scrolled-down void where the removed content used to be — the layout fragment frames don't shrink with the content. Two workarounds:
// Workaround A — clear before reassigning
textView.attributedText = nil
textView.attributedText = newShorterAttributedString
// Workaround B — opt out of TextKit 2 for this view at construction time
let textView = UITextView(usingTextLayoutManager: false)
This is one of the cases where the production opt-out above isn't a perf decision — it's correctness.
| OS | Change |
|---|---|
| iOS 15 / macOS 12 | TextKit 2 introduced as opt-in |
| iOS 16 / macOS 13 | Default for new text controls; compatibility-mode fallback added |
| iOS 17 / macOS 14 | NSTextList support; CJK line-breaking improvements |
| iOS 18 / macOS 15 | Basic printing in TextKit 2 |
| iOS 26 / macOS 26 | includesTextListMarkers on NSTextList and NSTextContentStorage; macOS adds NSTextViewAllowsDowngradeToLayoutManager user default; .layoutManager access on apps linked against macOS 26 SDK is logged |
The trend is in TextKit 2's favor, but multi-container layout and NSTextTable remain TextKit 1 only.
Diagnostic check that itself causes fallback. Reading if textView.layoutManager != nil { … } to "see which stack we're on" flips the view to TextKit 1. Always read textView.textLayoutManager first; it is nil-safe and never triggers a downgrade.
Reading textContainer.layoutManager thinking it's safer than the view-level access. It isn't — it's the same path through the container's back-reference to the TextKit 1 manager.
Caching the layout manager in a helper or category. A line like weak var lm = textView.layoutManager triggers fallback at execution time even if lm is never used. Move the access behind a textLayoutManager == nil guard, or restructure the helper to operate on a fragment-level abstraction.
Trusting a third-party UITextView extension that hasn't been updated since iOS 16. Many open-source line-number gutters and syntax highlighters access layoutManager unconditionally. Search dependencies for .layoutManager and addLayoutManager(.
Touching the field editor for diagnostics on macOS. window.fieldEditor(true, for:)?.layoutManager flips every NSTextField in the window. The cascade is silent and window-scoped.
Overriding drawInsertionPoint(in:color:turnedOn:) on NSTextView. Does not trigger fallback, but silently stops being called under TextKit 2. Custom cursor drawing disappears with no compile error or runtime warning.
Assuming Writing Tools "works" because the panel appears. The panel is the fallback UX. Inline rewriting requires TextKit 2. If only the panel opens, the view has already fallen back — textLayoutManager == nil.
Creating a TextKit 2 view, then immediately falling back. Wastes the TextKit 2 layout manager initialization. If the feature requires TextKit 1, use UITextView(usingTextLayoutManager: false) from the start.
Subclassing NSTextContentManager without wrapping NSTextStorage. Not a fallback — a crash. The supported pattern is subclassing NSTextStorage and using it as the backing store of NSTextContentStorage.
txt-textkit-debug — symptom-driven debugging when fallback is one of several plausible causestxt-textkit-choice — TextKit 1 vs TextKit 2 decision and migration risktxt-textkit1 — TextKit 1 API referencetxt-textkit2 — TextKit 2 API referencetxt-audit — severity-ranked code review including fallback risk findingstools
Integrate Writing Tools into UITextView, NSTextView, custom UITextInput views, or fully custom editors via UIWritingToolsCoordinator. Configure writingToolsBehavior and allowedWritingToolsResultOptions, declare protected ranges via writingToolsIgnoredRangesInEnclosingRange, gate edits with isWritingToolsActive, and pause syncing in willBegin/didEnd. Trigger on 'Apple Intelligence rewrite', 'AI summarize selection', 'compose with AI', 'why won't Writing Tools appear', or 'rewrite is breaking my code blocks' even without UIWritingToolsCoordinator named. Use when Writing Tools is missing from the menu, only the panel mode appears, rewrites corrupt code blocks, the inline animation isn't running, or a custom text engine needs to adopt UIWritingToolsCoordinator. Do NOT use for diagnosing general TextKit 1 fallback symptoms — see txt-fallback-triggers.
tools
Wrap UITextView (UIViewRepresentable) and NSTextView (NSViewRepresentable) inside SwiftUI without breaking editing. Covers binding sync, infinite-update-loop guards, cursor preservation across programmatic mutations, focus / first-responder bridging, auto-sizing strategies, environment value propagation, toolbar integration, and the iOS vs macOS scroll-view differences. Use when building or debugging a SwiftUI text-view wrapper, when cursor jumps after typing, when binding updates don't propagate, when @FocusState seems ignored, or when a wrapped editor won't size to its content. Do NOT use for picking which view class (txt-view-picker) or for which AttributedString attributes survive the SwiftUI boundary (txt-swiftui-interop).
tools
Configure TextKit 2 viewport-driven layout, NSTextLayoutFragment / NSTextLineFragment geometry, and rendering attributes vs storage attributes. Covers NSTextViewportLayoutController callbacks, layoutFragmentFrame vs renderingSurfaceBounds, line-fragment local coordinates, the extra trailing line fragment, exclusion paths that split a visual line, lineFragmentPadding vs container insets, font substitution via fixAttributes, and visible/overscroll/estimated regions. Use when working with custom layout fragments, debugging clipped diacritics or descenders, computing document coordinates from a line fragment, integrating with a custom scroll view, or when scroll-bar behavior under estimated heights is the visible problem. Do NOT use for symptom-driven debugging (txt-textkit-debug), the invalidation model (txt-layout-invalidation), or the TextKit 2 API surface in general (txt-textkit2).
development
Choose between SwiftUI Text/TextField/TextEditor, UIKit UITextView, and AppKit NSTextView. Capability comparison, tradeoffs, and decision criteria for read-only display vs single-line input vs multi-line editing vs rich attributed editing vs TextKit access. Use when the user asks "which text view should I use," "should I use TextField or TextEditor," "do I need UITextView for this," or describes a feature without naming a view class. Do NOT use for wrapping UITextView in SwiftUI — see txt-wrap-textview. Do NOT use for SwiftUI/TextKit attribute compatibility rules — see txt-swiftui-interop. Do NOT use for the iOS 26 SwiftUI TextEditor rich-text APIs themselves — see txt-swiftui-texteditor.