Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"Bash(gh api:*)"
],
"ask": [
"Write(./**/*.xcodeproj/**)",
"Edit(./**/*.xcodeproj/**)"
],
"deny": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftUI
public struct MMWebView: View {
@Environment(\.removeAds) private var removeAds
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) private var scenePhase

@State private var commentsURL = ""
@State private var internalLinkURL: URL?
Expand All @@ -15,6 +16,7 @@ public struct MMWebView: View {
@State private var galleryStateHandler = GalleryStateMessageHandler()
@State private var isGalleryOpen = false
@State private var reloadID = UUID()
@State private var appliedColorScheme: ColorScheme?

private let url: String?
private let cacheKey: String?
Expand Down Expand Up @@ -73,9 +75,16 @@ public struct MMWebView: View {
}
}
}
.onChange(of: colorScheme) {
isGalleryOpen = false
page?.reload()
.onChange(of: colorScheme) { _, newScheme in
guard WebViewLoadPolicy.shouldHandleColorSchemeChange(scenePhase: scenePhase) else { return }
applyColorScheme(newScheme)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active,
WebViewLoadPolicy.needsColorSchemeReconciliation(current: colorScheme,
applied: appliedColorScheme) {
applyColorScheme(colorScheme)
}
}
.onChange(of: removeAds) {
if let cacheKey {
Expand All @@ -91,6 +100,12 @@ public struct MMWebView: View {
// MARK: - Setup

private extension MMWebView {
func applyColorScheme(_ scheme: ColorScheme) {
appliedColorScheme = scheme
isGalleryOpen = false
page?.reload()
}

func makeLoadAction() -> (@MainActor (WebPage) async throws -> Void)? {
guard let cacheKey else { return urlLoadAction() }
if WebPageCache.shared.hasPage(for: cacheKey) {
Expand Down Expand Up @@ -146,6 +161,7 @@ private extension MMWebView {
await cookieStore.setCookie(cookie)
}

appliedColorScheme = colorScheme
return page
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public struct ManagedWebView: View {
@State private var viewStatus = WebViewStatus.idle
@State private var isActive = true
@State private var webViewReadyToRender = !ProcessInfo.processInfo.isiOSAppOnMac
@State private var completedLoadTrigger: UUID?
@State private var appliedColorScheme: ColorScheme?

let style: ManagedWebViewStyle
let pageProvider: @MainActor () async -> WebPage?
Expand Down Expand Up @@ -83,18 +85,25 @@ public struct ManagedWebView: View {
try? await Task.sleep(for: .milliseconds(50))
}

guard WebViewLoadPolicy.shouldLoad(trigger: reloadTrigger,
completedTrigger: completedLoadTrigger) else {
viewStatus = .done
return
}

await performLoad(on: activePage)
}
.onChange(of: scenePhase) { _, newPhase in
isActive = newPhase == .active
if newPhase == .active,
WebViewLoadPolicy.needsColorSchemeReconciliation(current: colorScheme,
applied: appliedColorScheme) {
handleColorSchemeChange(colorScheme)
}
}
.onChange(of: colorScheme) { _, newScheme in
guard let onColorSchemeChange, let page else { return }
Task {
viewStatus = .loading
await onColorSchemeChange(page, newScheme)
await performLoad(on: page)
}
guard WebViewLoadPolicy.shouldHandleColorSchemeChange(scenePhase: scenePhase) else { return }
handleColorSchemeChange(newScheme)
}
}
}
Expand All @@ -119,16 +128,30 @@ private extension ManagedWebView {
}
}

func handleColorSchemeChange(_ scheme: ColorScheme) {
guard let onColorSchemeChange, let page else { return }
appliedColorScheme = scheme
Task {
viewStatus = .loading
await onColorSchemeChange(page, scheme)
await performLoad(on: page)
}
}

func performLoad(on page: WebPage) async {
guard let loadAction else {
viewStatus = .done
completedLoadTrigger = reloadTrigger
appliedColorScheme = colorScheme
return
}

viewStatus = .loading
do {
try await loadAction(page)
viewStatus = .done
completedLoadTrigger = reloadTrigger
appliedColorScheme = colorScheme
if let postLoadAction {
await postLoadAction(page)
}
Expand All @@ -137,7 +160,7 @@ private extension ManagedWebView {
} catch let error as WebPage.NavigationError {
switch error {
case .webContentProcessTerminated:
break
completedLoadTrigger = nil
default:
if !Task.isCancelled {
viewStatus = .error(error.localizedDescription)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import SwiftUI

/// Pure decision logic for the WebView lifecycle, extracted for testability.
enum WebViewLoadPolicy {
/// A load is needed only when the current trigger has not already completed.
/// When a covered view's `.task(id:)` restarts on pop-back, the trigger is
/// unchanged and the redundant reload must be skipped to preserve the
/// reading position.
static func shouldLoad(trigger: UUID, completedTrigger: UUID?) -> Bool {
trigger != completedTrigger
}

/// Color-scheme changes reported while the scene is not active are the
/// system rendering app-switcher snapshots in both appearances — not a real
/// appearance change — and must not trigger a reload.
static func shouldHandleColorSchemeChange(scenePhase: ScenePhase) -> Bool {
scenePhase == .active
}

/// On return to the active scene, reload only if the appearance genuinely
/// changed while the scene was away.
static func needsColorSchemeReconciliation(current: ColorScheme, applied: ColorScheme?) -> Bool {
guard let applied else { return false }
return current != applied
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
@testable import MacMagazineUILibrary
import SwiftUI
import Testing

@Suite("WebViewLoadPolicy Tests")
struct WebViewLoadPolicyTests {

// MARK: - shouldLoad

@Test("Should load on first appearance when no load has completed")
func firstLoad() {
// Given
let trigger = UUID()

// Then
#expect(WebViewLoadPolicy.shouldLoad(trigger: trigger, completedTrigger: nil))
}

@Test("Should skip reload when reappearing with the same completed trigger")
func skipAfterPopBack() {
// Given
let trigger = UUID()

// Then
#expect(!WebViewLoadPolicy.shouldLoad(trigger: trigger, completedTrigger: trigger))
}

@Test("Should load again when the trigger rotates")
func reloadOnRotatedTrigger() {
#expect(WebViewLoadPolicy.shouldLoad(trigger: UUID(), completedTrigger: UUID()))
}

// MARK: - shouldHandleColorSchemeChange

@Test("Should handle color-scheme change only while the scene is active",
arguments: zip([ScenePhase.active, .inactive, .background], [true, false, false]))
func schemeChangeGating(phase: ScenePhase, expected: Bool) {
#expect(WebViewLoadPolicy.shouldHandleColorSchemeChange(scenePhase: phase) == expected)
}

// MARK: - needsColorSchemeReconciliation

@Test("Should not reconcile before any scheme was applied")
func noReconciliationWhenNeverApplied() {
#expect(!WebViewLoadPolicy.needsColorSchemeReconciliation(current: .dark, applied: nil))
}

@Test("Should not reconcile when the scheme is unchanged",
arguments: [ColorScheme.light, .dark])
func noReconciliationWhenUnchanged(scheme: ColorScheme) {
#expect(!WebViewLoadPolicy.needsColorSchemeReconciliation(current: scheme, applied: scheme))
}

@Test("Should reconcile when the scheme changed while away")
func reconciliationWhenChanged() {
#expect(WebViewLoadPolicy.needsColorSchemeReconciliation(current: .dark, applied: .light))
}
}
Loading
Loading