txt-fallback-triggers — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited txt-fallback-triggers (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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:
includesTextListMarkers property on NSTextList and NSTextContentStorage that controls whether marker strings appear in attributed-string contents.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 targetTextEdit'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?.layoutManagerThis 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 instanceThe 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.
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.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.layoutManager unconditionally. Search dependencies for .layoutManager and addLayoutManager(.window.fieldEditor(true, for:)?.layoutManager flips every NSTextField in the window. The cascade is silent and window-scoped.textLayoutManager == nil.UITextView(usingTextLayoutManager: false) from the start.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 findings~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.