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
// 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("<html><body></body></html>", 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 </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 {
var lastHTML: String?