From f10c356199b2f078e52d6ee281c31fa30b3464d8 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 21:42:59 -0500 Subject: [PATCH] polish: explicit WKProcessPool, scroll-preserving content upgrade, no reload flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Explicit WKProcessPool — static shared instance assigned to WKWebViewConfiguration. Prevents any future divergence even though iOS 15+ shares by default. 2. Scroll-preserving content upgrade — when articleContent updates (partial → full), uses JavaScript DOM replacement instead of loadHTMLString. Captures window.scrollY before swap, restores after. No visible flash or scroll jump. Falls back to full reload if JS replacement fails. 3. No unnecessary reloads — coordinator tracks lastHTML. Only loads if content actually changed. First article open = full page load (lastHTML is nil). Content upgrade = DOM swap (lastHTML exists, new content is different). 4. Clean separation — isUpgrade flag distinguishes first load from content upgrade. First load uses loadHTMLString (needs full document). Upgrade uses innerHTML replacement (preserves scroll, CSS, page state). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Reader/Views/ArticleWebView.swift | 69 +++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) 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..