From e9980142a8bf9ff2dc006a1ea7625d38c0274e6d Mon Sep 17 00:00:00 2001 From: Vincent Peng Date: Thu, 8 Oct 2026 22:51:06 +1100 Subject: [PATCH 1/4] Enable speculative rewrite in StreamedMarkdownView The partial-emphasis and partial-table rewriters were built for the streaming path but never wired up: the parse(text:config:) convenience hardcodes speculativeRewrite: false, so streamed snapshots ending in an unclosed emphasis marker or a partial table header rendered literal jitter text. StreamedMarkdownController now parses each snapshot with speculativeRewrite: true, matching the rewriters' intended use. MarkdownView (complete documents) keeps the literal behavior so trailing markers in static text are not eaten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarkdownText/StreamedMarkdownView.swift | 9 +++- .../StreamedMarkdownControllerTests.swift | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift diff --git a/Sources/MarkdownText/StreamedMarkdownView.swift b/Sources/MarkdownText/StreamedMarkdownView.swift index f55e82c1..5a368af4 100644 --- a/Sources/MarkdownText/StreamedMarkdownView.swift +++ b/Sources/MarkdownText/StreamedMarkdownView.swift @@ -87,8 +87,15 @@ final class StreamedMarkdownController: ObservableObject { guard let self else { return } for await text in self.source.text { if Task.isCancelled { return } - let renderable = await self.parser.parse(text: text, config: self.config) + let result = await self.parser.parse( + text: text, + option: .init( + speculativeRewrite: true, + imageSupport: self.config.imageConfig.enabled + ) + ) if Task.isCancelled { return } + let renderable = await RenderableDocument(document: result.document, config: self.config) await MainActor.run { self.markdownToRender = renderable } diff --git a/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift new file mode 100644 index 00000000..4211237b --- /dev/null +++ b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift @@ -0,0 +1,48 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +@testable import SwiftStreamingMarkdown +import XCTest + +final class StreamedMarkdownControllerTests: XCTestCase { + + private final class StubSource: StreamedMarkdownSource { + let text: AsyncStream + + init(snapshots: [String]) { + text = AsyncStream { continuation in + for snapshot in snapshots { + continuation.yield(snapshot) + } + continuation.finish() + } + } + } + + private func renderOnce(snapshots: [String], config: MarkdownRenderConfig = .default) async -> RenderableDocument { + let controller = StreamedMarkdownController(source: StubSource(snapshots: snapshots), config: config) + await controller.start() + for _ in 0..<200 where controller.markdownToRender.renderables.isEmpty { + try? await Task.sleep(ms: 5) + } + await controller.end() + return controller.markdownToRender + } + + func test_partial_trailing_strong_is_speculatively_closed() async { + let rendered = await renderOnce(snapshots: ["Yeah, this is **cool"]) + XCTAssertEqual(rendered.plainText, "Yeah, this is cool") + } + + func test_partial_table_header_is_hidden_until_complete() async { + let rendered = await renderOnce(snapshots: ["intro\n\n| Month | Savings |"]) + XCTAssertFalse(rendered.plainText.contains("Month")) + } + + func test_complete_markdown_renders_unchanged() async { + let rendered = await renderOnce(snapshots: ["Hello **world**"]) + XCTAssertEqual(rendered.plainText, "Hello world") + } +} From 4bd01af60f2cf7fd28d9db6503c604ed7537af86 Mon Sep 17 00:00:00 2001 From: Vincent Peng Date: Thu, 8 Oct 2026 22:58:21 +1100 Subject: [PATCH 2/4] Settle final streamed snapshot without speculative rewrite Review feedback: speculative rewriting also applied to the last emission, so a completed stream ending in a deliberate trailing marker ("2 * 3", a literal "|" line) stayed munged forever. After the source finishes, re-render the final snapshot literally. Also expose speculativeRewrite on parse(text:config:) so both call sites share the option bridging, and opt the sample chat demo's streaming path in (literal on the final chunk). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LLMChatInteractor.swift | 7 +- .../MarkdownText/Parser/MarkdownParser.swift | 8 +- .../MarkdownText/StreamedMarkdownView.swift | 22 +++--- .../StreamedMarkdownControllerTests.swift | 73 ++++++++++++++----- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/LLMChatInteractor.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/LLMChatInteractor.swift index d5f8c34c..2a482a5d 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/LLMChatInteractor.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/LLMChatInteractor.swift @@ -53,7 +53,12 @@ final class LLMChatInteractor: ObservableObject { ) ?? markdown.endIndex let snapshot = String(markdown[.. RenderableDocument { + public func parse(text: String, config: MarkdownRenderConfig, speculativeRewrite: Bool = false) async -> RenderableDocument { let document = await parse( text: text, - option: .init(speculativeRewrite: false, imageSupport: config.imageConfig.enabled) + option: .init(speculativeRewrite: speculativeRewrite, imageSupport: config.imageConfig.enabled) ).document return await RenderableDocument(document: document, config: config) } diff --git a/Sources/MarkdownText/StreamedMarkdownView.swift b/Sources/MarkdownText/StreamedMarkdownView.swift index 5a368af4..6effcd7e 100644 --- a/Sources/MarkdownText/StreamedMarkdownView.swift +++ b/Sources/MarkdownText/StreamedMarkdownView.swift @@ -69,7 +69,7 @@ final class StreamedMarkdownController: ObservableObject { private let source: StreamedMarkdownSource private let parser = MarkdownParserImpl() - private var task: Task? + var task: Task? init( source: StreamedMarkdownSource, @@ -85,17 +85,21 @@ final class StreamedMarkdownController: ObservableObject { task?.cancel() task = Task { [weak self] in guard let self else { return } + var lastText: String? for await text in self.source.text { if Task.isCancelled { return } - let result = await self.parser.parse( - text: text, - option: .init( - speculativeRewrite: true, - imageSupport: self.config.imageConfig.enabled - ) - ) + let renderable = await self.parser.parse(text: text, config: self.config, speculativeRewrite: true) + lastText = text + if Task.isCancelled { return } + await MainActor.run { + self.markdownToRender = renderable + } + } + // The stream completed: re-render the final snapshot without speculative + // rewriting so deliberate trailing markers are not eaten. + if let lastText { + let renderable = await self.parser.parse(text: lastText, config: self.config) if Task.isCancelled { return } - let renderable = await RenderableDocument(document: result.document, config: self.config) await MainActor.run { self.markdownToRender = renderable } diff --git a/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift index 4211237b..8bfd298f 100644 --- a/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift +++ b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift @@ -6,43 +6,76 @@ @testable import SwiftStreamingMarkdown import XCTest +@MainActor final class StreamedMarkdownControllerTests: XCTestCase { private final class StubSource: StreamedMarkdownSource { let text: AsyncStream - init(snapshots: [String]) { - text = AsyncStream { continuation in - for snapshot in snapshots { - continuation.yield(snapshot) - } - continuation.finish() - } + init(stream: AsyncStream) { + self.text = stream } } - private func renderOnce(snapshots: [String], config: MarkdownRenderConfig = .default) async -> RenderableDocument { - let controller = StreamedMarkdownController(source: StubSource(snapshots: snapshots), config: config) - await controller.start() + private func makeStream() -> (AsyncStream, AsyncStream.Continuation) { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { continuation = $0 } + return (stream, continuation) + } + + private func waitForRender(_ controller: StreamedMarkdownController) async { for _ in 0..<200 where controller.markdownToRender.renderables.isEmpty { try? await Task.sleep(ms: 5) } + } + + func test_partial_trailing_strong_is_speculatively_closed_midstream() async { + let (stream, continuation) = makeStream() + let controller = StreamedMarkdownController(source: StubSource(stream: stream), config: .default) + await controller.start() + + continuation.yield("Yeah, this is **cool") + await waitForRender(controller) + XCTAssertEqual(controller.markdownToRender.plainText, "Yeah, this is cool") + + continuation.yield("Yeah, this is **cooler**.") + continuation.finish() + await controller.task?.value + XCTAssertEqual(controller.markdownToRender.plainText, "Yeah, this is cooler.") await controller.end() - return controller.markdownToRender } - func test_partial_trailing_strong_is_speculatively_closed() async { - let rendered = await renderOnce(snapshots: ["Yeah, this is **cool"]) - XCTAssertEqual(rendered.plainText, "Yeah, this is cool") + func test_literal_trailing_marker_is_restored_when_stream_completes() async { + let (stream, continuation) = makeStream() + let controller = StreamedMarkdownController(source: StubSource(stream: stream), config: .default) + await controller.start() + + // Mid-stream the trailing "*" is speculatively treated as emphasis. + continuation.yield("2 * 3") + await waitForRender(controller) + XCTAssertEqual(controller.markdownToRender.plainText, "2 3") + + // Once the stream finishes the final snapshot re-renders literally. + continuation.finish() + await controller.task?.value + XCTAssertEqual(controller.markdownToRender.plainText, "2 * 3") + await controller.end() } func test_partial_table_header_is_hidden_until_complete() async { - let rendered = await renderOnce(snapshots: ["intro\n\n| Month | Savings |"]) - XCTAssertFalse(rendered.plainText.contains("Month")) - } + let (stream, continuation) = makeStream() + let controller = StreamedMarkdownController(source: StubSource(stream: stream), config: .default) + await controller.start() - func test_complete_markdown_renders_unchanged() async { - let rendered = await renderOnce(snapshots: ["Hello **world**"]) - XCTAssertEqual(rendered.plainText, "Hello world") + continuation.yield("intro\n\n| Month | Savings |") + await waitForRender(controller) + XCTAssertTrue(controller.markdownToRender.plainText.contains("intro")) + XCTAssertFalse(controller.markdownToRender.plainText.contains("Month")) + + continuation.yield("intro\n\n| Month | Savings |\n| ----- | ------- |\n| Jan | 100 |") + continuation.finish() + await controller.task?.value + XCTAssertTrue(controller.markdownToRender.plainText.contains("Month")) + await controller.end() } } From 9eb861e70c8314c23ddb3a170ff0160c9b2d0f9c Mon Sep 17 00:00:00 2001 From: Vincent Peng Date: Fri, 9 Oct 2026 00:40:07 +1100 Subject: [PATCH 3/4] Address review follow-ups on streamed settle pass - Bail before the settle parse when the task was cancelled so a torn-down view does not pay for a render it will never publish. - Cover the cancellation invariant with a test asserting no republish after end(). - Replace the implicitly-unwrapped continuation with AsyncStream.makeStream in the test helper (force_unwrapping is a strict-mode lint rule). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../MarkdownText/StreamedMarkdownView.swift | 1 + .../StreamedMarkdownControllerTests.swift | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Sources/MarkdownText/StreamedMarkdownView.swift b/Sources/MarkdownText/StreamedMarkdownView.swift index 6effcd7e..3ab18fa3 100644 --- a/Sources/MarkdownText/StreamedMarkdownView.swift +++ b/Sources/MarkdownText/StreamedMarkdownView.swift @@ -97,6 +97,7 @@ final class StreamedMarkdownController: ObservableObject { } // The stream completed: re-render the final snapshot without speculative // rewriting so deliberate trailing markers are not eaten. + if Task.isCancelled { return } if let lastText { let renderable = await self.parser.parse(text: lastText, config: self.config) if Task.isCancelled { return } diff --git a/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift index 8bfd298f..b32ed33f 100644 --- a/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift +++ b/Tests/MarkdownTextTests/StreamedMarkdownControllerTests.swift @@ -18,9 +18,7 @@ final class StreamedMarkdownControllerTests: XCTestCase { } private func makeStream() -> (AsyncStream, AsyncStream.Continuation) { - var continuation: AsyncStream.Continuation! - let stream = AsyncStream { continuation = $0 } - return (stream, continuation) + return AsyncStream.makeStream(of: String.self) } private func waitForRender(_ controller: StreamedMarkdownController) async { @@ -62,6 +60,22 @@ final class StreamedMarkdownControllerTests: XCTestCase { await controller.end() } + func test_cancelled_controller_does_not_republish_on_stream_finish() async { + let (stream, continuation) = makeStream() + let controller = StreamedMarkdownController(source: StubSource(stream: stream), config: .default) + await controller.start() + + continuation.yield("2 * 3") + await waitForRender(controller) + XCTAssertEqual(controller.markdownToRender.plainText, "2 3") + + await controller.end() + continuation.finish() + try? await Task.sleep(ms: 50) + // The cancelled task must not publish the settled literal render. + XCTAssertEqual(controller.markdownToRender.plainText, "2 3") + } + func test_partial_table_header_is_hidden_until_complete() async { let (stream, continuation) = makeStream() let controller = StreamedMarkdownController(source: StubSource(stream: stream), config: .default) From f57ffe203c243ac175849ff0104482ac41ff85e8 Mon Sep 17 00:00:00 2001 From: Vincent Peng Date: Sat, 10 Oct 2026 12:35:31 +1100 Subject: [PATCH 4/4] Guard publish against cancellation during the main-actor hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found `Task.isCancelled` checked before `MainActor.run` does not cover cancellation that lands while suspended in the hop — the enqueued closure still ran and published a stale frame. Re-check inside the closure at both publish sites. Also narrows `task` to `private(set)` (read by tests only) and documents `speculativeRewrite` in the README streaming section. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 10 +++++++++- Sources/MarkdownText/StreamedMarkdownView.swift | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efae63a8..b20863f1 100644 --- a/README.md +++ b/README.md @@ -236,9 +236,17 @@ struct ChatBubble: View { } ``` +Mid-stream snapshots are parsed with speculative rewriting enabled — +half-typed emphasis and partial table headers are repaired so text doesn't +jitter — and the final snapshot is re-parsed literally once the stream +completes, so a deliberate trailing `*` is restored. + If you'd rather drive `DocumentView` directly, parse each snapshot with `MarkdownParser.parse(text:config:)` and feed the resulting -`RenderableDocument` into your view yourself. +`RenderableDocument` into your view yourself. Pass +`speculativeRewrite: true` for incomplete snapshots and keep the default +`false` for the completed document — the same behavior +`StreamedMarkdownView` applies internally. The bundled [sample app](Examples/SwiftStreamingMarkdownSample) demonstrates chunked streaming end-to-end with adjustable chunk size and interval, plus diff --git a/Sources/MarkdownText/StreamedMarkdownView.swift b/Sources/MarkdownText/StreamedMarkdownView.swift index 3ab18fa3..fd2ade5e 100644 --- a/Sources/MarkdownText/StreamedMarkdownView.swift +++ b/Sources/MarkdownText/StreamedMarkdownView.swift @@ -69,7 +69,8 @@ final class StreamedMarkdownController: ObservableObject { private let source: StreamedMarkdownSource private let parser = MarkdownParserImpl() - var task: Task? + /// The streaming task, exposed read-only so tests can await completion. + private(set) var task: Task? init( source: StreamedMarkdownSource, @@ -92,6 +93,8 @@ final class StreamedMarkdownController: ObservableObject { lastText = text if Task.isCancelled { return } await MainActor.run { + // Cancellation during the hop must not publish a stale frame. + guard !Task.isCancelled else { return } self.markdownToRender = renderable } } @@ -102,6 +105,7 @@ final class StreamedMarkdownController: ObservableObject { let renderable = await self.parser.parse(text: lastText, config: self.config) if Task.isCancelled { return } await MainActor.run { + guard !Task.isCancelled else { return } self.markdownToRender = renderable } }