From b5cc890175b46f07c7238af09beea97fd7dfba20 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Wed, 1 Jul 2026 15:04:24 +0200 Subject: [PATCH] feat(ios): add async parse and render for EnrichedMarkdownText Move markdown parsing and attributed-string rendering to a serial background queue and apply results on the main thread, matching the RN iOS async render coordinator pattern. Co-authored-by: Cursor --- .../Rendering/AsyncRenderCoordinator.swift | 38 ++++++++ .../Views/EnrichedMarkdownText.swift | 27 +++++- .../Views/MarkdownRenderStore.swift | 34 +++++++ .../AsyncRenderCoordinatorTests.swift | 89 +++++++++++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AsyncRenderCoordinator.swift create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift create mode 100644 packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/AsyncRenderCoordinatorTests.swift diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AsyncRenderCoordinator.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AsyncRenderCoordinator.swift new file mode 100644 index 000000000..6b465d96d --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/AsyncRenderCoordinator.swift @@ -0,0 +1,38 @@ +import Foundation +import UIKit + +final class AsyncRenderCoordinator { + var blockAsyncRender = false + + private let queue: DispatchQueue + private var currentRenderId: UInt = 0 + + init(queueLabel: String = "com.swmansion.enriched.markdown.render") { + queue = DispatchQueue(label: queueLabel) + } + + func scheduleRender( + _ render: @escaping () -> NSAttributedString?, + apply: @escaping (NSAttributedString) -> Void + ) { + if blockAsyncRender { + return + } + + currentRenderId += 1 + let renderId = currentRenderId + + queue.async { [weak self] in + guard let result = render() else { return } + + DispatchQueue.main.async { + guard let self, renderId == self.currentRenderId else { return } + apply(result) + } + } + } + + func invalidate() { + currentRenderId += 1 + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift index 225c917b2..1a68ba33d 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift @@ -4,16 +4,39 @@ import UIKit public struct EnrichedMarkdownText: View { private let markdown: String - @Environment(\.markdownStyleConfig) private var styleConfig + @Environment(\.markdownThemeLayers) private var themeLayers + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @StateObject private var renderStore = MarkdownRenderStore() public init(_ markdown: String) { self.markdown = markdown } + private var styleConfig: MarkdownStyleConfig { + let traitCollection = ThemeResolver.traitCollection( + colorScheme: colorScheme, + dynamicTypeSize: dynamicTypeSize + ) + return MarkdownStyleConfig.resolve(layers: themeLayers, traitCollection: traitCollection) + } + public var body: some View { MarkdownTextViewRepresentable( - attributedText: MarkdownRenderer.render(markdown, config: styleConfig) + attributedText: renderStore.attributedText ) .fixedSize(horizontal: false, vertical: true) + .onAppear { + renderStore.schedule(markdown: markdown, config: styleConfig) + } + .onChange(of: markdown) { newValue in + renderStore.schedule(markdown: newValue, config: styleConfig) + } + .onChange(of: styleConfig) { newValue in + renderStore.schedule(markdown: markdown, config: newValue) + } + .onDisappear { + renderStore.invalidate() + } } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift new file mode 100644 index 000000000..7f9a2ae35 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift @@ -0,0 +1,34 @@ +import SwiftUI +import UIKit + +@MainActor +final class MarkdownRenderStore: ObservableObject { + @Published private(set) var attributedText = NSAttributedString() + + private let coordinator = AsyncRenderCoordinator() + + func schedule( + markdown: String, + config: MarkdownStyleConfig, + flags: Md4cFlags = .commonMark + ) { + if isBlank(markdown) { + attributedText = NSAttributedString() + return + } + + coordinator.scheduleRender { + MarkdownRenderer.render(markdown, config: config, flags: flags) + } apply: { [weak self] result in + self?.attributedText = result + } + } + + func invalidate() { + coordinator.invalidate() + } + + private func isBlank(_ markdown: String) -> Bool { + markdown.isEmpty || markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/AsyncRenderCoordinatorTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/AsyncRenderCoordinatorTests.swift new file mode 100644 index 000000000..26fe13dc5 --- /dev/null +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/AsyncRenderCoordinatorTests.swift @@ -0,0 +1,89 @@ +import UIKit +import XCTest +@testable import EnrichedMarkdown + +final class AsyncRenderCoordinatorTests: XCTestCase { + func testApplyRunsOnMainThread() { + let coordinator = AsyncRenderCoordinator() + let expectation = expectation(description: "apply on main") + var appliedOnMain = false + + coordinator.scheduleRender({ + NSAttributedString(string: "hello") + }, apply: { _ in + appliedOnMain = Thread.isMainThread + expectation.fulfill() + }) + + waitForExpectations(timeout: 2) + XCTAssertTrue(appliedOnMain) + } + + func testSecondRenderSupersedesFirst() { + let coordinator = AsyncRenderCoordinator() + let firstApply = expectation(description: "first apply") + firstApply.isInverted = true + let secondApply = expectation(description: "second apply") + + coordinator.scheduleRender({ + Thread.sleep(forTimeInterval: 0.05) + return NSAttributedString(string: "first") + }, apply: { _ in + firstApply.fulfill() + }) + + coordinator.scheduleRender({ + NSAttributedString(string: "second") + }, apply: { _ in + secondApply.fulfill() + }) + + wait(for: [firstApply, secondApply], timeout: 2, enforceOrder: false) + } + + func testInvalidateDropsPendingApply() { + let coordinator = AsyncRenderCoordinator() + let applyExpectation = expectation(description: "apply") + applyExpectation.isInverted = true + + coordinator.scheduleRender({ + Thread.sleep(forTimeInterval: 0.05) + return NSAttributedString(string: "stale") + }, apply: { _ in + applyExpectation.fulfill() + }) + + coordinator.invalidate() + + waitForExpectations(timeout: 0.2) + } + + func testBlockAsyncRenderSkipsDispatch() { + let coordinator = AsyncRenderCoordinator() + coordinator.blockAsyncRender = true + let applyExpectation = expectation(description: "apply") + applyExpectation.isInverted = true + + coordinator.scheduleRender({ + NSAttributedString(string: "blocked") + }, apply: { _ in + applyExpectation.fulfill() + }) + + waitForExpectations(timeout: 0.1) + } + + func testNilRenderResultSkipsApply() { + let coordinator = AsyncRenderCoordinator() + let applyExpectation = expectation(description: "apply") + applyExpectation.isInverted = true + + coordinator.scheduleRender({ + nil + }, apply: { _ in + applyExpectation.fulfill() + }) + + waitForExpectations(timeout: 0.2) + } +}