Files
Outpost/Outpost/Features/Home/HomeViewModel.swift
T
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00

158 lines
6.5 KiB
Swift

import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class HomeViewModel {
private(set) var pinnedDocuments: [OutlineDocument] = []
private(set) var recentlyViewed: [OutlineDocument] = []
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
private(set) var drafts: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
var errorMessage: String?
/// Drives a "Refresh" banner rather than silently swapping content out
/// from under whoever's looking at it — see `CollectionsViewModel`'s
/// identical pattern.
var hasRemoteChanges = false
private let apiClient: OutlineAPIClient
private var currentUserID: String?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func documents(for tab: HomeTab) -> [OutlineDocument] {
switch tab {
case .recentlyViewed: recentlyViewed
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
case .drafts: drafts
}
}
func loadPinned() async {
isLoadingPinned = true
defer { isLoadingPinned = false }
pinnedDocuments = await fetchPinned()
}
func load(tab: HomeTab) async {
isLoadingTab = true
errorMessage = nil
defer { isLoadingTab = false }
do {
let documents = try await fetch(tab: tab)
set(documents, for: tab)
hasRemoteChanges = false
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
}
}
/// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" — a
/// transient network hiccup (or, offline, `listPins` failing outright —
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves —
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar — but they're fetched concurrently
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
/// own cached-read/retry path either way.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
let client = apiClient
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
for pin in pins {
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
}
var result: [String: OutlineDocument] = [:]
for await (id, document) in group {
if let document { result[id] = document }
}
return result
}
// Preserve pins.list's own order rather than task-completion order.
return pins.compactMap { documentsByID[$0.documentId] }
}
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
switch tab {
case .recentlyViewed:
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
case .popular:
// `sort: "viewCount"` was a guess and the server rejected it
// outright ("sort: Invalid input") — sort is validated
// server-side against a fixed set, not free-form like the
// vendored spec's typing implies. Same conclusion as
// `CollectionTab.popular`: there's no real popularity
// ranking exposed via the REST API, so this falls back to
// the default list order rather than guessing again.
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
case .recentlyUpdated:
return try await apiClient.documentsList(
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
)
case .createdByMe:
let userId = try await resolveCurrentUserID()
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
case .drafts:
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
}
}
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
switch tab {
case .recentlyViewed: recentlyViewed = documents
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
}
}
private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID }
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
currentUserID = user.id
return user.id
}
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
documents
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
.sorted()
.joined(separator: "|")
}
}