@@ -15,6 +15,30 @@ import SwiftUI
1515// Runs with attachments are converted to placeholder images sized by the attachment's
1616// sizeThatFits(_:in:) result. Placeholders are tagged with AttachmentAttribute so overlays
1717// can identify and render the actual attachment views at the resolved layout positions.
18+ //
19+ // MARK: - Recursion-safe Text construction
20+ //
21+ // Only attachment placeholders and link runs need to be standalone Text nodes — they
22+ // rely on `customAttribute(AttachmentAttribute(...))` and `customAttribute(LinkAttribute(...))`
23+ // markers that cannot live inside an AttributedString. Every other run carries standard
24+ // AttributedString attributes (foregroundColor, font, presentationIntent, syntax-highlight
25+ // theme tokens, etc.) which Text(_ attributedString:) preserves natively.
26+ //
27+ // Building one Text per run and then `Text("\(prev)\(next)")`-reducing them into a single
28+ // value produces a LocalizedTextStorage tree whose depth equals the run count. SwiftUI
29+ // resolves that tree recursively at layout time, so a code block with thousands of Prism
30+ // tokens (or any AttributedString with thousands of runs) blows the main-thread stack at
31+ // resolve time — the "Thread stack size exceeded due to excessive recursion" crash.
32+ //
33+ // Instead, consecutive plain runs are coalesced into a single Text(_ attributedString:)
34+ // and concatenated with the `+` operator only across attachment / link boundaries. The
35+ // resulting Text-node count is bounded by `attachments + links + 1` rather than the run
36+ // count, and the concatenation uses ConcatenatedTextStorage instead of LocalizedTextStorage,
37+ // which avoids the localization-machinery walk entirely.
38+ //
39+ // The final `+`-merge is balanced (pairwise) so the ConcatenatedTextStorage tree is
40+ // O(log N) deep, not O(N). A left-fold reduce would re-introduce the same crash class
41+ // in a different guise for content with thousands of attachments / links.
1842
1943extension TextFragment {
2044 @MainActor @Observable final class TextBuilder {
@@ -64,41 +88,87 @@ extension Text {
6488 attachmentSizes: [ AttachmentKey : CGSize ] ,
6589 in environment: TextEnvironmentValues
6690 ) {
67- let textValues = attributedString . runs . map { run in
68- var text : Text
91+ var pieces : [ Text ] = [ ]
92+ var pending = AttributedString ( )
6993
70- var runEnvironment = environment
71- runEnvironment. font = run. font ?? environment. font
94+ func flushPending( ) {
95+ guard !pending. runs. isEmpty else { return }
96+ pieces. append ( Text ( pending) )
97+ pending = AttributedString ( )
98+ }
7299
73- let key = run. textual. attachment. map {
74- AttachmentKey ( attachment: $0, font: runEnvironment. font)
100+ for run in attributedString. runs {
101+ let attachment = run. textual. attachment
102+ let link = run. link
103+
104+ // Plain runs (no attachment, no link) carry only AttributedString attributes,
105+ // so they round-trip cleanly through Text(_ attributedString:). Coalesce them
106+ // into `pending` and only flush at attachment / link boundaries. The 99% case
107+ // (every Prism token in a code block, every styled span in prose) lands here
108+ // and never builds a runEnvironment or a separate Text node.
109+ if attachment == nil , link == nil {
110+ pending. append ( AttributedString ( attributedString [ run. range] ) )
111+ continue
75112 }
76113
77- if let key, let size = attachmentSizes [ key] {
78- // Create placeholder
114+ flushPending ( )
115+
116+ var runEnvironment = environment
117+ runEnvironment. font = run. font ?? environment. font
118+
119+ var text : Text
120+ if let attachment,
121+ let size = attachmentSizes [ AttachmentKey ( attachment: attachment, font: runEnvironment. font) ]
122+ {
79123 text = Text ( placeholderSize: size)
80- . baselineOffset ( key . attachment. baselineOffset ( in: runEnvironment) )
124+ . baselineOffset ( attachment. baselineOffset ( in: runEnvironment) )
81125 . customAttribute (
82126 AttachmentAttribute (
83- key . attachment,
127+ attachment,
84128 presentationIntent: run. presentationIntent
85129 )
86130 )
87131 } else {
88132 text = Text ( AttributedString ( attributedString [ run. range] ) )
89133 }
90134
91- // Add link attribute for TextLinkInteraction
92- if let link = run. link {
135+ if let link {
93136 text = text. customAttribute ( LinkAttribute ( link) )
94137 }
95138
96- return text
139+ pieces . append ( text)
97140 }
98141
99- self = textValues. reduce ( Text ( verbatim: " " ) ) { partialResult, text in
100- Text ( " \( partialResult) \( text) " )
142+ flushPending ( )
143+
144+ self = Text . balancedConcatenation ( of: pieces)
145+ }
146+
147+ /// Pairwise merge of `pieces` using `+`, producing a `ConcatenatedTextStorage`
148+ /// tree of O(log N) depth instead of the O(N) depth a left-fold would yield.
149+ /// `+`-resolve still recurses, so a balanced tree is the difference between
150+ /// safely rendering thousands of inline pieces and overflowing the stack at
151+ /// layout time. The construction cost stays O(N).
152+ ///
153+ /// Internal (rather than fileprivate) so unit tests can exercise the helper
154+ /// directly without going through full `TextBuilder` construction.
155+ static func balancedConcatenation( of pieces: [ Text ] ) -> Text {
156+ guard !pieces. isEmpty else { return Text ( verbatim: " " ) }
157+ var level = pieces
158+ while level. count > 1 {
159+ var next : [ Text ] = [ ]
160+ next. reserveCapacity ( ( level. count + 1 ) / 2 )
161+ var index = 0
162+ while index + 1 < level. count {
163+ next. append ( level [ index] + level[ index + 1 ] )
164+ index += 2
165+ }
166+ if index < level. count {
167+ next. append ( level [ index] )
168+ }
169+ level = next
101170 }
171+ return level [ 0 ]
102172 }
103173
104174 private init ( placeholderSize size: CGSize ) {
0 commit comments