perf: instant article open + non-blocking mark-read + static CSS
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

Changes:
1. Article opens INSTANTLY — articleContent initialized from entry's
   existing content in init(), not after API fetch. WebView renders
   immediately with whatever we have. Full content swaps in silently
   when getEntry returns (only if longer than current).

2. markAsRead is fire-and-forget — wrapped in detached Task inside
   .task, does not block the content display chain. Toolbar syncs
   from vm.entries immediately after.

3. CSS template pre-built as static string in ArticleHTMLBuilder.
   Avoids rebuilding ~2KB of CSS on every article open. HTML builder
   is a stateless enum with a single static method.

4. Removed isContentReady flag — no longer needed since content is
   available from init. Spinner only shows if entry truly has no
   content at all (rare edge case).

Flow is now:
  tap → ArticleView created with entry.articleHTML →
  WebView loads immediately → user can scroll →
  background: markAsRead fires, getEntry fetches full content →
  if full content is better, WebView updates silently

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 21:31:54 -05:00
parent 18dd5aa44d
commit 8ae1d48d68

View File

@@ -6,18 +6,20 @@ struct ArticleView: View {
@State private var currentEntry: ReaderEntry
@State private var isFetchingFull = false
@State private var savedToBrain = false
@State private var isContentReady = false
@State private var articleContent = ""
init(entry: ReaderEntry, vm: ReaderViewModel) {
self.entry = entry
self.vm = vm
_currentEntry = State(initialValue: entry)
// Initialize with whatever content we already have (from slim list or cache)
_articleContent = State(initialValue: entry.articleHTML)
}
var body: some View {
Group {
if !isContentReady {
if articleContent.isEmpty {
// Only show spinner if we truly have no content at all
VStack {
Spacer()
ProgressView()
@@ -29,27 +31,16 @@ struct ArticleView: View {
Spacer()
}
.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() }
}
.font(.subheadline.weight(.medium))
.foregroundStyle(Color.accentWarm)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// WKWebView handles all scrolling header is in the HTML
ArticleWebView(html: buildArticleHTML())
.ignoresSafeArea(edges: .bottom)
ArticleWebView(html: ArticleHTMLBuilder.build(
title: currentEntry.displayTitle,
feedName: currentEntry.feedName,
author: currentEntry.author,
timeAgo: currentEntry.timeAgo,
readingTime: currentEntry.readingTimeText,
body: articleContent
))
.ignoresSafeArea(edges: .bottom)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -103,20 +94,25 @@ struct ArticleView: View {
}
}
.task {
// 1. Mark as read IMMEDIATELY before any network call
await vm.markAsRead(entry)
// 1. Mark as read fire-and-forget, don't block UI
Task { await vm.markAsRead(entry) }
// 2. Sync local state with the read mutation
// 2. Sync toolbar state
if let updated = vm.entries.first(where: { $0.id == entry.id }) {
currentEntry = updated
}
// 3. Fetch full content do NOT overwrite status/starred from server
// 3. Fetch full content in background update when ready
do {
let fullEntry = try await ReaderAPI().getEntry(id: entry.id)
articleContent = fullEntry.articleHTML
let fullHTML = fullEntry.articleHTML
// Preserve local status/starred (may differ from server due to race)
// Only update if we got better content
if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
articleContent = fullHTML
}
// Preserve local status/starred
var merged = fullEntry
if let local = vm.entries.first(where: { $0.id == entry.id }) {
merged.status = local.status
@@ -124,9 +120,8 @@ struct ArticleView: View {
}
currentEntry = merged
} catch {
articleContent = currentEntry.articleHTML
// Keep whatever content we already have
}
isContentReady = true
}
}
@@ -149,7 +144,6 @@ struct ArticleView: View {
do {
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
articleContent = updated.articleHTML
// Preserve local status
var merged = updated
if let local = vm.entries.first(where: { $0.id == updated.id }) {
merged.status = local.status
@@ -166,23 +160,75 @@ struct ArticleView: View {
savedToBrain = true
}
}
}
// MARK: - HTML Builder (header + body in one document)
// MARK: - HTML Builder (stateless, reusable CSS template)
private func buildArticleHTML() -> String {
let title = currentEntry.displayTitle
enum ArticleHTMLBuilder {
// Pre-built CSS avoids rebuilding the same string every article open
private static let css = """
* { box-sizing: border-box; }
body {
font-family: -apple-system, system-ui, sans-serif;
font-size: 17px;
line-height: 1.6;
color: #1f1f1f;
padding: 0 16px 60px;
margin: 0;
background: transparent;
-webkit-text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
body { color: #ede8e1; }
a { color: #c79e40 !important; }
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%; height: auto; border-radius: 8px; margin: 12px 0; }
a { color: #8B6914; }
h1, h2, h3, h4 { line-height: 1.3; margin-top: 24px; margin-bottom: 8px; }
pre, code { background: #f5f0e8; border-radius: 6px; padding: 2px 6px; font-size: 15px; overflow-x: auto; }
pre { padding: 12px; }
pre code { padding: 0; background: none; }
blockquote { border-left: 3px solid #8B6914; margin-left: 0; padding-left: 16px; color: #666; }
figure { margin: 16px 0; }
figcaption { font-size: 14px; color: #888; text-align: center; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
"""
static func build(
title: String,
feedName: String,
author: String?,
timeAgo: String,
readingTime: String,
body: String
) -> String {
let safeTitle = title
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let feed = currentEntry.feedName
let safeFeed = 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")
var metaParts = [safeFeed]
if let author, !author.isEmpty { metaParts.append("by \(author)") }
if !timeAgo.isEmpty { metaParts.append(timeAgo) }
metaParts.append("\(readingTime) read")
let meta = metaParts.joined(separator: " &middot; ")
return """
@@ -191,94 +237,14 @@ struct ArticleView: View {
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="color-scheme" content="light dark">
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, system-ui, sans-serif;
font-size: 17px;
line-height: 1.6;
color: #1f1f1f;
padding: 0 16px 60px;
margin: 0;
background: transparent;
-webkit-text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
body { color: #ede8e1; }
a { color: #c79e40 !important; }
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%;
height: auto;
border-radius: 8px;
margin: 12px 0;
}
a { color: #8B6914; }
h1, h2, h3, h4 {
line-height: 1.3;
margin-top: 24px;
margin-bottom: 8px;
}
pre, code {
background: #f5f0e8;
border-radius: 6px;
padding: 2px 6px;
font-size: 15px;
overflow-x: auto;
}
pre { padding: 12px; }
pre code { padding: 0; background: none; }
blockquote {
border-left: 3px solid #8B6914;
margin-left: 0;
padding-left: 16px;
color: #666;
}
figure { margin: 16px 0; }
figcaption {
font-size: 14px;
color: #888;
text-align: center;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
td, th {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
</style>
<style>\(css)</style>
</head>
<body>
<div class="article-header">
<h1 class="article-title">\(title)</h1>
<div class="article-meta">\(meta)</div>
</div>
\(articleContent)
<div class="article-header">
<h1 class="article-title">\(safeTitle)</h1>
<div class="article-meta">\(meta)</div>
</div>
\(body)
</body>
</html>
"""