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 { .task {
guard showReader else { return } guard showReader else { return }
let renderer = ArticleRenderer.shared let renderer = ArticleRenderer.shared
// Window is now available ensure WKWebView is attached // Window is now available attach WKWebView to force GPU
// so GPU compositor launches during startup, not first article tap // compositor launch during startup, not first article tap
renderer.ensureAttachedToWindow() renderer.attachToWindow()
await readerVM.loadInitial() await readerVM.loadInitial()
} }
} }

View File

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

View File

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