Txt fallback triggers
Skills for Apple's text-editing stack: TextKit, UITextView, NSTextView, AttributedString, Writing Tools, Core Text.
npx -y skills add sitapix/apple-text --skill txt-fallback-triggersAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
16.3 KB, ~3.6k tokens by cl100k_base, as published. Nobody here has run it
TextKit 1 fallback triggers
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>).
Contents
- What fallback actually changes
- Explicit NSLayoutManager access
- Glyph-based APIs
- Content-driven fallback
- Multi-container layout
- Printing
- Framework-internal fallback
- The macOS field-editor cascade
- What does not cause fallback
- Detection
- Opting out and recovery
- Common Mistakes
- References
What fallback actually changes
When the system flips a text view to compatibility mode:
NSTextLayoutManageris replaced withNSLayoutManager.textView.textLayoutManagerreturnsnilpermanently for that instance.- Cached references to TextKit 2 objects (fragments, content storage, viewport controller) stop functioning.
- View-based
NSTextAttachmentViewProviderattachments are dropped — TextKit 1 cannot render them. - Writing Tools degrades to panel-only — no inline rewriting.
- Viewport-driven layout is gone; layout is contiguous (or non-contiguous-with-known-gotchas if you set the flag).
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.
Explicit NSLayoutManager access
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.
Glyph-based APIs
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.
Content-driven fallback
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 anincludesTextListMarkersproperty onNSTextListandNSTextContentStoragethat controls whether marker strings appear in attributed-string contents.NSTextAttachmentcell APIs.attachmentBounds(for:proposedLineFragment:glyphPosition:characterIndex:)andNSTextAttachmentCellare TextKit 1 only. On iOS 16, the bounds API can crash on a TextKit 2 view. UseNSTextAttachmentViewProviderfor TextKit 2.
Multi-container layout
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.
Printing
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.
Framework-internal fallback
Some fallbacks happen without any of your code on the stack:
- Internal AppKit / UIKit code paths sometimes reach for
layoutManagerthemselves. The set is undocumented and shifts release to release. - Quick Look previews of attachments on macOS 14 and earlier triggered fallback in NSTextView.
- Third-party libraries — line-numbering gutters, syntax highlighters, code editors written before iOS 16 — frequently access
layoutManagerunconditionally. 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."
The macOS field-editor cascade
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.
What does not cause fallback
Equally important. These are safe on TextKit 2:
textView.textLayoutManager— returnsnilif 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.- All
UITextViewDelegate/NSTextViewDelegatecallbacks. - Standard
NSAttributedString.Keyattributes — font, foreground color, paragraph style, link, attachment (when usingNSTextAttachmentViewProvider). NSTextContentStorage.performEditingTransaction { … }andNSTextStorage.beginEditing()/endEditing()inside the transaction.- A custom
NSTextStoragesubclass used as the backing store ofNSTextContentStorage. The storage layer is shared between stacks; subclassing it does not force TextKit 1.
What is not safe and crashes rather than falling back:
- Custom
NSTextContentManagersubclass that doesn't wrap anNSTextStorage. Crashes during element generation in current SDKs. - Custom
NSTextElementsubclasses beyondNSTextParagraph. Triggers runtime assertions.
Detection
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.
Opting out and recovery
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:
- Build a new
UITextView/NSTextViewwith TextKit 2. - Copy
attributedText(andselectedRange,typingAttributes, exclusion paths, container insets) over. - Replace the old view in the hierarchy.
- Re-wire delegate, observers, layout constraints, focus state.
iOS 16 TextKit 2 scroll-trail bug
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.
Improvement timeline
| 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.
Common Mistakes
-
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 readtextView.textLayoutManagerfirst; it isnil-safe and never triggers a downgrade. -
Reading
textContainer.layoutManagerthinking 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.layoutManagertriggers fallback at execution time even iflmis never used. Move the access behind atextLayoutManager == nilguard, 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
layoutManagerunconditionally. Search dependencies for.layoutManagerandaddLayoutManager(. -
Touching the field editor for diagnostics on macOS.
window.fieldEditor(true, for:)?.layoutManagerflips everyNSTextFieldin 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
NSTextContentManagerwithout wrappingNSTextStorage. Not a fallback — a crash. The supported pattern is subclassingNSTextStorageand using it as the backing store ofNSTextContentStorage.
References
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- NSTextLayoutManager
- NSLayoutManager
- UITextView
- NSTextView
- NSTextAttachmentViewProvider
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.