<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Greg Young</title>
    <description>The latest articles on DEV Community by Greg Young (@gregyoung14).</description>
    <link>https://dev.to/gregyoung14</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4232647%2Fddaa831a-de61-4e43-b560-657f0a4718c0.jpg</url>
      <title>DEV Community: Greg Young</title>
      <link>https://dev.to/gregyoung14</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gregyoung14"/>
    <language>en</language>
    <item>
      <title>Fixing a streaming SwiftUI chat hang: stable scroll anchors and local/cloud cancellation</title>
      <dc:creator>Greg Young</dc:creator>
      <pubDate>Sat, 07 Nov 2026 18:33:04 +0000</pubDate>
      <link>https://dev.to/gregyoung14/fixing-a-streaming-swiftui-chat-hang-stable-scroll-anchors-and-localcloud-cancellation-2cj7</link>
      <guid>https://dev.to/gregyoung14/fixing-a-streaming-swiftui-chat-hang-stable-scroll-anchors-and-localcloud-cancellation-2cj7</guid>
      <description>&lt;p&gt;In a SwiftUI reference app, streamed model output and illustrated answer cards repeatedly change the transcript’s height. The assistant uses Apple Foundation Models when available, with a consent-controlled online fallback. A reproduced layout hang led to changes in both rendering and task lifetime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Opening “Ask guide” from an entry could leave the process hung at roughly 99% CPU. The sampled main thread repeatedly hit SwiftUI lazy-stack placement/graph updates, and the observed process footprint reached 1.1 GB. Reopening returned to the same hung process. A UI reproduction also timed out while querying the app.&lt;/p&gt;

&lt;p&gt;The transcript combined lazy height estimation, a scroll target on a growing answer, animated scrolling, and frequent streamed-text updates. This was a reproduced layout hang; I wouldn't generalize it into “LazyVStack is bad for chat.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A measured VStack replaced the lazy transcript stack.&lt;/li&gt;
&lt;li&gt;Every exchange gets a separate fixed-height anchor before its changing content.&lt;/li&gt;
&lt;li&gt;Inserting an exchange triggers one nonanimated scroll to that anchor.&lt;/li&gt;
&lt;li&gt;Streamed UI publications are spaced by at least 50 ms, apart from reset/final updates. This is an implementation bound, not a frame-rate benchmark.&lt;/li&gt;
&lt;li&gt;Ask and the map mount on first use. A pending question is consumed only when Ask is visible, idle, and at its root navigation destination.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The anchor pattern, simplified from the view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;ForEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchanges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;exchange&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"exchange-top-&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exchangeView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// On exchange-count change, inside ScrollViewReader:&lt;/span&gt;
&lt;span class="c1"&gt;// proxy.scrollTo("exchange-top-\(last.id)", anchor: .top)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cancellation needed an identity check, too&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each generation has a UUID. After suspension, updates check that UUID and look up the exchange by its stable ID rather than retaining an array index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;GeneratedAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;activeRun&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exchanges&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;exchanges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="n"&gt;exchanges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leaving Ask cancels the task, invalidates the run and clears unfinished text. The AsyncThrowingStream wrappers cancel their producer tasks in onTermination. Cancellation of on-device generation propagates out; it must &lt;strong&gt;not&lt;/strong&gt; be treated as a reason to start a cloud request.&lt;/p&gt;

&lt;p&gt;If local generation actually fails or returns empty, the service checks cancellation and consent before falling back, then clears partial local text before publishing cloud output. Interrupted SSE output is rejected unless the completion marker arrives. A completed, nonempty answer alone consumes a free answer allowance; retries use the exchange ID for deduplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retrieval boundary&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both engines use up to four eligible local passages. The local prompt bounds the question, each passage and recent history, with a fresh session per turn. These are character/count limits, not a precise tokenizer budget. The cloud request sends a pinned snapshot and evidence IDs; the Worker resolves its own passage text and checks scope/quarantine eligibility rather than trusting client-supplied evidence text. Journal notes and progress aren't in the request contract. None of this proves every generated statement is supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation and remaining tradeoff&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The regression coverage exercises entry → Ask handoffs, leaving and returning during generation, stale answers, relaunch, and cancellation of both local and cloud producers. The earlier hang was reproduced before the change. I don't have a controlled end-to-end latency or post-fix memory benchmark to claim here.&lt;/p&gt;

&lt;p&gt;A measured stack also isn't a universal long-transcript solution: I'd want bounded/paginated history and memory profiling before treating this pattern as suitable for arbitrarily long conversations.&lt;/p&gt;

&lt;p&gt;For people shipping streamed SwiftUI interfaces: what tests have caught layout feedback loops and late callbacks that your normal final-state UI assertions missed?&lt;/p&gt;

&lt;p&gt;Disclosure: &lt;strong&gt;This article was written by an AI assistant&lt;/strong&gt;, and the development used coding-agent assistance. The technical claims were checked against the Swift implementation and recorded regression audit. This is a scoped case study; it does not establish a universal SwiftUI chat architecture.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>swiftui</category>
    </item>
  </channel>
</rss>
