iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: memoize story fullsize cache lookup to prevent branch-swap flash

Computing cachedFullsize inline re-checked Nuke's memory cache on every
body eval. Once LazyImage delivered the fullsize and wrote imageLoaded
from its onAppear, the resulting re-eval flipped a miss→hit and swapped
the if/else branch, tearing down the LazyImage subtree and flashing the
blurred thumb placeholder for one frame.

Memoize the lookup in a reference-typed state holder so the branch is
chosen once per story URI. Adds sync-hit / lazy-delivered signposts so
this can be verified against the os_log stream.

authored by

Hima Aramona and committed by
Chad Miller
b463e1a8 f4c63b08

+48 -8
+48 -8
Grain/Views/Stories/StoryViewer.swift
··· 86 86 var pending: CGFloat = 0 87 87 } 88 88 89 + /// Reference-typed memo for the current story's fullsize cache lookup. 90 + /// Why: `cachedFullsizeImage` is read from the view body. Computing it 91 + /// inline re-checks Nuke's memory cache on every body eval, so if a 92 + /// `@State` write (e.g. `imageLoaded = true` from LazyImage's onAppear) 93 + /// fires AFTER LazyImage has delivered, the next body re-eval sees a 94 + /// sudden cache hit and swaps the if/else branch from LazyImage → sync 95 + /// `Image(uiImage:)`. The branch swap tears down the LazyImage subtree, 96 + /// which visibly flashes the blurred thumb placeholder for one frame. 97 + /// Holding the lookup in a class means we can memoize per-URI without 98 + /// triggering view invalidation on update. 99 + @MainActor private final class FullsizeMemo { 100 + var uri: String? 101 + var image: UIImage? 102 + } 103 + 89 104 struct StoryViewer: View { 90 105 @Environment(AuthManager.self) private var auth 91 106 @Environment(LabelDefinitionsCache.self) private var labelDefsCache ··· 116 131 @State private var authorHistory: [(authorIndex: Int, storyIndex: Int)] = [] 117 132 @State private var imagePrefetcher = ImagePrefetcher() 118 133 @State private var isDragging = false 134 + @State private var fullsizeMemo = FullsizeMemo() 119 135 120 136 // MARK: - Comments & Likes 121 137 ··· 368 384 if let story = currentStory { 369 385 let lr = storyLabelResult 370 386 371 - // Story image — check memory cache before creating LazyImage so 372 - // we never get a two-state view swap (sync Image → LazyImage-delivered 373 - // Image) for the same pixel content, which causes a flash. 374 - let cachedFullsize: UIImage? = (lr.action != .hide || labelRevealed) 375 - ? URL(string: story.fullsize).flatMap { 376 - ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: $0))?.image 377 - } 378 - : nil 387 + // Memoized per-URI. Computing this inline would re-check Nuke's 388 + // memory cache on every body eval; once LazyImage delivers and 389 + // writes `imageLoaded`, the resulting re-eval would flip a 390 + // miss→hit and swap the if/else branch, tearing down LazyImage 391 + // and briefly flashing the blurred thumb placeholder. 392 + let cachedFullsize = cachedFullsizeImage( 393 + for: story, 394 + blocked: lr.action == .hide && !labelRevealed 395 + ) 379 396 ZStack { 380 397 Group { 381 398 if let cached = cachedFullsize { ··· 389 406 // Text("fullsize · cache").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 390 407 // } 391 408 .onAppear { 409 + svSignposter.emitEvent("storyImage.syncHit.appear") 410 + svLogger.info("[storyImage] sync-hit onAppear uri=\(story.uri)") 392 411 if !imageLoaded { 393 412 imageLoaded = true 394 413 startTimerIfSafe() ··· 411 430 // Text("fullsize · network").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 412 431 // } 413 432 .onAppear { 433 + svSignposter.emitEvent("storyImage.lazyDelivered.appear") 434 + svLogger.info("[storyImage] lazy-delivered onAppear uri=\(story.uri)") 414 435 if !imageLoaded { 415 436 imageLoaded = true 416 437 startTimerIfSafe() ··· 648 669 649 670 private func isFullsizeCached(_ story: GrainStory?) -> Bool { 650 671 storyFullsizeCached(story) 672 + } 673 + 674 + /// Returns the fullsize image for `story` if Nuke has it in the memory 675 + /// cache, memoized per story URI. Only re-checks the pipeline when the 676 + /// URI changes; otherwise returns the previously-recorded result so 677 + /// view-body re-evals don't swap the image branch mid-render. 678 + private func cachedFullsizeImage(for story: GrainStory, blocked: Bool) -> UIImage? { 679 + if blocked { return nil } 680 + if fullsizeMemo.uri == story.uri { 681 + return fullsizeMemo.image 682 + } 683 + let image = URL(string: story.fullsize).flatMap { 684 + ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: $0))?.image 685 + } 686 + fullsizeMemo.uri = story.uri 687 + fullsizeMemo.image = image 688 + svSignposter.emitEvent("fullsizeMemo.update", "hit=\(image != nil)") 689 + svLogger.info("[fullsizeMemo] update uri=\(story.uri) hit=\(image != nil)") 690 + return image 651 691 } 652 692 653 693 private func goToNext() {