polish: explicit WKProcessPool, scroll-preserving content upgrade, no reload flash
All checks were successful
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / dockerfile-lint (push) Successful in 4s

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 <html> document). Upgrade uses innerHTML replacement
   (preserves scroll, CSS, page state).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 21:42:59 -05:00
parent 8ae1d48d68
commit f10c356199

View File

@@ -2,10 +2,6 @@ import SwiftUI
import WebKit import WebKit
// MARK: - Shared Article Renderer // 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 @MainActor
final class ArticleRenderer { final class ArticleRenderer {
@@ -13,26 +9,28 @@ final class ArticleRenderer {
let webView: WKWebView 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() { private init() {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.processPool = ArticleRenderer.processPool
config.allowsInlineMediaPlayback = true config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config) webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = false webView.isOpaque = false
webView.backgroundColor = .clear webView.backgroundColor = .clear
// WKWebView handles its own scrolling no SwiftUI ScrollView wrapper
webView.scrollView.isScrollEnabled = true webView.scrollView.isScrollEnabled = true
// Pre-warm: spin up WebContent process // Pre-warm: spin up WebContent process at app launch
webView.loadHTMLString("<html><body></body></html>", baseURL: nil) webView.loadHTMLString("<html><body></body></html>", baseURL: nil)
} }
} }
// MARK: - Article Web View // 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 { struct ArticleWebView: UIViewRepresentable {
let html: String let html: String
@@ -55,9 +53,36 @@ struct ArticleWebView: UIViewRepresentable {
let webView = ArticleRenderer.shared.webView let webView = ArticleRenderer.shared.webView
webView.navigationDelegate = context.coordinator webView.navigationDelegate = context.coordinator
if context.coordinator.lastHTML != html { let newHTML = html
context.coordinator.lastHTML = html
webView.loadHTMLString(html, baseURL: nil) // 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() Coordinator()
} }
/// Extract the content after </head><body>...</body> as a JS string literal
private func extractBody(from html: String) -> String {
// Find body content between <body> and </body>
if let bodyStart = html.range(of: "<body>"),
let bodyEnd = html.range(of: "</body>") {
let bodyContent = String(html[bodyStart.upperBound..<bodyEnd.lowerBound])
// Escape for JavaScript string literal
let escaped = bodyContent
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "")
return "'\(escaped)'"
}
// Fallback: can't parse, return empty
return "''"
}
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
var lastHTML: String? var lastHTML: String?