NSPopover in NSTextView With Links Is Broken: Accessibility Hierarchy Slowdown
Calling NSPopover.show(relativeTo:of:preferredEdge:)
with a NSTextView
as the positioning view will slow down your app the more links your text view contains.
I discovered this with auto-completion popovers in my note-taking app The Archive and hunted down the issue. In notes with ~100 links (hashtags, web links, wiki links, …) displaying the popover with completion candidates was much slower than in short/empty notes. The slowness scales linearly with the amount of links, and is related to accessibility children iteration.
I have some ideas to bypass this (see below), but no proper fix.
Share your ideas if you have any!
Recreating the Problem
Take this simple text view subclass that counts how often accessibilityChildren()
has been called:
class MyTextView: NSTextView {
var accessibilityChildrenCallCount = 0
override func accessibilityChildren() -> [Any]? {
self.accessibilityChildrenCallCount += 1
print(self.accessibilityChildrenCallCount)
return super.accessibilityChildren()
}
}
For every click inside a text view, this will increment by 1.
For every time a popover is displayed relative to the text view, this will increment by 1. The popover’s accessibilityParent
is set to the text view it is being displayed relative to, and installing the popover into the accessibility hierarchy like this automatically walks siblings once.
To observe how links affect this, you need an NSAttributedString
with links. A simple way to add links to a text view’s text storage is via Markdown:
textView.textStorage?.setAttributedString(
try! NSAttributedString(
markdown: #"Hello [zettelkasten](https://zettelkasten.de/) world!"#.data(using: .utf8)!,
options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: nil),
baseURL: nil
)
)
This will add 1 link to the text view.
For every click inside a text view, the logged number will increment by 1.
For every time a popover is displayed relative to the text view (e.g. with a shortcut), this will increment by … 4!
Change the Markdown string to contain two links, and the count will increment by 7.
Add 100 links, and it will increment by 301!
(In my test note, I observed 436 increments, 3 per one of the 145 links in the note, plus 1 for the popover itself.)
It doesn’t matter what the popover shows. Could be an empty view.
This affects initial display of the popover; if the popover is already visible (aka in the view hierarchy, and accessibility hierarchy), displaying it at another location won’t cause trouble. So if you have popover follow the insertion point (which you probably don’t want to, because that’s quite jarring), the initial show
call will be slow, but subsequent show calls won’t.
Observation: What Makes This So Slow

The text view computes links as its accessibility children on-the-fly. You can see this in the call stack. Let me walk you through the noise from top to bottom, to show the highlights:
- the text storage’s
attributes(at:effectiveRange:)
is being called to determine the effective range and all attributes for each link; - where
_NSAccessibilityAttachments
is in the stack trace, the Time Profiler Instrument will show that-[NSText(NSTextAccessibilityPrivate) accessibilityTextLinks]
is part of the call hierarchy at that point (see the screenshot above); accessibilityChildren()
of the text view is called;-[NSPopover showRelativeToRect:ofView:preferredEdge:]
.
0x0000000104da8a7c in -[MyObjcTextStorage attributesAtIndex:effectiveRange:]
0x00000001936df194 in -[NSAttributedString attribute:atIndex:effectiveRange:] ()
0x00000001936dae5c in -[NSAttributedString attribute:atIndex:longestEffectiveRange:inRange:] ()
0x00000001967bdb88 in _NSAccessibilityAttachments ()
0x00000001967ea8a8 in -[NSTextView(NSTextViewAccessibility) accessibilityChildrenAttribute] ()
0x0000000195e7a1cc in NSAccessibilityGetObjectForAttributeUsingLegacyAPI ()
0x0000000195e79894 in NSAccessibilityGetObjectValueForAttribute ()
0x0000000102c635c0 in MyTextView.accessibilityChildren()
0x0000000102c63698 in @objc MyTextView.accessibilityChildren() ()
0x0000000195e78e50 in -[NSAccessibilityAttributeAccessorInfo getAttributeValue:forObject:] ()
0x000000019648145c in ___NSAccessibilityEntryPointValueForAttribute_block_invoke.759 ()
0x000000019647cf48 in NSAccessibilityPerformEntryPointObject ()
0x000000019604c918 in _NSAccessibilityEntryPointValueForAttribute ()
0x0000000195e78bc4 in NSAccessibilityChildren ()
0x0000000195f7bd44 in -[NSObject(NSObjectAccessibilityAttributeAccessAdditions) accessibilityIndexOfChild:] ()
0x0000000195f7bc30 in -[NSObject(NSAccessibilityUIElementSpecifier) _accessibilityUIElementSpecifierForChild:registerIfNeeded:] ()
0x0000000195f40598 in -[NSObject(NSAccessibilityUIElementSpecifier) _accessibilityUIElementSpecifierRegisterIfNeeded:] ()
0x0000000195df4478 in OverriddenAttributesForUIElement ()
0x0000000196221e30 in NSAccessibilityGetOverriddenAttributeForUIElement ()
0x0000000195e7a058 in _accessibilityGetAssociatedObjectForAttribute ()
0x0000000195e794c4 in NSAccessibilityGetObjectValueForAttribute ()
0x000000019609c2c0 in NSAccessibilityGetRectValueForAttribute ()
0x000000019609c050 in -[NSView(NSViewAccessibility) _accessibilityBasicHitTest:] ()
0x000000019609beec in -[NSView(NSViewAccessibility) accessibilityHitTest:] ()
0x00000001967ea9ec in -[NSTextView(NSTextViewAccessibility) accessibilityHitTest:] ()
0x000000019609bd54 in _NSAccessibilityElementForRectInView ()
0x000000019609bad8 in -[NSPopover(NSPopoverAccessibility) accessibilityAttributeValue:] ()
0x0000000195e7a1cc in NSAccessibilityGetObjectForAttributeUsingLegacyAPI ()
0x0000000195e79894 in NSAccessibilityGetObjectValueForAttribute ()
0x0000000196080e6c in -[NSPopover accessibilityParent] ()
0x0000000195e78e50 in -[NSAccessibilityAttributeAccessorInfo getAttributeValue:forObject:] ()
0x000000019648145c in ___NSAccessibilityEntryPointValueForAttribute_block_invoke.759 ()
0x000000019647cf48 in NSAccessibilityPerformEntryPointObject ()
0x000000019604c918 in _NSAccessibilityEntryPointValueForAttribute ()
0x000000019607df50 in -[NSPopover showRelativeToRect:ofView:preferredEdge:] ()
As a snapshot, this looks sensible: accessibility hierarchy changes as the popover is shown, so let’s update/check/validate/whatever-ify the hierarchy, including text links.
Or rather, it would make sense if you saw this only once. Iterating over 100 links in a text view that shows maybe 3 at a time in its text container would still be wasteful, but it’d be a waste that could be fixed by limiting the returned links to the ones in the visible screen rectangle.
The thing is that while -[NSPopover showRelativeToRect:ofView:preferredEdge:]
is called once, and also -[NSView(NSViewAccessibility) accessibilityHitTest:]
is called only once, MyTextView.accessibilityChildren()
is already invoked 3 times per link in the hierarchy.
I didn’t learn much that looked useful when stepping through the call stack, inspecting registers here and there.
But I did learn is that -[NSObject(NSObjectAccessibilityAttributeAccessAdditions) accessibilityIndexOfChild:]
is called 3 timers per link, too, by adding a breakpoint for this symbol and automatically executing this in the debugger:
po [$x2 accessibilityURL]
This prints the same URL 3 times, each (and then goes through the rest of the call stack).
Adding a breakpoint for a function higher up the stack,
br set -n "NSAccessibilityGetOverriddenAttributeForUIElement"
… shows that this is also called 3 times per link, with register x0 being an NSAccessibilityTextLink
instance, and register x1 being the strings "AXFrame"
, "AXSize"
, and "AXPosition"
in sequence.
So that’s why it’s three calls per link in the text view.
And every time the whole NSTextView.accessibilityChildren()
collection is requested.
Possible Remedies
Since I don’t know the reason why this happens, I can only guess how to influence this black box in a way that doesn’t break something else. Ideas:
- Nuclear option: Override
NSPopover.accessibilityParent()
and returnnil
. I see theoretical value in embedding the popover into the accesibility hierarchy so that you can get from the popover to the text view it is being displayed for. In practice, I don’t know what you’d do with this info in this particular situation.- There could be potential to recreate whichever features the accesibility hierarchy provides, but in the popover itself, but I don’t know, yet.
- You could also limit this to return
nil
only while the popover is in the process of being displayed, but return the text view later. I also don’t know the implications of this; maybe the hierarchy is never walked again if the parent is/wasnil
.
- General Caching: Override
NSTextView.accessibilityChildren()
, get the result fromsuper
, then cache it in a private variable. Clear the cache with the nextRunLoop
iteration.- This could produce outdated information from within the same
RunLoop
run, for example when a new accessibility child is added. - Outdated cached info could be mitigated by also clearing the cache when mutating functions are called. This begins to get hairy
- This could produce outdated information from within the same
-
Specific Caching: I control when the popover is displayed, so I can cache values before the call, show the popover, then clear the cache afterwards immediately:
self.cachedAccessibilityChildren = super.accessibilityChildren() defer { self.cachedAccessibilityChildren = nil } doTheWork()
That probably won’t help you in your specific situation. It will probably also not help me should I run into this again in the future, but for another reason.
Even though specific caching is simple and possible, I’m still leaning towards the nuclear option. The popover itself is the key window and e.g. screen reading would help navigate its content. But I don’t know what else might be breaking with that change.
Since it’s an auxiliary feature in my app (auto-completion of e.g. hashtags), I’d fine risking this to not break the text editor component in other ways.
As usual: Your mileage may vary!
If you happen to have any other ideas or insights, please share in the comments or via email.