diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift index 338b2b1..dc12939 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -2,10 +2,6 @@ import SwiftUI import WebKit // MARK: - Shared Article Renderer -// -// Single WKWebView instance with shared WKWebViewConfiguration. -// Created once at app launch. Reused for every article. -// WKWebView handles its own scrolling — no height measurement needed. @MainActor final class ArticleRenderer { @@ -13,26 +9,28 @@ final class ArticleRenderer { let webView: WKWebView + // Explicit shared process pool — guarantees all WebViews share + // one WebContent process, even though iOS 15+ does this by default. + // Being explicit prevents accidental divergence if a second + // WKWebView is ever created elsewhere. + static let processPool = WKProcessPool() + private init() { let config = WKWebViewConfiguration() + config.processPool = ArticleRenderer.processPool config.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false webView.backgroundColor = .clear - // WKWebView handles its own scrolling — no SwiftUI ScrollView wrapper webView.scrollView.isScrollEnabled = true - // Pre-warm: spin up WebContent process + // Pre-warm: spin up WebContent process at app launch webView.loadHTMLString("
", baseURL: nil) } } // MARK: - Article Web View -// -// Wraps the shared WKWebView in a container UIView. -// SwiftUI owns the container lifecycle, not the WKWebView's. -// No height binding — WKWebView scrolls natively. struct ArticleWebView: UIViewRepresentable { let html: String @@ -55,9 +53,36 @@ struct ArticleWebView: UIViewRepresentable { let webView = ArticleRenderer.shared.webView webView.navigationDelegate = context.coordinator - if context.coordinator.lastHTML != html { - context.coordinator.lastHTML = html - webView.loadHTMLString(html, baseURL: nil) + let newHTML = html + + // Only reload if content meaningfully changed + guard context.coordinator.lastHTML != newHTML else { return } + + let isUpgrade = context.coordinator.lastHTML != nil + context.coordinator.lastHTML = newHTML + + if isUpgrade { + // Content upgrade (partial → full): capture scroll, replace body, restore scroll. + // Uses JavaScript DOM replacement instead of full page reload to avoid flash. + let escapedBody = extractBody(from: newHTML) + let js = """ + (function() { + var scrollY = window.scrollY; + var header = document.querySelector('.article-header'); + var headerHTML = header ? header.outerHTML : ''; + document.body.innerHTML = headerHTML + \(escapedBody); + window.scrollTo(0, scrollY); + })(); + """ + webView.evaluateJavaScript(js) { _, error in + // If JS replacement fails (e.g. different page structure), fall back to full reload + if error != nil { + webView.loadHTMLString(newHTML, baseURL: nil) + } + } + } else { + // First load — full page load + webView.loadHTMLString(newHTML, baseURL: nil) } } @@ -65,6 +90,24 @@ struct ArticleWebView: UIViewRepresentable { Coordinator() } + /// Extract the content after ... as a JS string literal + private func extractBody(from html: String) -> String { + // Find body content between and + if let bodyStart = html.range(of: ""), + let bodyEnd = html.range(of: "") { + let bodyContent = String(html[bodyStart.upperBound..