From 3999685f8c5b4f805a47033e7762da7b984942a4 Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Fri, 16 Oct 2026 13:19:46 -0400 Subject: [PATCH] Avoid isolated deinit for synchronous Markdown rendering helpers --- .../MarkdownTextBuilder/ListProcessor.swift | 3 ++ .../Supplements/InlineNode+Render.swift | 3 ++ .../RenderingLifetimeTests.swift | 49 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 Tests/MarkdownViewTests/RenderingLifetimeTests.swift diff --git a/Sources/MarkdownView/MarkdownTextBuilder/ListProcessor.swift b/Sources/MarkdownView/MarkdownTextBuilder/ListProcessor.swift index 15fee45..a4408f7 100644 --- a/Sources/MarkdownView/MarkdownTextBuilder/ListProcessor.swift +++ b/Sources/MarkdownView/MarkdownTextBuilder/ListProcessor.swift @@ -15,6 +15,9 @@ import MarkdownParser // MARK: - ListProcessor final class ListProcessor { + // Synchronous rendering must avoid the isolated-deinit back-deployment path. + nonisolated deinit {} + private let theme: MarkdownTheme private let context: MarkdownTextView.PreprocessedContent private let viewProvider: ReusableViewProvider diff --git a/Sources/MarkdownView/Supplements/InlineNode+Render.swift b/Sources/MarkdownView/Supplements/InlineNode+Render.swift index 2b021fe..198792b 100644 --- a/Sources/MarkdownView/Supplements/InlineNode+Render.swift +++ b/Sources/MarkdownView/Supplements/InlineNode+Render.swift @@ -18,6 +18,9 @@ import SwiftMath /// Cached attribute dictionaries to avoid repeated allocations. /// Keyed by theme identity (font + color) which changes infrequently. final class InlineAttributeCache { + // Synchronous rendering must avoid the isolated-deinit back-deployment path. + nonisolated deinit {} + let bodyAttributes: [NSAttributedString.Key: Any] let codeAttributes: [NSAttributedString.Key: Any] diff --git a/Tests/MarkdownViewTests/RenderingLifetimeTests.swift b/Tests/MarkdownViewTests/RenderingLifetimeTests.swift new file mode 100644 index 0000000..6a71558 --- /dev/null +++ b/Tests/MarkdownViewTests/RenderingLifetimeTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest +@testable import MarkdownView + +@MainActor +final class RenderingLifetimeTests: XCTestCase { + private enum LayoutContext { + @TaskLocal static var value = "outside" + } + + func testInlineAttributeCacheReleaseInsideSynchronousTaskLocalScope() { + checkSynchronousRelease { + InlineAttributeCache(theme: .default) + } + } + + func testListProcessorAndOwnedCacheReleaseInsideSynchronousTaskLocalScope() { + let theme = MarkdownTheme.default + let context = MarkdownTextView.PreprocessedContent(markdown: "- **First**\n- `Second`", theme: theme) + let viewProvider = ReusableViewProvider() + checkSynchronousRelease { + ListProcessor( + theme: theme, + viewProvider: viewProvider, + context: context, + bulletDrawing: nil, + numberedDrawing: nil, + checkboxDrawing: nil + ) + } + } + + // Keep this synchronous to match UIKit layout callbacks outside a Swift task. + private func checkSynchronousRelease(_ makeObject: () -> T) { + for _ in 0 ..< 100 { + LayoutContext.$value.withValue("layout") { + autoreleasepool { + var objects = [0: makeObject()] + weak var object: T? + object = objects[0] + objects.removeAll() + XCTAssertNil(object) + } + XCTAssertEqual(LayoutContext.value, "layout") + } + XCTAssertEqual(LayoutContext.value, "outside") + } + } +}