From 669f906c10fdfc280c4dc9cf77ada109420539c8 Mon Sep 17 00:00:00 2001 From: Yan Cheng Cheok Date: Fri, 31 Oct 2025 04:14:50 +0800 Subject: [PATCH] 1. Use removeScriptMessageHandlers to prevent memory leaks. 2. Recreate the web view when it turns blank. Reference: https://nevermeant.dev/handling-blank-wkwebviews/ We avoid using a timer-based solution since it's resource-intensive. Instead, we've observed that blank issues typically occur when the app is restored from the background. Therefore, a one-time check is performed during background restoration. --- Sources/MarkdownWebView/MarkdownWebView.swift | 136 ++++++++++++++++-- 1 file changed, 128 insertions(+), 8 deletions(-) diff --git a/Sources/MarkdownWebView/MarkdownWebView.swift b/Sources/MarkdownWebView/MarkdownWebView.swift index 2338f75..16808a7 100644 --- a/Sources/MarkdownWebView/MarkdownWebView.swift +++ b/Sources/MarkdownWebView/MarkdownWebView.swift @@ -32,9 +32,9 @@ import WebKit public func makeCoordinator() -> Coordinator { .init(parent: self) } #if os(macOS) - public func makeNSView(context: Context) -> CustomWebView { context.coordinator.platformView } + public func makeNSView(context: Context) -> UIView { context.coordinator.containerView } #elseif os(iOS) - public func makeUIView(context: Context) -> CustomWebView { context.coordinator.platformView } + public func makeUIView(context: Context) -> UIView { context.coordinator.containerView } #endif func updatePlatformView(_ platformView: CustomWebView, context _: Context) { @@ -43,9 +43,9 @@ import WebKit } #if os(macOS) - public func updateNSView(_ nsView: CustomWebView, context: Context) { updatePlatformView(nsView, context: context) } + public func updateNSView(_ nsView: UIView, context: Context) { updatePlatformView(context.coordinator.platformView, context: context) } #elseif os(iOS) - public func updateUIView(_ uiView: CustomWebView, context: Context) { updatePlatformView(uiView, context: context) } + public func updateUIView(_ uiView: UIView, context: Context) { updatePlatformView(context.coordinator.platformView, context: context) } #endif public func onLinkActivation(_ linkActivationHandler: @escaping (URL) -> Void) -> Self { @@ -56,15 +56,86 @@ import WebKit .init(markdownContent, customStylesheet: customStylesheet, linkActivationHandler: linkActivationHandler, renderedContentHandler: renderedContentHandler) } - public class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - let parent: MarkdownWebView - let platformView: CustomWebView + final class ContainerView: UIView { + weak var webView: CustomWebView? + override var intrinsicContentSize: CGSize { + // delegate intrinsic size to the web view + return webView?.intrinsicContentSize ?? .zero + } + } + + public class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + let containerView = ContainerView() + + private let parent: MarkdownWebView + var platformView: CustomWebView! + + deinit { + removeScriptMessageHandlers() + deinitWillEnterForegroundNotification() + } + init(parent: MarkdownWebView) { self.parent = parent - platformView = .init() + super.init() + self.initPlatformView() + + self.initWillEnterForegroundNotification() + } + + private func deinitWillEnterForegroundNotification() { + NotificationCenter.default.removeObserver(self, name: UIScene.willEnterForegroundNotification, object: nil) + } + + private func initWillEnterForegroundNotification() { + NotificationCenter.default.addObserver( + self, + selector: #selector(willEnterForeground), + name: UIScene.willEnterForegroundNotification, + object: nil + ) + } + + @objc func willEnterForeground(_ notification: Notification) { + performBlankCheck() + } + + private func performBlankCheck() { + // Avoid checking while page is still loading to reduce false positives + guard let webView = platformView, !webView.isLoading else { return } + + // Quick JS returning body text length (fast) + webView.evaluateJavaScript("document && document.body ? document.body.innerText.length : 0") { [weak self] result, error in + guard let self else { return } + if let error = error { + // JS errored — print debug (page might be blank / bad state) + //print("⚠️ MarkdownWebView blank-check JS error: \(error.localizedDescription)") + recreatePlatformViewAsync() + return + } + + if let len = result as? Int { + if len == 0 { + //print("⚠️ MarkdownWebView appears blank (body text length == 0).") + recreatePlatformViewAsync() + } else { + // optional: for debug you can print length + //print("✅ MarkdownWebView content length: \(len)") + } + } else { + // unexpected type, print for debugging + //print("⚠️ MarkdownWebView blank-check unexpected result: \(String(describing: result))") + recreatePlatformViewAsync() + } + } + } + + private func initPlatformView() { + platformView = .init() + platformView.navigationDelegate = self #if DEBUG && os(iOS) @@ -114,6 +185,52 @@ import WebKit .replacingOccurrences(of: "PLACEHOLDER_SCRIPT", with: script) .replacingOccurrences(of: "PLACEHOLDER_STYLESHEET", with: self.parent.customStylesheet ?? defaultStylesheet) platformView.loadHTMLString(htmlString, baseURL: nil) + + attach(platformView, to: containerView) + } + + private func recreatePlatformViewAsync() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard let platformView else { return } + + removeScriptMessageHandlers() + + detach(platformView) + + initPlatformView() + } + } + + private func removeScriptMessageHandlers() { + platformView.configuration.userContentController.removeScriptMessageHandler( + forName: "sizeChangeHandler") + platformView.configuration.userContentController.removeScriptMessageHandler( + forName: "renderedContentHandler") + platformView.configuration.userContentController.removeScriptMessageHandler( + forName: "copyToPasteboard") + } + + private func attach(_ webView: CustomWebView, to container: ContainerView) { + webView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(webView) + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + container.webView = webView + } + + private func detach(_ webView: CustomWebView) { + webView.removeFromSuperview() + } + + public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + //print("⚠️ Process terminated!") + recreatePlatformViewAsync() } /// Update the content on first finishing loading. @@ -151,6 +268,9 @@ import WebKit else { return } platformView.contentHeight = contentHeight platformView.invalidateIntrinsicContentSize() + + containerView.invalidateIntrinsicContentSize() + case "renderedContentHandler": guard let renderedContentHandler = parent.renderedContentHandler, let renderedContentBase64Encoded = message.body as? String,