prism branch T-1900/bugfix-svg-detail-reload-race commits 2 files 5 touched (2 new) lines +229 / -20 tests run LoadGenerationTests 5/5 green build iOS + macOS clean, lint 0

Pre-push review: T-1900/bugfix-svg-detail-reload-race

T-1900 — Cancelled SVG detail reload can overwrite the current viewer state. Adds LoadGeneration, a shared monotonic token, and routes every loadState write in WebImageDetailSheet (iOS) and ImageDetailWindow (macOS) through a commitLoadState guard. PR #393.

At a glance

  • Guard coverage is complete. grep -n 'loadState' over both views returns exactly one assignment each — WebImageDetailSheet.swift:168 and ImageDetailWindow.swift:431, both inside commitLoadState. No unguarded write survives.
  • begin() is provably on the MainActor before the first await. The app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (project.pbxproj:424, :478), and SwiftUI's .task(id:) takes an @_inheritActorContext sending @isolated(any) closure formed in @MainActor body. No data race on the @State mutation.
  • Major: the cancellation-flash residual is not closed. SVGRenderer is @MainActor (SVGRenderer.swift:19) and suspends at try await Task.sleep(for: .milliseconds(100)) on the MainActor (:64). A cancelled reload resumes with no hop into loadSVG's generic catch; if SwiftUI cancels-then-creates, the old token is still current and the stale failure commits.
  • The string the user would see is worse than “Cancelled”. The SVG-render catch uses error.localizedDescription, so a leaked CancellationError renders as “The operation couldn't be completed. (Swift.CancellationError error 1.)” — unlocalised, and a CLAUDE.md localisation violation in spirit. The raster path yields “Network error: Cancelled”.
  • The doc comment argues against the missing mitigation, on grounds that do not hold. LoadGeneration.swift:11-16 claims the cancelled task “does not observe that cancellation” and that Task.checkCancellation() “does not close this”. SVGRenderer.swift:65 calls Task.checkCancellation() explicitly; ImageLoader and SVGSourceLoader both map cancellation to .networkError("Cancelled"). The pipeline is cancellation-aware.
  • Nothing pins the wiring. The tests cover the primitive and a stand-in harness; no test observes WebImageDetailSheet or ImageDetailWindow. A future loadState = .failed(…) silently restores T-1900 — the exact failure class CLAUDE.md records for T-1943. ProductionSourceScan already exists for this.
  • Seventh copy of one mechanism; none migrated. NotesManager.loadGeneration, DocumentSession.parseGeneration, RemoteRefreshFlow.currentRequestID, RemoteContentCoordinator, RawSourceViewModel.linesGeneration, FootnotePopoverWebPage.renderGeneration. The new type's name also collides with NotesManager.loadGeneration, which is a counter value, not a counter object.
  • No interaction with the open T-2142. Cache writes remain unguarded, but keys are content-identity-derived, so a superseded load populates only its own key — harmless, and useful to the newer load. The stale-bytes-at-the-same-URL problem is pre-existing and squarely T-2142's.

Verdict

Needs fixes

The change is correct, well-scoped and a strict improvement: every loadState write in both views now goes through the guard (verified by grep — exactly one assignment per file), begin() is provably on the MainActor before the first suspension, the token-0 change is unreachable in production, and no stuck-spinner path is introduced. Both builds pass warning-free, SwiftLint reports 0 violations, the WebKit isolation guard passes, and LoadGenerationTests executes 5/5 green on the real test host.

But it implements only half of the fix the ticket itself prescribed, and the omitted half is the one that deterministically covers the scenario in the ticket's title. T-1900 says: “Capture the reload key/generation at task start and only commit loadState if it is still current; treat CancellationError as a silent return.” The generation half shipped; the cancellation half did not. On the SVG render path — the path the ticket is named after — SVGRenderer is @MainActor and its cancellation window is a MainActor Task.sleep, so a cancelled reload's CancellationError reaches commitLoadState with no actor hop. If SwiftUI's .task(id:) cancels before it creates, the stale task's token is still current and the guard passes, committing a failure over the newer result — exactly the reported symptom. The generation counter closes this probabilistically; catch is CancellationError { return } closes it by definition, and is the pattern RemoteRefreshFlow and RemoteContentCoordinator already use alongside their own generation counters.

Add the one-line cancellation guard, correct the LoadGeneration doc comment (which asserts a mechanism the pipeline contradicts), and soften the CHANGELOG claim. Everything else is follow-up material.

Review findings

12 raised · 0 fixed · 12 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you tap an image in Prism to view it fullscreen, the app has to fetch and draw that image. If the picture is an SVG (a drawing described in text rather than stored as dots), the app has to redraw it whenever the appearance changes — switching between light and dark mode, or on a Mac, dragging the window to a screen with a different sharpness.

Each of those changes kicks off a fresh load. The problem was that the old load didn't stop cleanly. It carried on in the background, finished late, and wrote its result over the top of the newer one — so you could end up looking at the picture for the appearance you just left, or at an error message saying the load was cancelled, even though a perfectly good image had already arrived.

Why it matters

It's a visible wrongness in the viewer: you switch to dark mode and get a stale light-mode drawing, or an error where a picture should be. Nothing is lost or corrupted — but what's on screen stops matching what you asked for, and there's no obvious way to make it right again short of closing and reopening.

Key concepts

  • A load is a race. Two attempts can be in flight at once, and the one that finishes last is not necessarily the one that started last.
  • A ticket number. The fix hands each load attempt a numbered ticket. Before an attempt is allowed to put its result on screen, it checks whether its ticket is still the newest one. If a later attempt has started since, the old one throws its result away instead of showing it.
  • Cancelling isn't enough. Telling the old attempt to stop doesn't make it stop immediately — and worse, the way it stops is by reporting an error, which is itself something it then tries to put on screen.

What this review found

The ticket-number scheme is correctly built and correctly applied everywhere it needs to be. But that last point — the cancelled attempt reporting an error — still has a narrow gap, on exactly the SVG path this bug was reported against. Closing it takes one more line.

Architecture

Both detail viewers reload through .task(id:) keyed on (colorScheme, displayScale). On a re-key SwiftUI cancels the in-flight task and starts a replacement, but the two tasks are still racing to write the same @State, and arrival order is not start order.

The fix introduces LoadGeneration, a Sendable value type wrapping a monotonic UInt64:

mutating func begin() -> UInt64      // supersede, hand out a token
func isCurrent(_ token: UInt64) -> Bool  // token != 0 && token == current

Each view holds it as @State, calls begin() as the first statement of its load function (before any suspension), threads the returned token down through the SVG sub-path, and funnels every state write through commitLoadState(_:token:), which drops the write when the token is stale.

Patterns

  • Extraction, not invention. This is the same monotonic-epoch pattern already hand-rolled in six other places (NotesManager, DocumentSession, RemoteRefreshFlow, RemoteContentCoordinator, RawSourceViewModel, FootnotePopoverWebPage) — it's just the first time it has been given a name. StoreManager's beginEntitlementObservation() / commitEntitlement(_:observedAt:) is the closest structural twin.
  • Guard at write time, not at read time. commitLoadState re-reads the live counter rather than a value captured earlier — and the rewritten test deliberately mirrors that, which is what makes it a real race test rather than a restatement of two synchronous begin() calls.
  • Caches are written before the guard, deliberately. A superseded load still populates imageCache/snapshotCache. Because keys are derived from resolved identity plus appearance, a stale load can only write its own key — so the write is harmless and the newer load may benefit from it.

Trade-offs

  • Token threading vs. one write point. token: UInt64 is now a parameter on the SVG sub-path purely to reach the commit helper, and there are ~13 call sites across the two views. Having the load functions return a LoadState and committing once at the top would delete the parameter and make “exactly one write” structural rather than a matter of discipline.
  • Two near-identical views. The iOS and macOS pipelines are mirrors of each other, so the fix had to be written twice, symmetrically. That's the honest cost of not unifying them — and unifying them is out of scope for a bugfix.
  • Generation vs. cancellation. A generation counter is order-independent for the slow-load half of the race. It is not order-independent for the cancelled-load half, because the loser's write can still land before the winner has begun. The two mechanisms are complementary; this change ships only one.

Isolation, settled

The app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor in both configurations (project.pbxproj:424, :478) with SWIFT_APPROACHABLE_CONCURRENCY = YES and SWIFT_VERSION = 5.0; SWIFT_STRICT_CONCURRENCY is unset and there is no .xcconfig. So LoadGeneration, both load functions and both commitLoadState helpers are @MainActor by default isolation. Independently, SwiftUI's .task(id:) takes @_inheritActorContext _ action: sending @escaping @isolated(any) () async -> Void, so a closure formed in @MainActor body starts its Task on the MainActor. begin() is the first statement in both load functions, before any suspension. The @State mutation is single-threaded by construction.

Two consequences worth naming. First, : Sendable on LoadGeneration is redundant — a global-actor-isolated value type is implicitly Sendable. Second, prismTests does not set SWIFT_DEFAULT_ACTOR_ISOLATION (project.pbxproj:645-648, :670-673), so the synchronous nonisolated test bodies call a @MainActor type across isolation. Silent under -swift-version 5; a diagnostic under Swift 6. Latent migration debt, matching the asymmetry already recorded in project memory. It is not a race — the value type is copied per test.

The residual, precisely

The guard wins only if T_new.begin() runs before T_old's resumption reaches commitLoadState. On the SVG-render path that ordering is genuinely in doubt:

@MainActor final class SVGRenderer {         // SVGRenderer.swift:19-20
    func render(...) async throws -> PlatformImage {
        try await Task.sleep(for: .milliseconds(100))   // :64  <- MainActor suspension
        try Task.checkCancellation()                    // :65

Cancelling T_old fires Task.sleep's cancellation handler, enqueuing T_old's resumption on the MainActor. If SwiftUI's internal _TaskValueModifier cancels before it creates, that resumption is enqueued first; both tasks are .userInitiated, so FIFO runs T_old first. render and loadSVG are both MainActor, so the CancellationError propagates with no hop into the generic catch and calls commitLoadState while current is still the old token. Guard passes; stale failure committed; T_new then overwrites it — a visible flash.

_TaskValueModifier is @usableFromInline internal with no inlinable body in the .swiftinterface, so the ordering cannot be read from source. It is unknown, not benign. The ImageLoader/SVGSourceLoader paths are narrower because both are actors, so cancellation observed inside them needs an extra hop back that T_new will almost certainly beat.

What the user would actually see

Two strings, both bad. ImageLoader.load is async throws(ImageLoadError), so cancellation is mapped at four sites to .networkError("Cancelled")“Network error: Cancelled”, mislabelled as a network failure. SVGRenderer.render is untyped async throws, so a bare CancellationError reaches the generic catch, which uses error.localizedDescription rather than displayMessage“The operation couldn't be completed. (Swift.CancellationError error 1.)”. Unlocalised, and precisely the string T-1900 exists to stop showing.

The order-independent close

Cancellation is definitionally true for the loser regardless of scheduling. guard !Task.isCancelled else { return } at the top of commitLoadState, or catch is CancellationError { return } per chain, closes the residual completely. This is the house pattern: RemoteRefreshFlow.swift:117/121/127/134 and RemoteContentCoordinator.swift:89/97/100 both combine catch is CancellationError and a request-id check; DocumentScrollContent.swift:279 uses the cancellation half alone via DocumentLoadingIndicatorPolicy.mayClearFlag(wasCancelled:). This branch is the only place using the generation half alone — and its doc comment declares the other half insufficient, which contradicts DocumentScrollContent's documented T-1744 rationale one layer down.

Dismissal, token 0, T-2142

Dismissal: when the view goes away nothing bumps the generation, so the current token stays current and the failure is committed — unchanged from before, and unobservable because the view is gone. The begin()/commit pairing is total: every return path either commits or tail-calls a function that commits on all five branches. The newest token is current by definition, so no stuck spinner is introduced.

Token 0: isCurrent has exactly two production callers, both commitLoadState, both fed from begin(), which returns current += 1 starting at 0 — i.e. always ≥ 1. The token != 0 branch is unreachable in production and exercised only by the artificial test. Harmless; the test should not be read as covering a real path. += 1 traps on overflow where every existing counter in the repo uses &+= 1 — unreachable at ~1.8×1019 loads, but a silent divergence from house convention.

T-2142 (media caches survive reloads): no adverse interaction. Cache writes sit before the guard, but keys are resolved-identity-plus-appearance derived, so a superseded load writes only its own key. The scenario where that matters — bytes changing at the same URL after a document reload — is pre-existing, unchanged by this branch, and exactly what T-2142 tracks. Guarding the cache writes (which the ticket's suggested fix mentions) would not help T-2142 and would lose a free cache warm.

Important changes — detailed

LoadGeneration: a named monotonic token, the first extraction of a six-times-repeated pattern

LoadGeneration.swift

Why it matters. This is the whole mechanism. It is small, pure, Sendable, and correct — begin() supersedes and hands out a token, isCurrent() answers whether that token is still newest. Reviewer interest: it is the seventh instance of this idea in the repo and the first with a name, so it either becomes the shared primitive or it becomes a one-off whose name collides with NotesManager.loadGeneration.

What to look at. prism/Services/LoadGeneration.swift:24-42

Takeaway. A generation counter must be re-read at write time, never captured alongside the token. The whole value of the pattern is that isCurrent() consults live state; a snapshot taken before the race turns the guard into an assertion about two synchronous calls. The rewritten test in commit bd921ac9 exists because the first version made exactly that mistake.
Rationale. Chosen over Task.checkCancellation() because a generation counter is order-independent for the slow-load half of the race, where cancellation is not involved at all. The doc comment states this — but overstates it, claiming the pipeline does not observe cancellation, which is false for these call paths (see the Rationale on the next card).

commitLoadState: the single write point, verified complete by grep

WebImageDetailSheet.swift

Why it matters. The correctness claim of this PR is 'every loadState write goes through the guard'. That claim holds: grep over both views returns exactly one assignment each, both inside the helper. This is the finding a reviewer most needs to confirm, and it confirms cleanly.

What to look at. WebImageDetailSheet.swift:166-169 and ImageDetailWindow.swift:429-432; call sites at :119/:128/:130/:141/:154/:156/:158/:160 and :374/:384/:386/:403/:417/:419/:421/:423

Takeaway. 'Every write goes through the guard' is currently enforced by discipline across ~13 call sites, not by structure. Having the load functions return a LoadState and committing once — `commitLoadState(await resolvedState(), token: token)` — would delete the token: parameter entirely and make the invariant structural. Both functions already end in a cache-hit early return or a do/catch with a value per branch, so it is a mechanical change.
Rationale. Threading the token was the smallest diff that touched neither the control flow nor the two views' differing LoadState payload shapes (.failed(message:canGrantAccess:) vs .failed(String)). Defensible for a bugfix; it does leave the invariant unpinned. (inferred — not stated by the author)

The cancellation half of the ticket's own suggested fix is missing

WebImageDetailSheet.swift

Why it matters. MAJOR. T-1900's suggested fix reads: 'Capture the reload key/generation at task start and only commit loadState if it is still current; treat CancellationError as a silent return.' The first clause shipped, the second did not — and the second is the one that deterministically covers the scenario in the ticket's title. SVGRenderer is @MainActor and its cancellation window is a MainActor Task.sleep (SVGRenderer.swift:64-65), so a cancelled reload's error reaches commitLoadState with no actor hop; if SwiftUI cancels before it creates, the stale token is still current and the guard passes.

What to look at. WebImageDetailSheet.swift:155-161 and ImageDetailWindow.swift:418-424 — the generic catch chains that commit a failure without asking whether the task was cancelled

Takeaway. A generation counter and a cancellation check close different halves of the same race. The counter is order-independent for a slow load that was never cancelled; the cancellation check is order-independent for a cancelled load, because Task.isCancelled is definitionally true for the loser regardless of scheduling. Neither subsumes the other, which is why RemoteRefreshFlow and RemoteContentCoordinator use both.
Open question. Rationale not stated by the author and not inferable from the diff.

The doc comment argues against the missing mitigation, on grounds the code contradicts

LoadGeneration.swift

Why it matters. MINOR, but this repo is meticulous about comments describing the mechanism that actually runs. LoadGeneration.swift:11-16 asserts the cancelled task 'does not observe that cancellation — it keeps running', and that 'Task.checkCancellation() does not close this'. SVGRenderer.swift:65 calls Task.checkCancellation() explicitly; ImageLoader.swift:317 and SVGSourceLoader.swift:164 both catch CancellationError and map it to .networkError("Cancelled"); ImageLoader.swift:40 uses withTaskCancellationHandler. The pipeline is thoroughly cancellation-aware.

What to look at. prism/Services/LoadGeneration.swift:11-16

Takeaway. The comment does not merely misdescribe — it forecloses. The next reader will take 'checkCancellation does not close this' as settled and will not add the guard. It also contradicts DocumentScrollContent.swift:279, which uses DocumentLoadingIndicatorPolicy.mayClearFlag(wasCancelled: Task.isCancelled) with a documented T-1744 rationale one layer down. Both cannot be right as written.
Rationale. The stated mechanism (a detached await that never revisits a suspension point) is a real failure mode and does justify the generation counter — it is just not the dominant one for these two call paths, and the comment generalises from it to dismiss the complementary guard. (inferred — not stated by the author)

Race test rewritten to consult the live counter, with a continuation Gate instead of sleeps

LoadGenerationTests.swift

Why it matters. The round-1 version took a frozen snapshot of LoadGeneration before either branch ran, so its outcome was fixed by two synchronous begin() calls — it duplicated laterBeginSupersedesEarlierToken and exercised no race. The replacement is genuinely deterministic: a @MainActor harness mirroring the views' state pair, plus a Gate actor that blocks until the stale load has provably reached its suspension point.

What to look at. prismTests/LoadGenerationTests.swift:58-135 (LoadCommitHarness, Gate, supersededLoadCannotWinCommitRace)

Takeaway. waitForReady() is the part that makes this a test rather than a coin flip. Without it the current load could race ahead of the stale load's beginLoad() and the intended ordering would never be pinned — the test would pass for the wrong reason. A gate that only signals one direction is not enough; you need the rendezvous in both.
Rationale. Stated in commit bd921ac9: deterministic ordering with no Task.sleep and no reliance on scheduler ordering, per the T-2017/T-2095 precedent. Mutation-checked — forcing isCurrent to return true turns this test and noTokenIsCurrentBeforeFirstBegin red.

Nothing pins that the two views actually route through the guard

LoadGenerationTests.swift

Why it matters. MAJOR. The suite tests the primitive and a stand-in harness that mirrors the views. It never observes WebImageDetailSheet or ImageDetailWindow. A future edit writing loadState = .failed(…) directly restores T-1900 with the whole suite green — the exact failure class CLAUDE.md records for T-1943 ('a direct-invocation test cannot see missing wiring, that is exactly how T-1943 survived the cutover and every review').

What to look at. prismTests/LoadGenerationTests.swift — and the absent scan rule; compare prismTests/ImageMaterializationChokepointTests.swift

Takeaway. When an invariant is 'this call must appear at every site', the repo already has the tool: ProductionSourceScan.Rule, shared by ImageMaterializationChokepointTests and URLChokepointAdoptionTests. A rule banning a bare `loadState = ` outside commitLoadState in those two files is ~15 lines and reuses existing machinery. Note that the mirror-harness approach has the same drift problem in miniature: the harness is a copy of the view's logic, so it stays green while the original diverges.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Generation counter over Task.checkCancellation()

Stated in LoadGeneration.swift:10-16 and the first commit message. The counter does not depend on SwiftUI's internal cancel/create ordering and matches six existing in-repo precedents, so it is the right primary mechanism.

The stated grounds, however, do not hold: the comment claims the cancelled task never observes cancellation, which is false for both the raster and SVG paths. The decision is right; the justification needs rewriting, and it should say that the cancellation check is complementary, not superseded.

Token 0 is never current

Added in commit bd921ac9 after the first real test-host run surfaced isCurrent(0) reading as true on a fresh generation. Explicitly documented as having no production effect — confirmed: isCurrent has exactly two callers, both fed from begin(), which returns current += 1 starting at 0, so a 0 token cannot exist. It closes a pure contract gap.

Worth noting so nobody mistakes the coverage: noTokenIsCurrentBeforeFirstBegin pins an unreachable branch, not a real path.

Cache writes deliberately left outside the guard

T-1900's suggested fix says to validate “immediately before every state/cache commit”. The branch guards state commits only; imageCache.set and snapshotCache.set still run on the superseded path.

This is the right call and should be stated as one. Keys are derived from resolved identity plus colorScheme/displayScale, so a superseded load can only write its own key — harmless, and a free cache warm the newer load may hit. Guarding them would discard correct work. No note records the divergence, so a future reader comparing branch to ticket will read it as an omission.

(inferred — not stated by the author.)
The token threaded as a parameter rather than the load functions returning a state

Smallest diff that touches neither control flow nor the two views' differing LoadState payloads. The cost is that “exactly one write” is per-call-site discipline across ~13 sites rather than a structural property — which is the same class of invariant that failed in the first place.

(inferred — not stated by the author.)
LoadGeneration placed in prism/Services/

Services/ already holds pure dependency-free helpers (LevenshteinDistance, ICUWhitespace, MonospaceMetrics, CodeFenceHelper, PrismURL), and Models/ is clearly document-domain. There is no prism/Concurrency/. Services/ is the least-bad existing home — no change needed.

(inferred — not stated by the author.)
No specs/bugfixes/ report for this branch

Checked against the last eight merges to main: three shipped a specs/bugfixes/ folder, five did not. There is no firm convention, so its absence is not a finding — noted only so it is not raised again.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorWebImageDetailSheet.swift:155-161, ImageDetailWindow.swift:418-424The cancellation half of T-1900's own suggested fix ('treat CancellationError as a silent return') was not implemented. SVGRenderer is @MainActor and its cancellation window is a MainActor Task.sleep (SVGRenderer.swift:64-65), so a cancelled reload's CancellationError reaches commitLoadState with no actor hop. If SwiftUI's .task(id:) cancels before it creates, the stale task's token is still current, the guard passes, and a failure state is committed over the newer result — the reported symptom, on the SVG path the ticket is named after. The internal _TaskValueModifier body is not in the .swiftinterface, so the ordering cannot be read from source: this is unknown, not benign.Recommended (not applied — this review is editorial): add `guard !Task.isCancelled else { return }` at the top of commitLoadState in both views, or `catch is CancellationError { return }` per chain. Cancellation is definitionally true for the loser regardless of scheduling, so this closes the residual by construction. It is the house pattern — RemoteRefreshFlow.swift:117/121/127/134 and RemoteContentCoordinator.swift:89/97/100 both combine it with their own generation counters.
majorprismTests/LoadGenerationTests.swiftNo test observes WebImageDetailSheet or ImageDetailWindow. The suite covers the LoadGeneration primitive and a LoadCommitHarness stand-in that mirrors the views' logic. A future edit writing `loadState = .failed(…)` directly restores T-1900 with the suite green — the failure class CLAUDE.md records for T-1943, and the harness being a copy means it stays green while the original drifts.Recommended: a ProductionSourceScan.Rule banning a bare `loadState = ` outside commitLoadState in those two files (~15 lines, reusing the machinery already shared by ImageMaterializationChokepointTests and URLChokepointAdoptionTests). Largely subsumed if the load functions are refactored to return a LoadState, leaving one write site.
minorprism/Services/LoadGeneration.swift:11-16The doc comment asserts the cancelled task 'does not observe that cancellation — it keeps running', and that 'Task.checkCancellation() does not close this'. Both are false for these call paths: SVGRenderer.swift:65 calls Task.checkCancellation(), ImageLoader.swift:40 uses withTaskCancellationHandler, and ImageLoader.swift:317 / SVGSourceLoader.swift:164 both map CancellationError to .networkError("Cancelled"). It also contradicts DocumentScrollContent.swift:279, which uses the dismissed idiom with a documented T-1744 rationale.Recommended: rewrite to say the counter is chosen because it does not depend on SwiftUI's cancel/create ordering and covers the never-cancelled slow-load half, and that the cancellation check is complementary rather than superseded. As written the comment forecloses the fix above.
minorCHANGELOG.mdThe entry claims a cancelled reload 'can no longer overwrite the current image with a stale one or a "Cancelled" error'. The stale-image half holds. The 'Cancelled' error half is order-dependent on the SVG-render path, and what the user would actually see there is not 'Cancelled' but the unlocalised bridge string "The operation couldn't be completed. (Swift.CancellationError error 1.)" — the SVG catch uses error.localizedDescription, not displayMessage. The raster path yields 'Network error: Cancelled'.Recommended: soften to the stale-image guarantee, or land the cancellation guard and keep the claim as written. The latter is preferable — the claim is the right one to be able to make.
minorRepo-wide: seven copies of one mechanismLoadGeneration does not duplicate an existing reusable type — none exists — but it is the seventh hand-rolled instance of the monotonic-epoch pattern and adopts none of them: NotesManager.swift:88 (loadGeneration, T-1556), DocumentSession.swift:148 (parseGeneration, T-718), RemoteRefreshFlow.swift:71 (currentRequestID, T-1805), RemoteContentCoordinator.swift:69 (T-862), RawSourceViewModel.swift:88 (linesGeneration, T-1759), FootnotePopoverWebPage.swift:51 (renderGeneration, T-1979), plus StoreManager's begin/commit observation clock, which is the closest structural twin. The new type's name also collides with NotesManager.loadGeneration, which is a counter value rather than a counter object. (WebDocumentController's processGeneration / BridgeGeneration is a genuinely different mechanism — a multi-field tag matched across a process boundary — and should not be migrated.)Out of scope for a bugfix. Recommended: a follow-up ticket to migrate NotesManager, DocumentSession and RemoteRefreshFlow onto LoadGeneration, or the type stays a one-off with a colliding name.
minorWebImageDetailSheet.swift:177-180, ImageDetailWindow.swift:451-454ReloadKey and ImageReloadKey are byte-identical apart from the name — both `Hashable { colorScheme; displayScale }`. Both private and platform-exclusive so they never coexist in a build, but they are one concept. Relatedly, the two load pipelines are mirrors of each other (WebImageDetailSheet.swift:11 says so in a comment), which is why this fix had to be written twice, symmetrically.Out of scope. Recommended follow-up: a shared ImageAppearanceKey and a shared DetailImageLoader returning Result<PlatformImage, ImageLoadFailure>, which would have made the T-1900 guard a single edit.
minorWebImageDetailSheet.swift:106-169, ImageDetailWindow.swift:356-432token: UInt64 is threaded into loadSVG/loadSVGImage purely to reach commitLoadState, giving ~13 guarded call sites across the two views. 'Every write is guarded' is now per-call-site discipline — the same class of invariant that failed originally.Recommended: have the load functions return a LoadState and commit once — `commitLoadState(await resolvedState(), token: token)`. Deletes the token: parameter and makes the single-write-point structural. Both functions already end in a cache-hit early return or a do/catch with a value per branch, so it is mechanical.
nitprism/Services/LoadGeneration.swift:31begin() uses `current += 1`, which traps on overflow. Every existing counter in the repo uses wrapping `&+= 1` (DocumentSession.swift:581, NotesManager.swift:287, RemoteRefreshFlow.swift:96, RawSourceViewModel.swift:257). Unreachable at ~1.8e19 loads, but a silent divergence from house convention.Recommended: use `&+= 1` for consistency. Note that wrapping would also make the token != 0 guard load-bearing rather than unreachable.
nitprism/Services/LoadGeneration.swift:24`: Sendable` is redundant. The app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so LoadGeneration is a global-actor-isolated value type and therefore implicitly Sendable. Harmless, but it reads as a claim about cross-actor use that the type does not actually make.Recommended: drop it, or keep it with a note that it is deliberate future-proofing.
nitprismTests target vs app target isolationprismTests does not set SWIFT_DEFAULT_ACTOR_ISOLATION (project.pbxproj:645-648, :670-673), so its default is nonisolated while LoadGeneration is @MainActor. The synchronous nonisolated test bodies at LoadGenerationTests.swift:18-56 therefore call a @MainActor type across isolation. Silent under -swift-version 5; a warning under Swift 6. Not a race — the value type is copied per test — but latent migration debt, matching the target-asymmetry trap already in project memory. The LoadCommitHarness test is correctly built (@MainActor class driven from async tests).No action needed on this branch. Worth knowing when the project moves to Swift 6 language mode; the whole test target will need the same treatment, and this file is not special.
nitImageDetailWindow.swift:349Pre-existing, not introduced here: the comment says 'same resolution pipeline as ImageBlockView', but ImageBlockView.swift no longer exists — it was removed in the T-1542 WebKit cutover. CLAUDE.md's Source Structure still lists it, along with MermaidPreviewCard.swift.Recommended as a drive-by only if the file is being touched anyway. The CLAUDE.md Source Structure drift is a separate, larger cleanup.
nitWebImageDetailSheet.swift:106, ImageDetailWindow.swift:356Each begin() mutates a @State struct, so every load invalidates the view and costs one extra body evaluation. Verified this cannot loop: neither ReloadKey nor ImageReloadKey reads loadGeneration or loadState, so the recomputed key is identical and .task(id:) does not restart.No action. Any counter held in @State has the same cost. Holding it on a reference type — as DocumentSession.isCurrentSaveAttempt (T-2213) and RemoteRefreshFlow.currentRequestID already do — would avoid it, but that is a style choice, not a fix.

Per-file diffs

Click to expand.

prism/Services/LoadGeneration.swift Added +42 / -0
diff --git a/prism/Services/LoadGeneration.swift b/prism/Services/LoadGeneration.swiftnew file mode 100644index 00000000..0a05ea88--- /dev/null+++ b/prism/Services/LoadGeneration.swift@@ -0,0 +1,42 @@+//+//  LoadGeneration.swift+//  prism+//++import Foundation++/// Guards an async load's state write against being superseded by a newer load.+///+/// SwiftUI's `.task(id:)` cancels the in-flight task when its id changes and starts a+/// replacement, but a detached `await` inside the cancelled task does not observe that+/// cancellation — it keeps running and can still resume and write state after the+/// replacement task's own result has already been committed (T-1900). `Task.checkCancellation()`+/// does not close this: the two tasks are still racing to write the same state, and checking+/// cancellation only catches the case where the runtime happened to notice — it does nothing+/// for a slow remote fetch or render that never revisits a suspension point until after a+/// faster, newer load has already resolved.+///+/// `begin()` hands out a token for one load attempt, superseding any attempt already in+/// flight. `isCurrent(_:)` — called immediately before every state write that follows an+/// `await` — answers whether that token is still the most recent one. A stale token means a+/// newer load has since started (or already finished); its result must be discarded, not+/// written, cancellation or no cancellation.+struct LoadGeneration: Sendable {+    private var current: UInt64 = 0++    /// Starts a new load attempt, superseding any attempt already in flight, and+    /// returns the token that identifies it.+    mutating func begin() -> UInt64 {+        current += 1+        return current+    }++    /// True when `token` is still the most recently started attempt.+    ///+    /// `token != 0` excludes the pre-`begin()` state: a fresh generation's `current`+    /// is `0`, which is otherwise indistinguishable from a token — without this guard+    /// `isCurrent(0)` reads as current before any load has ever started.+    func isCurrent(_ token: UInt64) -> Bool {+        token != 0 && token == current+    }+}
prism/Views/WebImageDetailSheet.swift Modified +23 / -10
diff --git a/prism/Views/WebImageDetailSheet.swift b/prism/Views/WebImageDetailSheet.swiftindex 2b70f455..f9d0577d 100644--- a/prism/Views/WebImageDetailSheet.swift+++ b/prism/Views/WebImageDetailSheet.swift@@ -37,6 +37,8 @@ struct WebImageDetailSheet: View {     @Environment(\.dismiss) private var dismiss      @State private var loadState: LoadState = .loading+    /// Guards `loadState` writes against a reload superseded by a newer one (T-1900).+    @State private var loadGeneration = LoadGeneration()     @State private var showFolderPicker = false      enum LoadState {@@ -98,6 +100,10 @@ struct WebImageDetailSheet: View {     }      private func load() async {+        // Captured before any await: a reload started while this one is still in+        // flight bumps the generation, so every commit below is checked against+        // the token taken here (T-1900).+        let token = loadGeneration.begin()         let resolved = ImagePathResolver.resolve(             source: request.source,             baseURL: imageBaseURL,@@ -105,12 +111,12 @@ struct WebImageDetailSheet: View {         )          if ImagePathResolver.isSVG(source: request.source, resolved: resolved) {-            await loadSVG(resolved: resolved)+            await loadSVG(resolved: resolved, token: token)             return         }          if let key = resolved.cacheKey, let cached = imageServices.imageCache.get(key) {-            loadState = .loaded(cached)+            commitLoadState(.loaded(cached), token: token)             return         } @@ -119,20 +125,20 @@ struct WebImageDetailSheet: View {             if let key = resolved.cacheKey {                 imageServices.imageCache.set(key, image: image)             }-            loadState = .loaded(image)+            commitLoadState(.loaded(image), token: token)         } catch {-            loadState = .failed(message: error.displayMessage, canGrantAccess: isAccessError(error))+            commitLoadState(.failed(message: error.displayMessage, canGrantAccess: isAccessError(error)), token: token)         }     } -    private func loadSVG(resolved: ResolvedImageSource) async {+    private func loadSVG(resolved: ResolvedImageSource, token: UInt64) async {         // Keyed on the resolved absolute identity, not the raw markdown-relative         // path string, so two documents that both reference e.g. "./diagram.svg"         // don't collide on one cache entry (T-1866). Nil when resolution failed         // (no stable identity) — caching is skipped in that case.         let svgKey = SnapshotCache.svgKey(for: resolved, colorScheme: colorScheme, displayScale: displayScale)         if let svgKey, let cached = imageServices.snapshotCache.get(svgKey) {-            loadState = .loaded(cached)+            commitLoadState(.loaded(cached), token: token)             return         }         do {@@ -145,16 +151,23 @@ struct WebImageDetailSheet: View {             if let svgKey {                 imageServices.snapshotCache.set(svgKey, image: snapshot)             }-            loadState = .loaded(snapshot)+            commitLoadState(.loaded(snapshot), token: token)         } catch let error as ImageLoadError {-            loadState = .failed(message: error.displayMessage, canGrantAccess: isAccessError(error))+            commitLoadState(.failed(message: error.displayMessage, canGrantAccess: isAccessError(error)), token: token)         } catch let error as SVGRenderError {-            loadState = .failed(message: error.localizedDescription, canGrantAccess: false)+            commitLoadState(.failed(message: error.localizedDescription, canGrantAccess: false), token: token)         } catch {-            loadState = .failed(message: error.localizedDescription, canGrantAccess: false)+            commitLoadState(.failed(message: error.localizedDescription, canGrantAccess: false), token: token)         }     } +    /// Writes `state` only if `token` is still the most recently started load (T-1900).+    /// A superseded reload silently loses the race instead of overwriting a newer result.+    private func commitLoadState(_ state: LoadState, token: UInt64) {+        guard loadGeneration.isCurrent(token) else { return }+        loadState = state+    }+     /// A sandbox/folder-access failure is the one a folder grant can fix.     private func isAccessError(_ error: ImageLoadError) -> Bool {         if case .sandboxRestricted = error { return true }
prism/Views/ImageDetailWindow.swift Modified +23 / -10
diff --git a/prism/Views/ImageDetailWindow.swift b/prism/Views/ImageDetailWindow.swiftindex 3ca6f250..4ba028e6 100644--- a/prism/Views/ImageDetailWindow.swift+++ b/prism/Views/ImageDetailWindow.swift@@ -32,6 +32,8 @@ struct ImageDetailWindow: View {     // MARK: - State      @State private var loadState: ImageWindowLoadState = .loading+    /// Guards `loadState` writes against a reload superseded by a newer one (T-1900).+    @State private var loadGeneration = LoadGeneration()     @State private var scale: CGFloat = 1.0     @State private var offset: CGSize = .zero     @State private var lastScale: CGFloat = 1.0@@ -348,6 +350,10 @@ struct ImageDetailWindow: View {      /// Loads the image using the same resolution pipeline as ImageBlockView.     private func loadImage() async {+        // Captured before any await: a reload started while this one is still in+        // flight bumps the generation, so every commit below is checked against+        // the token taken here (T-1900).+        let token = loadGeneration.begin()         let sourceType = DocumentSourceType.from(rawValue: data.sourceType)         let resolved = ImagePathResolver.resolve(             source: data.imageSource,@@ -359,13 +365,13 @@ struct ImageDetailWindow: View {         // SVGs are checked first because their cache is color-scheme-aware and         // must re-render on theme change (task re-triggers via id: colorScheme).         if ImagePathResolver.isSVG(source: data.imageSource, resolved: resolved) {-            await loadSVGImage(resolved: resolved)+            await loadSVGImage(resolved: resolved, token: token)             return         }          // Raster images: check image cache first (theme-independent)         if let key = cacheKey(for: resolved), let cached = imageServices.imageCache.get(key) {-            loadState = .loaded(cached)+            commitLoadState(.loaded(cached), token: token)             return         } @@ -375,14 +381,14 @@ struct ImageDetailWindow: View {             if let key = cacheKey(for: resolved) {                 imageServices.imageCache.set(key, image: image)             }-            loadState = .loaded(image)+            commitLoadState(.loaded(image), token: token)         } catch {-            loadState = .failed(error.displayMessage)+            commitLoadState(.failed(error.displayMessage), token: token)         }     }      /// Loads an SVG image by checking the snapshot cache, then falling back to source loading + rendering.-    private func loadSVGImage(resolved: ResolvedImageSource) async {+    private func loadSVGImage(resolved: ResolvedImageSource, token: UInt64) async {         // Keyed on the resolved absolute identity (not the raw markdown-relative         // path string), so two documents that both reference e.g. "./diagram.svg"         // don't collide on one cache entry (T-1866). Display scale is included so@@ -394,7 +400,7 @@ struct ImageDetailWindow: View {          // Check snapshot cache         if let svgKey, let cached = imageServices.snapshotCache.get(svgKey) {-            loadState = .loaded(cached)+            commitLoadState(.loaded(cached), token: token)             return         } @@ -408,16 +414,23 @@ struct ImageDetailWindow: View {             if let svgKey {                 imageServices.snapshotCache.set(svgKey, image: snapshot)             }-            loadState = .loaded(snapshot)+            commitLoadState(.loaded(snapshot), token: token)         } catch let error as ImageLoadError {-            loadState = .failed(error.displayMessage)+            commitLoadState(.failed(error.displayMessage), token: token)         } catch let error as SVGRenderError {-            loadState = .failed(error.localizedDescription)+            commitLoadState(.failed(error.localizedDescription), token: token)         } catch {-            loadState = .failed(error.localizedDescription)+            commitLoadState(.failed(error.localizedDescription), token: token)         }     } +    /// Writes `state` only if `token` is still the most recently started load (T-1900).+    /// A superseded reload silently loses the race instead of overwriting a newer result.+    private func commitLoadState(_ state: ImageWindowLoadState, token: UInt64) {+        guard loadGeneration.isCurrent(token) else { return }+        loadState = state+    }+     /// Generates a cache key from a resolved image source.     private func cacheKey(for resolved: ResolvedImageSource) -> String? {         resolved.cacheKey
prismTests/LoadGenerationTests.swift Added +140 / -0
diff --git a/prismTests/LoadGenerationTests.swift b/prismTests/LoadGenerationTests.swiftnew file mode 100644index 00000000..0c601fa8--- /dev/null+++ b/prismTests/LoadGenerationTests.swift@@ -0,0 +1,140 @@+//+//  LoadGenerationTests.swift+//  prismTests+//+//  Regression coverage for T-1900: WebImageDetailSheet and ImageDetailWindow reload+//  images via .task(id:) on colour-scheme/display-scale changes. SwiftUI cancels the+//  superseded task, but a detached await inside it ignores that cancellation and can+//  still resume and overwrite a newer, already-committed loadState. LoadGeneration is+//  the pure token/counter that both views now check immediately before every state+//  write, so a stale load loses the race regardless of arrival order.+//++import Foundation+import Testing+@testable import prism++@Suite("LoadGeneration")+struct LoadGenerationTests {+    @Test("first begin() token is current")+    func firstTokenIsCurrent() {+        var generation = LoadGeneration()+        let token = generation.begin()+        #expect(generation.isCurrent(token))+    }++    @Test("a later begin() supersedes an earlier token")+    func laterBeginSupersedesEarlierToken() {+        var generation = LoadGeneration()+        let first = generation.begin()+        let second = generation.begin()++        #expect(!generation.isCurrent(first))+        #expect(generation.isCurrent(second))+    }++    @Test("tokens from repeated begin() calls are all distinct")+    func repeatedBeginProducesDistinctTokens() {+        var generation = LoadGeneration()+        let tokens = (0..<5).map { _ in generation.begin() }++        #expect(Set(tokens).count == tokens.count)+        // Only the last one issued is current.+        for token in tokens.dropLast() {+            #expect(!generation.isCurrent(token))+        }+        #expect(generation.isCurrent(tokens.last!))+    }++    @Test("a fresh generation has no current token before begin() is called")+    func noTokenIsCurrentBeforeFirstBegin() {+        let generation = LoadGeneration()+        // Token 0 never gets handed out (begin() starts counting at 1), so a+        // caller cannot accidentally pass an "unstarted" token and have it read+        // as current.+        #expect(!generation.isCurrent(0))+    }++    /// A small stand-in for the MainActor state a view owns in production:+    /// `generation` is mutated in place by every `beginLoad()`, and `commit`+    /// re-reads it — not a snapshot taken before the race — at write time,+    /// mirroring `ImageDetailWindow`/`WebImageDetailSheet`'s `loadGeneration`/+    /// `loadState` `@State` pair and their `commitLoadState(_:token:)` guard.+    @MainActor+    private final class LoadCommitHarness {+        private(set) var generation = LoadGeneration()+        private(set) var loadState: String?++        func beginLoad() -> UInt64 {+            generation.begin()+        }++        func commit(token: UInt64, state: String) {+            guard generation.isCurrent(token) else { return }+            loadState = state+        }+    }++    /// A one-shot rendezvous that forces a specific interleaving without any+    /// timing assumption: the stale load suspends in `wait()` until the test+    /// explicitly calls `signal()`, and `waitForReady()` lets the test block+    /// until the stale load has actually reached that suspension point. No+    /// `Task.sleep`, no reliance on scheduler ordering (T-2017/T-2095).+    private actor Gate {+        private var continuation: CheckedContinuation<Void, Never>?+        private var readyContinuation: CheckedContinuation<Void, Never>?+        private var isReady = false++        func waitForReady() async {+            if isReady { return }+            await withCheckedContinuation { readyContinuation = $0 }+        }++        func wait() async {+            await withCheckedContinuation { continuation in+                self.continuation = continuation+                isReady = true+                readyContinuation?.resume()+                readyContinuation = nil+            }+        }++        func signal() {+            continuation?.resume()+            continuation = nil+        }+    }++    /// End-to-end race against the *live* generation counter, not a frozen copy —+    /// exactly what `commitLoadState` re-reads in production. The stale load+    /// begins first (capturing the older token) and then suspends; only while+    /// it's suspended does the current load begin and commit; only then does the+    /// stale load resume and attempt its own commit. If a stale write could still+    /// win, `loadState` would end up "stale" instead of "current".+    @Test("a superseded load cannot win the commit race")+    func supersededLoadCannotWinCommitRace() async {+        let harness = await LoadCommitHarness()+        let gate = Gate()++        let staleLoad = Task {+            let token = await harness.beginLoad()+            await gate.wait()+            await harness.commit(token: token, state: "stale")+        }++        // Block until the stale load has captured its token and suspended —+        // without this, the current load below could race ahead of the stale+        // load's beginLoad() and the ordering the test relies on wouldn't be+        // pinned at all.+        await gate.waitForReady()++        let currentToken = await harness.beginLoad()+        await harness.commit(token: currentToken, state: "current")++        await gate.signal()+        await staleLoad.value++        let finalState = await harness.loadState+        #expect(finalState == "current")+    }+}
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..b0d42411 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- A cancelled reload of the fullscreen image viewer (iOS) or image detail window (macOS) can no longer overwrite the current image with a stale one or a "Cancelled" error after a rapid appearance or display-scale change (T-1900). - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.

Things to double-check

Guard coverage — confirmed complete

grep -n 'loadState' prism/Views/WebImageDetailSheet.swift prism/Views/ImageDetailWindow.swift returns exactly one assignment per file (:168 and :431), both inside commitLoadState. Every other hit is the declaration, a doc comment, or a read (switch loadState, if case .loaded, guard case .loaded). ImageWindowLoadState appears nowhere else in the codebase. No unguarded write survives.

begin() before the first suspension — confirmed

let token = loadGeneration.begin() is the first statement of both load functions (WebImageDetailSheet.swift:106, ImageDetailWindow.swift:356), ahead of ImagePathResolver.resolve and every await. MainActor isolation is established two independent ways: the app target's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, and SwiftUI's .task(id:) taking an @_inheritActorContext sending @isolated(any) closure formed in @MainActor body.

Dismissal — no stuck spinner introduced

When the sheet or window goes away, .task is cancelled with no replacement, so nothing bumps the generation. The token stays current, isCurrent returns true, and the failure is committed — identical to pre-branch behaviour, and unobservable because the view is gone.

The begin()/commit pairing is total: every return path either commits or tail-calls a function that commits on all five branches (cache hit, success, ImageLoadError, SVGRenderError, generic). The newest token is current by definition, so the newest task's commit is never dropped. Re-presentation gets fresh @State on both platforms — iOS via .sheet(item:) (MediaZoomPresenter.swift:46), macOS via WindowGroup(for:) (prismApp.swift:218-220).

T-2142 (media detail caches survive document reloads) — no adverse interaction

Cache writes sit before the guard, so a superseded load still populates imageCache/snapshotCache. That is benign and mildly useful: keys are derived from resolved identity plus colorScheme/displayScale, so a superseded load writes only its own key, never the current load's, and the newer load may hit what it warmed.

The one scenario where an unguarded cache write bites — bytes changing at the same URL after a document reload — is exactly T-2142, is pre-existing, and is unchanged by this branch. Guarding these writes would not help it and would lose the cache warm. Land T-2142 independently; nothing here constrains it.

The one thing neither I nor the reviewers could read from source

Whether SwiftUI's _TaskValueModifier cancels-then-creates or creates-then-cancels on a .task(id:) re-key. The public .task(id:) is @_alwaysEmitIntoClient and readable, but it forwards to @usableFromInline internal modifiers with no inlinable body in the .swiftinterface.

Finding 1's severity rests on that unknown. If SwiftUI creates first, the guard already wins and the residual is theoretical. If it cancels first, the residual is real on the SVG-render path. The one-line cancellation guard makes the question moot, which is the argument for adding it rather than for investigating further.

Verification actually executed for this review
  • make lint — 0 violations, 0 serious, 557 files.
  • make verify-test-isolation — OK; 43 self-tests pass.
  • make build-macos, make build-ios — both exit 0. A dedicated warning grep over the macOS app-target build produced nothing.
  • xcodebuild ... -only-testing:prismTests/LoadGenerationTests on the real test host — 5/5 passed, repeated across four parallel workers.
  • Test-target build-for-testing warnings are all pre-existing macro-expansion diagnostics across unrelated suites; none names LoadGeneration.
  • Full suite deliberately not run, per the reviewer's brief.