fix: keep WKWebView attached to window to prevent GPU process idle exit
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

The GPU process was exiting due to idle timeout between warmup and
first article open. Now the WKWebView stays attached to the window
(alpha=0, invisible) until first article use. makeUIView reparents
it to the article container via removeFromSuperview + addSubview.

Also: removed all debug logging (warmup, article open, WebView timing).

Confirmed by instrumentation:
- GPU process launches during warmup (1.3s, background)
- First article open: 22ms total, WebView finish: 3ms
- No user-visible freeze

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 00:48:04 -05:00
parent 127da8feaa
commit 1579633da0
3 changed files with 24 additions and 77 deletions

View File

@@ -95,9 +95,9 @@ struct MainTabView: View {
.task {
guard showReader else { return }
let renderer = ArticleRenderer.shared
// Window is now available ensure WKWebView is attached
// so GPU compositor launches during startup, not first article tap
renderer.ensureAttachedToWindow()
// Window is now available attach WKWebView to force GPU
// compositor launch during startup, not first article tap
renderer.attachToWindow()
await readerVM.loadInitial()
}
}

View File

@@ -13,7 +13,6 @@ struct ArticleView: View {
self.vm = vm
_currentEntry = State(initialValue: entry)
_articleContent = State(initialValue: entry.articleHTML)
print("[ART-OPEN] init contentLen=\(entry.articleHTML.count)")
}
var body: some View {
@@ -60,16 +59,12 @@ struct ArticleView: View {
}
}
.task {
let t0 = CFAbsoluteTimeGetCurrent()
print("[ART-OPEN] .task started")
let entryId = entry.id
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }),
!vm.entries[idx].isRead {
vm.entries[idx].status = "read"
}
currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry
print("[ART-OPEN] mark-read done +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
Task {
let api = ReaderAPI()
@@ -77,18 +72,12 @@ struct ArticleView: View {
vm.counters = try? await api.getCounters()
}
let t1 = CFAbsoluteTimeGetCurrent()
do {
let fullEntry = try await ReaderAPI().getEntry(id: entryId)
let t2 = CFAbsoluteTimeGetCurrent()
print("[ART-OPEN] getEntry returned +\(Int((t2-t0)*1000))ms contentLen=\(fullEntry.articleHTML.count)")
let fullHTML = fullEntry.articleHTML
if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
articleContent = fullHTML
print("[ART-OPEN] articleContent updated +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
} else {
print("[ART-OPEN] content NOT upgraded (existing=\(articleContent.count) new=\(fullHTML.count))")
}
var merged = fullEntry
@@ -97,10 +86,7 @@ struct ArticleView: View {
merged.starred = local.starred
}
currentEntry = merged
} catch {
print("[ART-OPEN] getEntry FAILED: \(error)")
}
print("[ART-OPEN] .task complete +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
} catch {}
}
}

View File

@@ -10,34 +10,17 @@ final class ArticleRenderer {
let webView: WKWebView
private init() {
let t0 = CFAbsoluteTimeGetCurrent()
print("[WARMUP] ArticleRenderer.init started")
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
// Create at screen size (not .zero) so WebKit initializes the
// GPU compositor during warmup, not on first article open.
let screenBounds = UIScreen.main.bounds
webView = WKWebView(frame: screenBounds, configuration: config)
// Create at screen size so WebKit initializes the GPU compositor
// during warmup, not on first article open.
webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = true
// Attach to the key window briefly to force GPU process launch.
// The WKWebView must be in a real window hierarchy for WebKit to
// spin up the GPU compositor. At .zero frame or detached, it skips
// GPU init causing a ~3s stall on first real display.
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.windows.first {
webView.alpha = 0 // invisible
window.addSubview(webView)
print("[WARMUP] attached to window")
} else {
print("[WARMUP] no window available yet — will attach later")
}
// Load realistic content to warm CSS parsing, font loading, layout
// Load realistic content to warm CSS, fonts, layout, image decoder
webView.loadHTMLString(ArticleHTMLBuilder.build(
title: "Warming up",
url: nil,
@@ -55,31 +38,23 @@ final class ArticleRenderer {
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="100" height="60">
"""
), baseURL: nil)
// Remove from window after a delay (GPU process stays alive)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak webView] in
webView?.removeFromSuperview()
webView?.alpha = 1
print("[WARMUP] detached from window +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
}
print("[WARMUP] init complete +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
}
/// Attach WKWebView to the app window (invisible) to force GPU process
/// launch. Must be called after window is available. Keeps the webview
/// attached — it will be moved to the article container on first use.
func attachToWindow() {
guard webView.window == nil,
let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.windows.first
else { return }
/// Call this after app window is available if init ran too early
func ensureAttachedToWindow() {
guard webView.window == nil else { return }
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.windows.first {
webView.alpha = 0
window.addSubview(webView)
print("[WARMUP] late-attached to window")
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.webView.removeFromSuperview()
self?.webView.alpha = 1
print("[WARMUP] late-detached from window")
}
}
// Keep attached (don't remove) so GPU process stays alive.
// The webview will be reparented to the article container
// via removeFromSuperview + addSubview in makeUIView.
}
}
@@ -89,7 +64,6 @@ struct ArticleWebView: UIViewRepresentable {
let html: String
func makeUIView(context: Context) -> UIView {
print("[ART-WV] makeUIView")
let container = UIView()
container.backgroundColor = .clear
@@ -115,8 +89,6 @@ struct ArticleWebView: UIViewRepresentable {
let isUpgrade = context.coordinator.lastHTML != nil
context.coordinator.lastHTML = newHTML
print("[ART-WV] updateUIView isUpgrade=\(isUpgrade) htmlLen=\(newHTML.count)")
if isUpgrade {
// Content upgrade (partial → full): swap only #article-body contents.
// Header + outer document structure stay intact. Scroll preserved.
@@ -178,7 +150,6 @@ struct ArticleWebView: UIViewRepresentable {
class Coordinator: NSObject, WKNavigationDelegate {
var lastHTML: String?
var loadStart: CFAbsoluteTime = 0
func webView(
_ webView: WKWebView,
@@ -193,15 +164,5 @@ struct ArticleWebView: UIViewRepresentable {
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
loadStart = CFAbsoluteTimeGetCurrent()
print("[ART-WV] didStartNavigation")
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let elapsed = Int((CFAbsoluteTimeGetCurrent() - loadStart) * 1000)
print("[ART-WV] didFinish +\(elapsed)ms")
}
}
}