From 1579633da01c51dd992ba29657ec064bafe115d1 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 00:48:04 -0500 Subject: [PATCH] fix: keep WKWebView attached to window to prevent GPU process idle exit 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) --- ios/Platform/Platform/ContentView.swift | 6 +- .../Features/Reader/Views/ArticleView.swift | 18 +---- .../Reader/Views/ArticleWebView.swift | 77 +++++-------------- 3 files changed, 24 insertions(+), 77 deletions(-) diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index 61d28fe..35db154 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -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() } } diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index f425ec0..577e6cd 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -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 {} } } diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift index 99666b7..79da55b 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -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 { """ ), 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 - 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") - } - } + /// 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 } + + webView.alpha = 0 + window.addSubview(webView) + + // 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") - } } }