prism branch T-1866/bugfix-relative-svg-previews-collide-across-documents PR #378 commits 1 + working tree files 6 touched lines +161 / -20 findings 0 blocking

Pre-push review: T-1866 relative SVG previews collide across documents

A cache-key identity bug in the full-screen SVG preview path. Two documents that each referenced ./diagram.svg shared one SnapshotCache entry, so the second document showed the first one's rendered picture. The fix routes both the read and the write site through a single key builder that keys on the resolved absolute identity rather than the authored path.

At a glance

  • Root cause: SnapshotCache.svgKey(source:…) hashes its source argument verbatim, and both detail views passed the authored markdown path. ./diagram.svg is not a unique identity — every document that uses that filename produces the same key.
  • Fix: a new svgKey(for: ResolvedImageSource, colorScheme:, displayScale:) -> String? keys on ResolvedImageSource.cacheKey — absolute file URL, remote URL, or a SHA-256 of data-URI bytes.
  • The subtle part: routing both the read and the write through one function. A half-migration would have produced a cache that never hits — a silent performance regression masquerading as a fix — which is why a same-document cache-hit test sits alongside the collision test.
  • Deliberately shared: two documents embedding byte-identical inline SVGs still share one entry. That is correct, not a residual collision: identical bytes render identically.
  • Blast radius is genuinely two call sites. An independent grep confirmed no other production code builds an SVG snapshot key; in-document SVGs never touch SnapshotCache at all — they are served as sanitized bytes by PrismDocSchemeHandler and rasterised by WebKit in-page.
  • Validated locally, not in CI. Actions is billing-blocked and Linux-only; main fails the same checks. Two builds, lint, and a full macOS unit run were done on this machine.

Verdict

Ready to push

The fix is correct, minimal, and lands exactly on the root cause. Both call sites now build their key through one function, which is the load-bearing property here — keying writes and reads differently would have silently disabled the cache instead of fixing the collision, and the test suite pins both halves (cross-document keys diverge and same-document reads still hit).

Local validation is the authoritative signal on this repo: GitHub Actions is billing-blocked and its checks are Linux-only, so they cannot exercise this code at all. Locally: SwiftLint clean (0 violations / 543 files), iOS and macOS builds both succeed, and the full macOS unit suite reports 4403 passed / 2 failed / 37 skipped — both failures being load-sensitive flakes (WebScrollabilityReportingTests debounce timing, StatePersistenceTests disk write) that pass in isolation and touch nothing in this diff. A first run hit an unrelated WebKit host abort that cascaded ~233 phantom failures; it did not reproduce on the second run, confirming it as environmental rather than a regression.

Four review findings were raised and fixed in the working tree: a missing CHANGELOG entry (11 of the last 12 Fix T- commits on main ship one), three missing test cases, and two stale/misleading doc comments — including the one that made the original bug possible.

Review findings

9 raised · 5 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism can show an SVG image full screen. Rendering an SVG into a picture is slow, so Prism keeps the finished picture in a memory store called a cache and reuses it the next time you open the same image.

To find things again, a cache needs a label for each item — a key. Prism was using the name written in the markdown file as that label. Markdown lets you write a short, relative name like ./diagram.svg, meaning "the file called diagram.svg sitting next to this document".

Why it mattered

That name is only meaningful relative to a document. Two unrelated documents can each have their own diagram.svg next to them, and both would write ./diagram.svg. Prism saw one label, so it saw one item. Open document A's diagram, then open document B's — and B showed you A's picture. Nothing warned you it was the wrong image, and it stayed wrong for the rest of the session.

The fix

Prism already had a step that turns a relative name into the real thing: a full file path, a web address, or (for images written directly into the document) the image's own bytes. The cache label is now built from that instead of from the written name. Two diagram.svg files in different folders are different files, so they now get different labels and stop overwriting each other.

Key concepts

  • Cache key — the label a cache files an item under. If two different things share a label, the cache hands back the wrong one. This is called a key collision.
  • Relative vs absolute./diagram.svg is relative (only meaningful given a starting point); /Users/you/Docs/A/diagram.svg is absolute (meaningful on its own). Only absolute things make safe labels.
  • Reading and writing must agree — a cache is two operations, storing and looking up. If they build the label differently, nothing is ever found. The fix makes both call the same piece of code so they cannot drift apart.

Architecture

SnapshotCache is an NSCache-backed store of rasterised SVG and Mermaid snapshots, keyed by a SHA-256 over source + colorScheme + displayScale. The scheme and scale components exist because the same SVG rasterises differently in light vs dark and at 1x vs 2x vs 3x (T-917).

The source component was the defect. svgKey(source:colorScheme:displayScale:) hashes whatever string you hand it, and its doc comment described that string as "the SVG source content string" — but both production callers passed a path: data.imageSource in ImageDetailWindow and request.source in WebImageDetailSheet. Those are the raw, authored markdown references, which for relative paths are not app-unique.

The pattern applied

The fix adds an overload rather than changing the existing one:

static func svgKey(for resolved: ResolvedImageSource,
                   colorScheme: ColorScheme,
                   displayScale: CGFloat) -> String? {
    guard let identity = resolved.cacheKey else { return nil }
    return svgKey(source: identity, colorScheme: colorScheme, displayScale: displayScale)
}

ResolvedImageSource.cacheKey already existed and already had the right semantics — it is what the raster image path had been keying on all along. It returns url.absoluteString for .localFile and .remote, a SHA-256 of the decoded bytes for .dataURI, and nil for .failed. The SVG path simply was not using it. So this is less "new mechanism" than "bring the outlier into line with the convention the rest of the image pipeline already followed".

Trade-offs

  • Optional return vs throwing. A failed resolution has no stable identity, so the function returns String? and callers guard with if let. The rejected alternative — synthesising a fallback key from the raw source — would have quietly reintroduced a narrower version of the same bug.
  • Overload vs replacement. Keeping svgKey(source:) means a future caller could still pick the unsafe one. Mitigated here by documentation rather than by type: the string overload now states outright that an authored relative path is not an acceptable argument and names T-1866.
  • More cache entries. Correct keying means more distinct entries than the old colliding key produced. That is the point — the old low entry count came from unrelated documents overwriting each other, not from genuine reuse. NSCache still evicts against the unchanged 256 MB cost limit.

Failure-mode analysis

This is an identity bug, not a caching bug: the cache behaved exactly as designed, and the key it was given was simply not injective over the domain it was used on. The interesting property is that the correct identity function (ResolvedImageSource.cacheKey) was already present in the codebase and already in use on the sibling raster path in the very same functions — ImageDetailWindow.loadImage guards its raster lookup with cacheKey(for: resolved) three lines above the SVG branch that did not. The SVG path was the outlier, which is why the fix reads as deletion of a special case rather than as new machinery.

The failure the fix could have introduced

Worth naming explicitly because it is the more expensive of the two failure modes and it is invisible: a partial migration that updated the write key but not the read key (or vice versa) yields a cache with a 0% hit rate. Every open re-rasterises through SVGRenderer — a WKWebView round-trip — and nothing is visibly wrong. The collision bug is loud (wrong picture); the miss bug is silent (slow). The commit message calls this out as the reason for centralising on one function, and the test suite encodes it: svgKeyForResolvedSourceEnablesCacheHitForSameDocument stores under a key from one resolution and retrieves under a key from a second, independent resolution, asserting === identity through a real SnapshotCache instance. A key-string equality assertion alone would not have caught a broken round-trip.

Edge cases traced

  • .failednil → skip caching. Verified benign end to end: SVGSourceLoader.load(_:) throws immediately on .failed, before any set could be reached, so a failed resolution was never cached before this change either. The guards are defensive, not behavioural — and critically, the nil path still falls through to the same catch let error as ImageLoadError branch, so loadState is never left unset.
  • Data URIs share entries across documents. The key is a SHA-256 of decoded bytes with no document component, so identical inline SVGs in different documents collide by design. This is content-addressing, and it is sound: rasterisation is a pure function of (bytes, scheme, scale), all three of which are in the key. It is also not a behaviour change — the old key was the full literal data-URI string, which already shared.
  • Absolute vs relative references to the same file converge. Both flow through resolvedSource(url:asLocal:) after .standardized normalisation, so they produce one entry rather than two. Pre-existing, preserved.
  • Double hashing on data URIs. cacheKey hashes the payload, then svgKey hashes a ~70-byte digest string. The second hash is constant-cost regardless of payload size, and the old code hashed a string of the same order of magnitude as the payload, so total work is essentially unchanged.

Architectural impact

Confined to two full-screen detail surfaces. The in-document SVG path does not participate: PrismDocSchemeHandler serves sanitized SVG bytes over prism-doc:// and WebKit rasterises them in-page, touching SnapshotCache not at all. There is therefore no second keying scheme to keep in step, and no cross-path cache-coherence question to answer — a fact that makes the fix much smaller than the ticket title suggests.

Important changes — detailed

SnapshotCache: single key builder over the resolved identity

prism/Services/SnapshotCache.swift

Why it matters. This is the whole fix. It converts the cache key from a document-relative, non-unique string into an app-unique identity, and gives both call sites one place to get it from so they cannot drift apart.

What to look at. SnapshotCache.swift:52-73 — svgKey(for:colorScheme:displayScale:)

Takeaway. When a cache key is built at more than one site, make the builder a function before you make it correct. Correctness that has to be re-derived at each call site is correctness with a half-life — and the failure mode of disagreeing key builders (a permanent 0% hit rate) is silent, where the failure mode of a wrong key (visibly wrong content) is loud.
Rationale. Stated in the commit message: "Routing both through one function is the point: keying writes and reads differently would have silently disabled caching instead of fixing the collision." The Optional return was chosen over a synthesised fallback key because a failed resolution has no stable identity, and inventing one would reintroduce a narrower version of the same collision.

Both detail surfaces migrated off the authored path

prism/Views/ImageDetailWindow.swift

Why it matters. These two views ARE the bug's blast radius. An independent grep of every svgKey / snapshotCache.get / snapshotCache.set site confirmed no third production caller, so the migration is complete rather than partial.

What to look at. ImageDetailWindow.swift:383-410 (loadSVGImage) and WebImageDetailSheet.swift:126-147 (loadSVG)

Takeaway. The correct identity already existed on the sibling raster path in the same function — ImageDetailWindow.loadImage guards its raster lookup with resolved.cacheKey three lines above the SVG branch that did not. When one branch of a function is wrong and its neighbour is right, the fix is usually convergence, not invention.
Rationale. Passing `resolved` down rather than re-resolving inside the key builder keeps resolution at its existing single site (loadImage / load) and avoids a second ImagePathResolver.resolve call per load.

Cache-hit round-trip test, not a key-equality test

prismTests/SnapshotCacheTests.swift

Why it matters. This test is what distinguishes a real fix from a fix that silently disabled the cache. It stores under a key from one resolution and reads under a key from a second, independent resolution, through a real SnapshotCache.

What to look at. SnapshotCacheTests.swift:211-228 — svgKeyForResolvedSourceEnablesCacheHitForSameDocument

Takeaway. For any keyed store, one collision test is not enough — pair it with a hit test. The collision test alone passes just as happily against a key function that returns a fresh UUID every call, which is the exact shape of the regression you are most likely to introduce while fixing a collision.
Rationale. Stated in the commit message: "the tests assert same-document cache hits still occur as well as that cross-document collisions stop."

Review fix: doc comment on the unsafe overload now names the trap

prism/Services/SnapshotCache.swift

Why it matters. The string overload survives and remains callable, so the bug remains reachable by a future caller. Its parameter doc previously said "the SVG source content string" — while both production callers were passing a path. That mismatch is arguably what enabled T-1866 in the first place.

What to look at. SnapshotCache.swift:34-50 — svgKey(source:colorScheme:displayScale:) doc comment

Takeaway. A doc comment that disagrees with every one of a function's actual callers is not merely stale — it is actively load-bearing in the wrong direction, because it is the thing a reader checks before deciding an argument is safe. Fixing the code without fixing the comment leaves the trap armed.
Rationale. Raised during this review and applied to the working tree. Renaming the overload (e.g. to svgContentKey) was considered as the stronger, type-level fix but rejected as beyond the scope of a bugfix branch; documentation is the proportionate mitigation here.

Review fix: CHANGELOG entry added

CHANGELOG.md

Why it matters. This is a user-visible correctness bug — the wrong picture, silently — and the repo convention is unambiguous: 11 of the last 12 `Fix T-` commits on main ship an Unreleased/Fixed entry. The branch had none.

What to look at. CHANGELOG.md:23 — new Unreleased/Fixed entry

Takeaway. The entry deliberately documents the data-URI sharing as intended behaviour. A reader who later notices two documents sharing one inline-image preview would otherwise reasonably file it as the same bug reopening.
Rationale. Convention verified directly against history rather than assumed — `git log origin/main -12 --grep='^Fix T-'` cross-referenced against whether each commit touched CHANGELOG.md.

Key decisions

Add an overload rather than change svgKey(source:)'s signature

The existing string-taking function is kept and the new one delegates to it. This preserves the existing Mermaid-key symmetry and leaves the tests that exercise real SVG content strings valid. The cost is that the unsafe overload stays reachable — mitigated in this review by a doc comment that names T-1866 and states outright that an authored relative path is not an acceptable argument.

Return String? and let callers skip caching, rather than throwing or synthesising a fallback

A .failed resolution has no stable identity. Throwing would push identical try? boilerplate into both call sites for a condition that is not exceptional; a synthesised fallback key (e.g. hashing the raw authored source) would quietly reintroduce a narrower version of the very collision being fixed. Optional plus if let at two sites is the honest encoding of "there is no key for this".

Data-URI images intentionally share one cache entry across documents

ResolvedImageSource.cacheKey hashes the decoded bytes with no document component, so byte-identical inline SVGs in different documents map to one entry. This is content-addressing and it is correct — rasterisation is a pure function of (bytes, colour scheme, display scale), and all three are in the key. It is also not a behaviour change: the previous key was the full literal data-URI string, which already shared. Documented in the CHANGELOG so it is not later mistaken for a residual collision.

Pass `resolved` into the key builder rather than re-resolving inside it

Both call sites already hold a ResolvedImageSource produced by their enclosing load function. Taking it as a parameter keeps ImagePathResolver.resolve at one site per load and keeps SnapshotCache free of any dependency on the resolver.

(inferred — not stated by the author.)
No bugfix report under specs/bugfixes/

Sampling recent history, roughly one in four comparable fixes ships a report, generally where the root-cause narrative is involved or several distinct defects were found under one ticket. T-1866 is a single contained root cause with a two-line fix, so the CHANGELOG entry plus the doc comments carry the explanation adequately. The CHANGELOG entry, by contrast, is required by convention and was added.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorCHANGELOG.mdNo Unreleased/Fixed entry, against a strong repo convention: 11 of the last 12 'Fix T-' commits merged to main add one. This is a user-visible correctness bug (wrong image shown, silently), which is exactly the category the changelog exists to record.Added an entry in the established user-facing prose style. It describes the symptom, the cause, and the fix, and explicitly documents the intended data-URI sharing so it is not later misread as the same bug reopening.
minorprism/Services/SnapshotCache.swift doc commentsvgKey(source:) documents its parameter as 'The SVG source content string', but both production callers were passing an authored path — the mismatch that made T-1866 possible. The overload survives the fix and stays callable, so the trap stays armed for a future caller who reads the doc and concludes a path is fine.Rewrote the doc to state that source is hashed verbatim and must already be app-unique, that an authored document-relative path is NOT such a string, that this was T-1866, and that any caller holding a ResolvedImageSource must use the new overload.
minorprismTests/SnapshotCacheTests.swiftOnly 2 of ResolvedImageSource's 4 cases were exercised (.localFile, .failed). The .dataURI case is named in the new function's own doc comment as an explicit design goal — identical bytes in different documents SHOULD share an entry — with zero coverage; .remote was untested entirely.Added svgKeyForResolvedSourceHandlesRemoteURLs (distinct remote URLs separate, same URL is stable) and svgKeyForResolvedSourceSharesEntryForIdenticalDataURIs (identical bytes share, differing bytes separate), pinning the sharing behaviour as intentional.
minorprismTests/SnapshotCacheTests.swiftNo test pinned that colorScheme and displayScale still fragment the key through the NEW overload. Existing T-917 coverage exercises only the old string overload, and the cross-overload equivalence test fixes scheme and scale at one pair — so a refactor that dropped either parameter from svgKey(for:) would pass the suite.Added svgKeyForResolvedSourceFragmentsBySchemeAndScale, asserting all four (scheme x scale) combinations produce four distinct keys through the new overload.
minordocs/agent-notes/image-support.mdThe Task 16 section documents SnapshotCache.svgKey(source:colorScheme:) reached via ImageBlockView — a view retired in the T-1542 WebKit cutover, and the exact stale signature a future session would copy to reintroduce this bug. Pre-existing, but directly in this change's path, and CLAUDE.md directs that stale notes be fixed rather than left.Added a 'Superseded' callout at the head of the section: ImageBlockView is gone, in-document SVGs go through PrismDocSchemeHandler and never touch SnapshotCache, and the two detail surfaces must key through svgKey(for:) on the resolved identity. Historical bullets retained below it.
minorFull-suite test runWebScrollabilityReportingTests ('a sustained trigger burst still reports within the debounce's maximum wait') and StatePersistenceTests ('save overwrites existing state for same session') failed in the full macOS run. The first failed on wall-clock timing (only 1 trigger observed in 0.27s under load); neither touches SnapshotCache or the image path.Both re-run in isolation: 31/31 pass. Load-sensitive flakes on a shared machine, not regressions from this diff. Left as-is — modifying them would be papering over an environment characteristic, not fixing a bug.
minorFull-suite test run (first attempt)The first full run reported 234 failures. Analysis of the result bundle showed 233 with no recorded duration (never ran) and every reported failure message reading 'Test crashed with signal abrt' — the signature of one host abort cascading. The crash report shows a WebKit NSException on a Swift concurrency job; the two known crashers (SVGWebViewTests / T-1541, MermaidCSPSpikeTests / T-2219) were verified absent from the bundle, so a third exists.Re-ran the full suite: the cascade did not reproduce (4403 passed / 2 failed), confirming it as a load-related environmental abort rather than anything introduced here. Not this branch's to fix — the diff contains no WebKit surface.
nitprism/Views/ImageDetailWindow.swiftThe private cacheKey(for:) helper is a one-line passthrough to resolved.cacheKey, while WebImageDetailSheet does the identical raster lookup inline. Pre-existing asymmetry, untouched by this branch and still live on the raster path.Not changed. Removing it is unrelated cleanup on a bugfix branch and would widen the diff without improving the fix.
nitBoth call sitesImageDetailWindow and WebImageDetailSheet now carry near-identical explanatory comments about the keying.Not changed. The duplicated text is rationale, not logic — the logic is already deduplicated into the single key builder — and keeping it local means each call site explains itself without a jump to SnapshotCache.

Per-file diffs

Click to expand.

prism/Services/SnapshotCache.swift Modified +31 / -1
diff --git a/prism/Services/SnapshotCache.swift b/prism/Services/SnapshotCache.swiftindex a8190e0..eea0424 100644--- a/prism/Services/SnapshotCache.swift+++ b/prism/Services/SnapshotCache.swift@@ -37,8 +37,15 @@ final class SnapshotCache: @unchecked Sendable {     /// Display scale is included in the key so snapshots rasterised at     /// different pixel densities never collide (T-917).     ///+    /// `source` is hashed verbatim, so it must already be a string that+    /// identifies the image uniquely across the whole app. An authored,+    /// document-relative path is NOT such a string — two documents each+    /// referencing `./diagram.svg` produce the same key and collide, which+    /// was T-1866. Callers holding a `ResolvedImageSource` must use+    /// `svgKey(for:colorScheme:displayScale:)` instead of this overload.+    ///     /// - Parameters:-    ///   - source: The SVG source content string.+    ///   - source: SVG content, or an already-absolute identity string.     ///   - colorScheme: The current color scheme (light/dark).     ///   - displayScale: The destination display's scale factor.     /// - Returns: A deterministic cache key string with "svg:" prefix.@@ -49,6 +56,29 @@ final class SnapshotCache: @unchecked Sendable {         return "svg:" + hash.compactMap { String(format: "%02x", $0) }.joined()     } +    /// Generates a cache key for SVG snapshots from a resolved image source,+    /// color scheme, and display scale.+    ///+    /// Keys on `resolved.cacheKey` — the resolved absolute source identity+    /// (file URL, remote URL, or data URI hash) — rather than the raw+    /// markdown-relative path string a caller might otherwise use. Two+    /// different documents that both reference e.g. `./diagram.svg` resolve+    /// to different absolute identities and so no longer collide on one+    /// cache entry (T-1866). Callers at both the write site and the read+    /// site should build the key through this single function so the two+    /// can never disagree.+    ///+    /// - Returns: `nil` when `resolved` is `.failed` and has no stable+    ///   identity to key on — callers should skip caching in that case.+    static func svgKey(+        for resolved: ResolvedImageSource,+        colorScheme: ColorScheme,+        displayScale: CGFloat+    ) -> String? {+        guard let identity = resolved.cacheKey else { return nil }+        return svgKey(source: identity, colorScheme: colorScheme, displayScale: displayScale)+    }+     /// Generates a cache key for mermaid snapshots from source, PrismTheme,     /// and display scale.     ///
prism/Views/ImageDetailWindow.swift Modified +12 / -12
diff --git a/prism/Views/ImageDetailWindow.swift b/prism/Views/ImageDetailWindow.swiftindex 8ce3098..3ca6f25 100644--- a/prism/Views/ImageDetailWindow.swift+++ b/prism/Views/ImageDetailWindow.swift@@ -383,19 +383,17 @@ struct ImageDetailWindow: View {      /// Loads an SVG image by checking the snapshot cache, then falling back to source loading + rendering.     private func loadSVGImage(resolved: ResolvedImageSource) async {-        // Use the same key format as ImageBlockView for cache consistency.-        // Display scale is included so cache entries don't collide across-        // displays with different backing scale factors. Pulled from the-        // SwiftUI environment so the value tracks the screen this window is-        // actually on (not the key window's screen).-        let svgKey = SnapshotCache.svgKey(-            source: data.imageSource,-            colorScheme: colorScheme,-            displayScale: displayScale-        )+        // 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+        // cache entries don't collide across displays with different backing+        // scale factors. Pulled from the SwiftUI environment so the value tracks+        // the screen this window is actually on (not the key window's screen).+        // Nil when resolution failed (no stable identity) — caching is skipped.+        let svgKey = SnapshotCache.svgKey(for: resolved, colorScheme: colorScheme, displayScale: displayScale)          // Check snapshot cache-        if let cached = imageServices.snapshotCache.get(svgKey) {+        if let svgKey, let cached = imageServices.snapshotCache.get(svgKey) {             loadState = .loaded(cached)             return         }@@ -407,7 +405,9 @@ struct ImageDetailWindow: View {                 colorScheme: colorScheme,                 displayScale: displayScale             )-            imageServices.snapshotCache.set(svgKey, image: snapshot)+            if let svgKey {+                imageServices.snapshotCache.set(svgKey, image: snapshot)+            }             loadState = .loaded(snapshot)         } catch let error as ImageLoadError {             loadState = .failed(error.displayMessage)
prism/Views/WebImageDetailSheet.swift Modified +9 / -7
diff --git a/prism/Views/WebImageDetailSheet.swift b/prism/Views/WebImageDetailSheet.swiftindex c2ad367..2b70f45 100644--- a/prism/Views/WebImageDetailSheet.swift+++ b/prism/Views/WebImageDetailSheet.swift@@ -126,12 +126,12 @@ struct WebImageDetailSheet: View {     }      private func loadSVG(resolved: ResolvedImageSource) async {-        let svgKey = SnapshotCache.svgKey(-            source: request.source,-            colorScheme: colorScheme,-            displayScale: displayScale-        )-        if let cached = imageServices.snapshotCache.get(svgKey) {+        // 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)             return         }@@ -142,7 +142,9 @@ struct WebImageDetailSheet: View {                 colorScheme: colorScheme,                 displayScale: displayScale             )-            imageServices.snapshotCache.set(svgKey, image: snapshot)+            if let svgKey {+                imageServices.snapshotCache.set(svgKey, image: snapshot)+            }             loadState = .loaded(snapshot)         } catch let error as ImageLoadError {             loadState = .failed(message: error.displayMessage, canGrantAccess: isAccessError(error))
prismTests/SnapshotCacheTests.swift Modified +106 / -0
diff --git a/prismTests/SnapshotCacheTests.swift b/prismTests/SnapshotCacheTests.swiftindex f7dc6f4..1244f1d 100644--- a/prismTests/SnapshotCacheTests.swift+++ b/prismTests/SnapshotCacheTests.swift@@ -190,6 +190,112 @@ struct SnapshotCacheTests {         #expect(Set([light1x, light2x, dark1x, dark2x]).count == 4)     } +    // MARK: - SVG Key From Resolved Source (T-1866)++    @Test("svgKey(for:) differs for the same relative path resolved against different documents")+    func svgKeyForResolvedSourceDiffersAcrossDocuments() {+        // Two documents that both reference "./diagram.svg" resolve to different+        // absolute file URLs. Regression for T-1866: keying on the raw relative+        // path string made these collide on one cache entry.+        let docA = ResolvedImageSource.localFile(URL(fileURLWithPath: "/Users/x/DocA/diagram.svg"))+        let docB = ResolvedImageSource.localFile(URL(fileURLWithPath: "/Users/x/DocB/diagram.svg"))++        let keyA = SnapshotCache.svgKey(for: docA, colorScheme: .light, displayScale: 2.0)+        let keyB = SnapshotCache.svgKey(for: docB, colorScheme: .light, displayScale: 2.0)++        #expect(keyA != nil)+        #expect(keyB != nil)+        #expect(keyA != keyB)+    }++    @Test("svgKey(for:) produces the same key for the same resolved document, enabling cache hits")+    func svgKeyForResolvedSourceEnablesCacheHitForSameDocument() {+        let resolved = ResolvedImageSource.localFile(URL(fileURLWithPath: "/Users/x/DocA/diagram.svg"))++        let firstLoad = SnapshotCache.svgKey(for: resolved, colorScheme: .light, displayScale: 2.0)+        let secondLoad = SnapshotCache.svgKey(for: resolved, colorScheme: .light, displayScale: 2.0)++        #expect(firstLoad != nil)+        #expect(firstLoad == secondLoad)++        // Prove it end-to-end through the cache: a write keyed by the first+        // resolution is a HIT on a read keyed by a second, independent+        // resolution of the same document — not just equal key strings.+        let cache = SnapshotCache()+        let image = makeTestImage()+        cache.set(firstLoad!, image: image)+        #expect(cache.get(secondLoad!) === image)+    }++    @Test("svgKey(for:) returns nil when resolution failed, so callers skip caching")+    func svgKeyForResolvedSourceIsNilOnFailure() {+        let failed = ResolvedImageSource.failed(.fileNotFound)+        let key = SnapshotCache.svgKey(for: failed, colorScheme: .light, displayScale: 2.0)+        #expect(key == nil)+    }++    @Test("svgKey(for:) matches svgKey(source:) built from the same resolved identity")+    func svgKeyForResolvedSourceMatchesSourceOverload() {+        let url = URL(fileURLWithPath: "/Users/x/DocA/diagram.svg")+        let resolved = ResolvedImageSource.localFile(url)++        let viaResolved = SnapshotCache.svgKey(for: resolved, colorScheme: .dark, displayScale: 3.0)+        let viaSource = SnapshotCache.svgKey(source: url.absoluteString, colorScheme: .dark, displayScale: 3.0)++        #expect(viaResolved == viaSource)+    }++    @Test("svgKey(for:) separates distinct remote URLs and reuses one entry per URL")+    func svgKeyForResolvedSourceHandlesRemoteURLs() {+        let one = ResolvedImageSource.remote(URL(string: "https://a.example/diagram.svg")!)+        let two = ResolvedImageSource.remote(URL(string: "https://b.example/diagram.svg")!)++        let keyOne = SnapshotCache.svgKey(for: one, colorScheme: .light, displayScale: 2.0)+        let keyTwo = SnapshotCache.svgKey(for: two, colorScheme: .light, displayScale: 2.0)+        let keyOneAgain = SnapshotCache.svgKey(for: one, colorScheme: .light, displayScale: 2.0)++        #expect(keyOne != nil)+        #expect(keyOne != keyTwo)+        #expect(keyOne == keyOneAgain)+    }++    @Test("svgKey(for:) shares one entry for byte-identical inline SVGs in different documents")+    func svgKeyForResolvedSourceSharesEntryForIdenticalDataURIs() {+        // Sharing here is correct, not a collision: a data URI carries its own+        // content, so identical bytes render identically no matter which+        // document embedded them. Differing bytes must still separate.+        let svg = Data("<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>".utf8)+        let other = Data("<svg xmlns=\"http://www.w3.org/2000/svg\"><circle/></svg>".utf8)++        let inDocA = ResolvedImageSource.dataURI(svg, mimeType: "image/svg+xml")+        let inDocB = ResolvedImageSource.dataURI(svg, mimeType: "image/svg+xml")+        let different = ResolvedImageSource.dataURI(other, mimeType: "image/svg+xml")++        let keyA = SnapshotCache.svgKey(for: inDocA, colorScheme: .light, displayScale: 2.0)+        let keyB = SnapshotCache.svgKey(for: inDocB, colorScheme: .light, displayScale: 2.0)+        let keyDifferent = SnapshotCache.svgKey(for: different, colorScheme: .light, displayScale: 2.0)++        #expect(keyA != nil)+        #expect(keyA == keyB)+        #expect(keyA != keyDifferent)+    }++    @Test("svgKey(for:) still fragments by color scheme and display scale")+    func svgKeyForResolvedSourceFragmentsBySchemeAndScale() {+        // Pins T-917 through the new overload specifically: it must carry+        // scheme and scale through, not just the resolved identity.+        let resolved = ResolvedImageSource.localFile(URL(fileURLWithPath: "/Users/x/DocA/diagram.svg"))++        let light1x = SnapshotCache.svgKey(for: resolved, colorScheme: .light, displayScale: 1.0)+        let light2x = SnapshotCache.svgKey(for: resolved, colorScheme: .light, displayScale: 2.0)+        let dark1x = SnapshotCache.svgKey(for: resolved, colorScheme: .dark, displayScale: 1.0)+        let dark2x = SnapshotCache.svgKey(for: resolved, colorScheme: .dark, displayScale: 2.0)++        let keys = [light1x, light2x, dark1x, dark2x].compactMap { $0 }+        #expect(keys.count == 4)+        #expect(Set(keys).count == 4)+    }+     // MARK: - Clear      @Test("Clear removes all entries")
CHANGELOG.md Review fix +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex dec5fa0..1b10f3f 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Opening an SVG image full screen now shows that document's image, rather than one belonging to a different document (T-1866). Two documents that each referred to an SVG by the same relative name — `./diagram.svg` sitting next to each file, say — shared a single entry in the store of rendered previews, because that store was keyed on the name written in the markdown instead of on the file the name actually resolves to. Whichever document you opened first won: opening the second document's diagram showed you the first one's picture, with nothing to indicate it was the wrong one, and it kept doing so for the rest of the session. Rendered SVG previews are now keyed on the resolved image itself — a file's full path, a remote address, or the contents of an inline image — so same-named images in different documents can no longer stand in for one another. Reopening the same image in the same document still comes back instantly from the store, as before, and two documents that embed a byte-for-byte identical inline image do still share one rendered preview, which is correct: identical content renders identically. - A note imported from a document, anchored to a nested list item (a sub-item under a top-level list item), now shows its quoted text and the section it belongs to (T-1871). In the notes pane it appeared with a blank quote and no heading, and it could not be told apart from any other nested-item note in the document. The note itself was always attached to the correct item — only the surrounding context was missing, and it was missing every time the document was opened, not just on a reload. Imported notes are rebuilt from the document on each open, and that rebuild only recognised top-level list-item identifiers, so a nested item's identifier was never matched and the context came back empty. Nested identifiers are now recognised the same way every other part of the app that addresses list items already recognises them, so an imported note on a nested item gets the same context as one on a top-level item. - A `prism://open?url=…` link now opens the address it names, even when that address mixes already-escaped and unescaped characters (T-2140). `…/my%20file and more.md` was fetched as `…/my%2520file%20and%20more.md` — a different resource, with no error shown — because the address had already been unescaped one layer by the time it was read, and was then escaped a second time in full. Investigating it surfaced a second fault of the same kind, live on every markdown link and image in every document: the escaping used for addresses turned an escaped `%2F` back into a real `/`, splitting one path segment into two. That silently broke any address that identifies something by an escaped path — a GitLab project URL, for instance, which 404s once `group%2Fproj` becomes `group/proj`. Escaped slashes and escaped ampersands now survive every route into the app: typed and pasted addresses, deep links, document links and images, `mailto:` links, and the GitHub blob-to-raw rewrite. One narrow side effect of reworking that rewrite: a GitHub address written with a doubled slash in it (`github.com//owner/repo/blob/…`) is no longer recognised as a file address, so it now reports an unsupported content type instead of opening. 
docs/agent-notes/image-support.md Review fix +2 / -0
diff --git a/docs/agent-notes/image-support.md b/docs/agent-notes/image-support.mdindex f40cf65..e914f69 100644--- a/docs/agent-notes/image-support.md+++ b/docs/agent-notes/image-support.md@@ -116,6 +116,8 @@  ## ImageBlockView SVG routing and tap-to-zoom (Task 16) +> **Superseded — read this before the bullets below.** `ImageBlockView` was retired in the T-1542 WebKit cutover. In-document SVGs are now served as sanitized bytes by `PrismDocSchemeHandler` and rasterised by WebKit in-page, with no `SnapshotCache` involvement at all. `SnapshotCache` is used for SVGs only by the two full-screen detail surfaces (`ImageDetailWindow`, `WebImageDetailSheet`), and both build the key through `SnapshotCache.svgKey(for:colorScheme:displayScale:)`, which keys on `ResolvedImageSource.cacheKey` — the resolved absolute identity (file URL, remote URL, or data-URI content hash) — **not** the authored path. Keying on the authored path was T-1866: two documents each referencing `./diagram.svg` collided on one entry and showed each other's snapshot. Build the read key and the write key through that one function; a `nil` return means resolution failed and the caller must skip caching. The bullets below record the original Task 16 design and are kept as history.+ - `ImageBlockView` now accepts `width: ImageDimension?` and `height: ImageDimension?` parameters (from HTML `<img>` attributes). - `TextualBlockView` passes width/height through to `ImageBlockView` instead of discarding with `_, _`. - SVG detection: after `resolveOnce()`, checks `ImagePathResolver.isSVG(source:resolved:)` to route SVG vs raster.

Things to double-check

Existing cache entries are silently invalidated on upgrade

Every SVG snapshot key changes with this build, so the first open of any SVG after upgrading re-rasterises. SnapshotCache is an in-memory NSCache with no on-disk persistence, so this costs exactly one render per image per session and nothing needs migrating — but it is worth having consciously accepted rather than discovered.

The unsafe overload remains public and callable

svgKey(source:colorScheme:displayScale:) is still reachable, and nothing in the type system stops a future caller passing an authored path to it. This review strengthened its documentation, which is proportionate for a bugfix branch. If this class of bug recurs, the durable fix is a type-level one — make the string overload private and have callers reach it only through a resolved identity, or introduce a distinct SnapshotIdentity wrapper type that an authored path cannot be silently coerced into.

Neither detail view has a call-site test

Coverage sits entirely at the key-builder level; nothing asserts that ImageDetailWindow.loadSVGImage and WebImageDetailSheet.loadSVG actually call the new overload. This matches the existing convention (mermaidKey and svgKey(source:) have no call-site tests either) and both migrations are two-line mechanical changes, so the risk is low — but this is the same shape as the T-1943 lesson recorded in CLAUDE.md, where a correct component passed all its direct-invocation tests while never being wired up in production.

A third full-suite crasher is unaccounted for

The first run's abort was not SVGWebViewTests (T-1541) or MermaidCSPSpikeTests (T-2219) — both were confirmed excluded from the result bundle. Something else aborts the host under sequential load. Unrelated to this branch and out of scope here, but it will keep costing full-suite runs their legibility until it is identified, and is worth a ticket.