fix: mark-read on first open + eliminate long-article scroll freeze
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 3s

## Bug 1: First open didn't mark as read

ROOT CAUSE: Race condition. markAsRead set local status="read",
then getEntry returned the server's status="unread" (API sync
hadn't completed yet) and overwrote the local mutation.

FIX:
- markAsRead runs FIRST, before getEntry (was concurrent before)
- After getEntry, merge server response but PRESERVE local
  status/starred (which may differ from server due to race)
- currentEntry syncs from vm.entries after markAsRead, ensuring
  the toolbar reflects the correct state

## Bug 2: Long articles freeze before scrollable

ROOT CAUSE: WKWebView.scrollView.isScrollEnabled = false, embedded
inside SwiftUI ScrollView with .frame(height: webViewHeight).
For a 15000px article, WebKit had to render the entire document,
JavaScript measured document.body.scrollHeight, SwiftUI relaid out
the 15000px frame — all blocking before scroll became responsive.

FIX:
- WKWebView now handles its own scrolling (isScrollEnabled = true)
- Removed SwiftUI ScrollView wrapper around article
- Removed contentHeight binding and height measurement JavaScript
- Removed the Coordinator's didFinish height evaluation
- Article header (title, feed, time) moved into the HTML document
  so it scrolls naturally with the content
- WKWebView fills available space, scrolls natively via WebKit's
  compositor thread — immediate scroll response

Both fixes preserve the shared WKWebView architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 21:28:24 -05:00
parent 05bc5f8047
commit 18dd5aa44d
2 changed files with 107 additions and 125 deletions

View File

@@ -6,7 +6,6 @@ struct ArticleView: View {
@State private var currentEntry: ReaderEntry
@State private var isFetchingFull = false
@State private var savedToBrain = false
@State private var webViewHeight: CGFloat = 400
@State private var isContentReady = false
@State private var articleContent = ""
@@ -17,95 +16,47 @@ struct ArticleView: View {
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Article header
VStack(alignment: .leading, spacing: 8) {
Text(currentEntry.displayTitle)
.font(.title2.weight(.bold))
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
Text(currentEntry.feedName)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
if let author = currentEntry.author, !author.isEmpty {
Text("\u{2022} \(author)")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
}
HStack(spacing: 12) {
if !currentEntry.timeAgo.isEmpty {
Label(currentEntry.timeAgo, systemImage: "clock")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Label(currentEntry.readingTimeText, systemImage: "book")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
Group {
if !isContentReady {
VStack {
Spacer()
ProgressView()
.controlSize(.regular)
Text("Loading article...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
.padding(.top, 8)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Divider()
.padding(.horizontal, 16)
// Article body
if !isContentReady {
HStack {
ProgressView()
.controlSize(.small)
Text("Loading article...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if articleContent.isEmpty {
VStack(spacing: 12) {
Spacer()
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundStyle(Color.textTertiary)
Text("No content available")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
Button("Fetch Full Article") {
Task { await fetchFull() }
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else if articleContent.isEmpty {
if isFetchingFull {
HStack {
ProgressView()
.controlSize(.small)
Text("Fetching article...")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundStyle(Color.textTertiary)
Text("No content available")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
Button("Fetch Full Article") {
Task { await fetchFull() }
}
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
} else {
ArticleWebView(html: wrapHTML(articleContent), contentHeight: $webViewHeight)
.frame(height: webViewHeight)
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
Spacer()
}
Spacer(minLength: 80)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// WKWebView handles all scrolling header is in the HTML
ArticleWebView(html: buildArticleHTML())
.ignoresSafeArea(edges: .bottom)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
// Star toggle
Button {
Task { await toggleStar() }
} label: {
@@ -113,7 +64,6 @@ struct ArticleView: View {
.foregroundStyle(currentEntry.starred ? .orange : Color.textTertiary)
}
// Read/unread toggle
Button {
Task { await toggleRead() }
} label: {
@@ -121,7 +71,6 @@ struct ArticleView: View {
.foregroundStyle(Color.textTertiary)
}
// More actions
Menu {
if currentEntry.url != nil {
Button {
@@ -154,19 +103,27 @@ struct ArticleView: View {
}
}
.task {
// Auto-mark as read (fire-and-forget)
Task { await vm.markAsRead(entry) }
// 1. Mark as read IMMEDIATELY before any network call
await vm.markAsRead(entry)
// Fetch full entry content on demand
// 2. Sync local state with the read mutation
if let updated = vm.entries.first(where: { $0.id == entry.id }) {
currentEntry = updated
}
// 3. Fetch full content do NOT overwrite status/starred from server
do {
let fullEntry = try await ReaderAPI().getEntry(id: currentEntry.id)
currentEntry = fullEntry
let fullEntry = try await ReaderAPI().getEntry(id: entry.id)
articleContent = fullEntry.articleHTML
if let idx = vm.entries.firstIndex(where: { $0.id == fullEntry.id }) {
vm.entries[idx] = fullEntry
// Preserve local status/starred (may differ from server due to race)
var merged = fullEntry
if let local = vm.entries.first(where: { $0.id == entry.id }) {
merged.status = local.status
merged.starred = local.starred
}
currentEntry = merged
} catch {
// Fall back to whatever content we already have
articleContent = currentEntry.articleHTML
}
isContentReady = true
@@ -191,11 +148,14 @@ struct ArticleView: View {
isFetchingFull = true
do {
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
currentEntry = updated
articleContent = updated.articleHTML
if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) {
vm.entries[idx] = updated
// Preserve local status
var merged = updated
if let local = vm.entries.first(where: { $0.id == updated.id }) {
merged.status = local.status
merged.starred = local.starred
}
currentEntry = merged
} catch {}
isFetchingFull = false
}
@@ -207,8 +167,25 @@ struct ArticleView: View {
}
}
private func wrapHTML(_ body: String) -> String {
"""
// MARK: - HTML Builder (header + body in one document)
private func buildArticleHTML() -> String {
let title = currentEntry.displayTitle
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let feed = currentEntry.feedName
.replacingOccurrences(of: "<", with: "&lt;")
let author = currentEntry.author ?? ""
let time = currentEntry.timeAgo
let reading = currentEntry.readingTimeText
var metaParts = [feed]
if !author.isEmpty { metaParts.append("by \(author)") }
if !time.isEmpty { metaParts.append(time) }
metaParts.append("\(reading) read")
let meta = metaParts.joined(separator: " &middot; ")
return """
<!DOCTYPE html>
<html>
<head>
@@ -221,7 +198,7 @@ struct ArticleView: View {
font-size: 17px;
line-height: 1.6;
color: #1f1f1f;
padding: 0 16px 40px;
padding: 0 16px 60px;
margin: 0;
background: transparent;
-webkit-text-size-adjust: 100%;
@@ -232,6 +209,23 @@ struct ArticleView: View {
pre, code { background: #1e1c1a !important; }
blockquote { border-left-color: #c79e40; color: #9a9590; }
td, th { border-color: #333; }
.article-meta { color: #8a8580; }
}
.article-header {
padding: 16px 0 12px;
border-bottom: 1px solid rgba(128,128,128,0.2);
margin-bottom: 16px;
}
.article-title {
font-size: 24px;
font-weight: 700;
line-height: 1.25;
margin: 0 0 8px;
}
.article-meta {
font-size: 13px;
color: #8B6914;
line-height: 1.4;
}
img {
max-width: 100%;
@@ -279,7 +273,13 @@ struct ArticleView: View {
}
</style>
</head>
<body>\(body)</body>
<body>
<div class="article-header">
<h1 class="article-title">\(title)</h1>
<div class="article-meta">\(meta)</div>
</div>
\(articleContent)
</body>
</html>
"""
}

View File

@@ -3,9 +3,9 @@ import WebKit
// MARK: - Shared Article Renderer
//
// Single WKWebView instance with shared WKProcessPool and WKWebViewConfiguration.
// Created once at app launch. Reused for every article open.
// Eliminates the ~3s WebContent process launch on first article tap.
// 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 {
@@ -20,23 +20,22 @@ final class ArticleRenderer {
webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = false
webView.scrollView.bounces = false
// WKWebView handles its own scrolling no SwiftUI ScrollView wrapper
webView.scrollView.isScrollEnabled = true
// Pre-warm: load empty page to spin up WebContent process immediately
// Pre-warm: spin up WebContent process
webView.loadHTMLString("<html><body></body></html>", baseURL: nil)
}
}
// MARK: - Article Web View
//
// UIViewRepresentable that wraps the shared WKWebView in a container UIView.
// SwiftUI owns the container's lifecycle, not the WKWebView's.
// The WKWebView survives across article opens.
// 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
@Binding var contentHeight: CGFloat
func makeUIView(context: Context) -> UIView {
let container = UIView()
@@ -54,14 +53,10 @@ struct ArticleWebView: UIViewRepresentable {
func updateUIView(_ container: UIView, context: Context) {
let webView = ArticleRenderer.shared.webView
// Ensure delegate points to THIS coordinator (not a stale one)
webView.navigationDelegate = context.coordinator
// Only reload if HTML changed
if context.coordinator.lastHTML != html {
context.coordinator.lastHTML = html
context.coordinator.heightBinding = $contentHeight
webView.loadHTMLString(html, baseURL: nil)
}
}
@@ -72,7 +67,6 @@ struct ArticleWebView: UIViewRepresentable {
class Coordinator: NSObject, WKNavigationDelegate {
var lastHTML: String?
var heightBinding: Binding<CGFloat>?
func webView(
_ webView: WKWebView,
@@ -87,17 +81,5 @@ struct ArticleWebView: UIViewRepresentable {
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
if let height = result as? CGFloat, height > 0 {
DispatchQueue.main.async {
self?.heightBinding?.wrappedValue = height
}
}
}
}
}
}
}