One commit replacing BlockIDCache's clear-on-overflow wipe with a non-promoting two-generation BlockIDStore, plus regression tests. Reviewed across reuse, quality, efficiency, and spec adherence.
block.id values are unchanged (same content-only SHA-256) — persisted notes and scroll positions still resolve by exact match.block.id) was deliberately dropped — duplicate-content ForEach collisions are already handled at the view layer, and the change would orphan persisted notes.main are filed as T-1440 / T-1441 / T-1442, not introduced by this change.Ready to push
No blockers or major findings. The eviction logic is correct (overflow walk verified by two independent reviewers), spec adherence is complete, and the tests verify the requirements they claim. One minor hardening was applied during review (explicit Sendable conformance on BlockIDStore). Remaining findings are pre-existing and tracked separately (T-1440, T-1441, T-1442).
c679bba T-1165: Replace BlockIDCache clear-on-overflow with two-generation eviction Each markdown block has a stable id (a SHA-256 hash of its content) that SwiftUI uses to track it on screen. Computing it is expensive, so the answer is cached. The old cache, once it held 10,000 answers, threw all of them away at once. On a very large document that wipe could recur within a single screen layout (SwiftUI reads each block's id many times per pass), forcing expensive recomputation and causing stutter.
The new cache keeps two buckets. New answers fill the first; when it's full, it slides to become the second and a fresh first bucket starts, discarding only the oldest bucket. It never throws everything away. Nothing the user sees changes — the id values are identical, so saved notes and scroll positions still line up.
Memoization, cache eviction policy, and SwiftUI list identity (every row needs a stable ID).
The storage and eviction policy moved out of the BlockIDCache enum into a new internal struct BlockIDStore holding two dictionaries (primary, secondary). BlockIDCache is now a thin process-global wrapper — a Mutex<BlockIDStore> plus delegating id(for:)/clear(). MarkdownBlock.id and clearIDCache() are unchanged at the call site.
primary then secondary; a hit in either returns immediately without promoting into primary.primary.count >= maxEntries, rotate (secondary = primary; primary = [:]), then insert.removeAll() empties both generations; clear()/clearIDCache() delegate to it.Extracting an instantiable type lets tests use a small-cap instance instead of racing on the process-global static under parallel test execution. Non-promoting was chosen over promotion-on-hit (which refills primary faster, causing more rotations) and over true LRU (per-read bookkeeping under the lock). Dictionary copy-on-write makes rotation O(1).
primary rotates the moment it would exceed maxEntries (checked on a miss, before insert); secondary holds at most one prior full primary, so the live set peaks at exactly 2 × maxEntries.primary into secondary; the just-completed generation survives one more rotation.computeId is pure (unchanged SHA-256 → prefix(16) hex), independent of generation placement, and is the single source of truth for MarkdownBlock.id.The rotation check sits behind the two cache-miss lookups, so re-reading a cached key never rotates. A document whose unique-block working set fits in primary never rotates, regardless of how many times SwiftUI re-reads each id per pass. Above 2 × maxEntries the set churns under any bounded policy, but only as bounded per-rotation recompute, not a catastrophic wipe.
BlockIDStore is explicitly Sendable; the global is Mutex<BlockIDStore> and the mutating id(for:) runs inside withLock's inout access. COW makes secondary = primary O(1). maxEntries of 0/1 degrade gracefully (no crash); production hardcodes 10,000.
prism/Models/MarkdownBlock.swift
Why it matters. This is the entire behavioural change — it removes the clear-on-overflow performance cliff while keeping block.id values byte-identical, which is what protects persisted notes and scroll positions.
What to look at. BlockIDStore.id(for:) / removeAll() and the BlockIDCache wrapper
prism/Models/MarkdownBlock.swift
Why it matters. The ticket's primary request would have changed every block id and orphaned every persisted note and saved scroll position on first open after the update.
What to look at. contentForHashing unchanged; no index/byteOffset added
prismTests/VisibleBlockIdentityTests.swift
Why it matters. Locks in both the new eviction invariants and the pre-existing view-layer protection so neither can silently regress.
What to look at. BlockIDStoreEvictionTests (in BlockIDCacheTests.swift) + VisibleBlockIdentityTests
Adding array index / byte offset to the hash would change every id and orphan persisted notes (BlockNote.blockId) and scroll positions, while the duplicate-content ForEach concern is already handled at the view layer (VisibleBlock.id = block.id + sourceIndex; nested rendering uses enumerated offsets).
The hot path runs under a Mutex; per-access LRU reordering adds cost. Non-promoting keeps lookups O(1), never wipes to zero, and avoids the faster rotations that promotion-on-hit would cause under a large working set.
BlockIDStore takes maxEntries and exposes count/contains as test seams, so eviction is verified deterministically with a small cap rather than racing on the process-global static under parallel test execution.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | MarkdownBlock.swift BlockIDStore | BlockIDStore is shared process-wide via Mutex<BlockIDStore> but its Sendable conformance was only implicit; a future non-Sendable stored property would be a silent thread-safety hole rather than a compile error. | Added explicit `struct BlockIDStore: Sendable` with a documenting comment; lint clean and affected tests still pass. |
| minor | MarkdownBlock.swift computeId | The SHA-256 → hex `prefix(n)` idiom is duplicated across ~8 sites repo-wide (DiagramCache, SnapshotCache, ImportedNoteResolvedCache, ImagePathResolver, DiagramIdentifier, etc.). | Pre-existing repo-wide duplication; the line moved unchanged in this commit. Out of scope for T-1165 — candidate for a separate cleanup chore (a shared SHA256 hex helper). |
| nit | MarkdownBlock.swift BlockIDStore.init | maxEntries <= 0 is accepted; it degrades to a 2-entry cache (no crash). | Skipped — graceful degradation, the type is internal and only constructed with 10,000 or sensible test caps. A guard would be over-engineering. |
| nit | CLAUDE.md source structure | The MarkdownBlock.swift source-structure line could mention BlockIDStore/BlockIDCache. | Skipped — the existing line is not inaccurate and the type is an internal same-file helper. |
| major | make test-quick (pre-existing, not this change) | Full suite shows a ~3000-test 0.000s crash cascade plus real failures in ExportImportRoundTripPropertyTests, DiagramWindowManagerTests, and timing-threshold performance tests; MarkdownBlock/Equatable Swift 6 warnings in test files. | Confirmed to reproduce identically on a clean main checkout — not introduced by this change. Filed as T-1440 (export property tests), T-1441 (window minimize/restore), T-1442 (Equatable MainActor warnings). |
Click to expand.
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex 335d88f..b40644e 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -9,50 +9,121 @@ import Foundation import CryptoKit import Synchronization -/// Thread-safe cache for memoizing MarkdownBlock ID computations.+/// Storage and eviction policy for memoizing MarkdownBlock ID computations. /// /// The ID computation involves SHA-256 hashing which is expensive when called /// repeatedly. SwiftUI's ForEach and LazyVStack access block IDs many times /// per layout pass, causing severe performance degradation with large documents. ///-/// This cache stores computed IDs keyed by the content hash input string,-/// ensuring each unique block's ID is computed only once.-private enum BlockIDCache {- /// The underlying cache storage, protected by a Mutex for thread safety.- private static let cache = Mutex<[String: String]>([:])+/// Entries are held across two generations (`primary` + `secondary`). New+/// inserts always land in `primary`; when `primary` reaches `maxEntries` it is+/// rotated into `secondary` (discarding the older generation) and a fresh+/// `primary` is started. Lookups check both generations and return a hit+/// *without* promoting it, so memory is bounded at ~2×`maxEntries` and the+/// most-recent generation always survives an overflow — replacing the former+/// clear-on-overflow wipe that dropped every entry at once (T-1165).+///+/// Not thread-safe on its own: the process-global instance in ``BlockIDCache``+/// is guarded by a `Mutex`. This type is extracted so its eviction behaviour+/// can be unit-tested in isolation with a small `maxEntries`, free of the+/// shared static state that parallel tests would race on.+///+/// Explicitly `Sendable`: the process-global instance is shared across threads+/// through ``BlockIDCache``'s `Mutex`, so a future non-`Sendable` stored+/// property must be a compile-time error at this declaration, not a silent+/// thread-safety hole at the `Mutex` use site.+struct BlockIDStore: Sendable {+ /// Maximum entries in the primary generation before a rotation.+ let maxEntries: Int++ /// Most-recent generation; receives every new insert.+ private var primary: [String: String] = [:]++ /// Previous generation, retained across one rotation so recently-used+ /// entries survive an overflow instead of being wiped.+ private var secondary: [String: String] = [:]++ init(maxEntries: Int) {+ self.maxEntries = maxEntries+ }++ /// Returns the cached ID for the given hash input, computing and inserting+ /// it on a miss.+ ///+ /// - Parameter hashInput: The content string to hash for ID generation.+ /// - Returns: The cached or newly computed ID.+ mutating func id(for hashInput: String) -> String {+ if let cached = primary[hashInput] {+ return cached+ }+ if let cached = secondary[hashInput] {+ // Non-promoting: return the hit without copying it into `primary`.+ // Promotion would fill `primary` faster under a large working set,+ // triggering more rotations for no worst-case gain (Decision 2).+ return cached+ }++ let computedId = Self.computeId(for: hashInput)++ // Rotate before inserting when the primary generation is full so the+ // previous generation survives rather than being discarded all at once.+ if primary.count >= maxEntries {+ secondary = primary+ primary = [:]+ }+ primary[hashInput] = computedId+ return computedId+ }++ /// Empties both generations.+ mutating func removeAll() {+ primary.removeAll()+ secondary.removeAll()+ } - /// Maximum number of cached entries before cleanup.- /// Set high enough to handle large documents without eviction during normal use.+ /// Total entries currently retained across both generations.+ ///+ /// Test support: lets eviction tests assert memory stays bounded.+ var count: Int { primary.count + secondary.count }++ /// Whether `hashInput` is currently cached in either generation.+ ///+ /// Test support: lets eviction tests assert generation retention after a+ /// rotation without recomputing the value.+ func contains(_ hashInput: String) -> Bool {+ primary[hashInput] != nil || secondary[hashInput] != nil+ }++ private static func computeId(for hashInput: String) -> String {+ let data = Data(hashInput.utf8)+ let hash = SHA256.hash(data: data)+ return hash.compactMap { String(format: "%02x", $0) }.prefix(16).joined()+ }+}++/// Thread-safe, process-global cache for memoizing MarkdownBlock ID+/// computations. Delegates storage and eviction to a shared ``BlockIDStore``+/// guarded by a `Mutex`.+private enum BlockIDCache {+ /// Maximum entries per generation. Memory is bounded at ~2× this value.+ /// Set high enough to handle large documents without eviction during+ /// normal use. private static let maxEntries = 10_000 + /// The shared store, protected by a Mutex for thread safety.+ private static let store = Mutex<BlockIDStore>(BlockIDStore(maxEntries: maxEntries))+ /// Returns the cached ID for the given hash input, computing it if not present. /// /// - Parameter hashInput: The content string to hash for ID generation. /// - Returns: The cached or newly computed ID. static func id(for hashInput: String) -> String {- cache.withLock { storage in- if let cached = storage[hashInput] {- return cached- }-- // Compute the ID- let data = Data(hashInput.utf8)- let hash = SHA256.hash(data: data)- let computedId = hash.compactMap { String(format: "%02x", $0) }.prefix(16).joined()-- // Simple cache eviction: clear if too large- if storage.count >= maxEntries {- storage.removeAll(keepingCapacity: true)- }-- storage[hashInput] = computedId- return computedId- }+ store.withLock { $0.id(for: hashInput) } } /// Clears the cache. Call when documents are closed or memory pressure occurs. static func clear() {- cache.withLock { $0.removeAll() }+ store.withLock { $0.removeAll() } } }
diff --git a/prismTests/BlockIDCacheTests.swift b/prismTests/BlockIDCacheTests.swiftindex 9b21a98..9ff2b27 100644--- a/prismTests/BlockIDCacheTests.swift+++ b/prismTests/BlockIDCacheTests.swift@@ -63,3 +63,81 @@ struct BlockIDCacheTests { #expect(originalIDs == recomputedIDs) } }++/// Unit tests for ``BlockIDStore`` eviction (T-1165).+///+/// These exercise a local, small-cap store instance rather than the+/// process-global ``BlockIDCache``, so the assertions are deterministic and+/// free of the shared static state that parallel tests would race on.+struct BlockIDStoreEvictionTests {++ @Test("Overflow bounds memory at ~2x the cap and never drops to zero")+ func overflowKeepsMemoryBoundedWithoutFullWipe() {+ let maxEntries = 8+ var store = BlockIDStore(maxEntries: maxEntries)++ // Insert well past two full generations to force repeated rotations.+ for index in 0..<(maxEntries * 3) {+ _ = store.id(for: "key-\(index)")+ }++ // Memory stays bounded across both generations...+ #expect(store.count <= maxEntries * 2)+ // ...and, crucially, a rotation never wipes everything the way the old+ // clear-on-overflow strategy did (which would leave a single entry).+ #expect(store.count > maxEntries)+ }++ @Test("Most-recent generation survives one rotation, evicted after two")+ func generationRetentionAcrossRotations() {+ let maxEntries = 4+ var store = BlockIDStore(maxEntries: maxEntries)++ // Cache a known entry, then capture its ID for stability comparison.+ let originalId = store.id(for: "anchor")++ // Fill the primary generation to the cap and trigger exactly one+ // rotation, which moves "anchor" into the surviving secondary.+ for index in 0..<maxEntries {+ _ = store.id(for: "first-\(index)")+ }++ // Retained without recomputation, and the value is unchanged.+ #expect(store.contains("anchor"))+ #expect(store.id(for: "anchor") == originalId)++ // Trigger a second rotation; this discards the generation holding+ // "anchor", so it is no longer cached.+ for index in 0..<maxEntries {+ _ = store.id(for: "second-\(index)")+ }+ #expect(!store.contains("anchor"))++ // Even when re-derived after eviction, the ID is content-stable.+ #expect(store.id(for: "anchor") == originalId)+ }++ @Test("removeAll empties both generations")+ func removeAllClearsBothGenerations() {+ let maxEntries = 4+ var store = BlockIDStore(maxEntries: maxEntries)++ // Populate enough to occupy both generations.+ for index in 0..<(maxEntries * 2) {+ _ = store.id(for: "key-\(index)")+ }+ #expect(store.count > 0)++ store.removeAll()+ #expect(store.count == 0)+ }++ @Test("Identical content yields a stable ID, matching MarkdownBlock.id")+ func storeIdMatchesBlockId() {+ var store = BlockIDStore(maxEntries: 10_000)+ // The store hashes the same "paragraph:<text>" input MarkdownBlock uses.+ let storeId = store.id(for: "paragraph:Hello world")+ let blockId = MarkdownBlock.paragraph(markdown: "Hello world").id+ #expect(storeId == blockId)+ }+}
diff --git a/prismTests/VisibleBlockIdentityTests.swift b/prismTests/VisibleBlockIdentityTests.swiftnew file mode 100644index 0000000..aa6cb60--- /dev/null+++ b/prismTests/VisibleBlockIdentityTests.swift@@ -0,0 +1,62 @@+//+// VisibleBlockIdentityTests.swift+// prismTests+//+// Created by Claude on 2/6/2026.+//++import Testing+@testable import prism++/// Regression tests locking in the view-layer protection against duplicate+/// content (T-1165).+///+/// `MarkdownBlock.id` is content-only by design, so blocks with identical+/// content share an `id`. SwiftUI `ForEach` identity at the document level is+/// instead keyed on `VisibleBlock.id` — the composite `"\(block.id)-\(sourceIndex)"`+/// — so duplicate content still renders with distinct identities. These tests+/// guard that composite so the protection cannot silently regress (for example+/// if `VisibleBlock.id` were ever reduced to `block.id`).+struct VisibleBlockIdentityTests {++ /// A document with three pairs of duplicate content: identical paragraphs,+ /// identical fenced code blocks, and repeated thematic breaks.+ private static let duplicateContentMarkdown = """+ Repeated paragraph.++ Repeated paragraph.++ ---++ ---++ ```swift+ let x = 1+ ```++ ```swift+ let x = 1+ ```+ """++ @Test("Duplicate-content blocks get distinct VisibleBlock identities")+ func duplicateContentBlocksHaveDistinctVisibleIds() {+ let blocks = MarkdownBlockParser.parse(Self.duplicateContentMarkdown)+ let structure = MarkdownSectionBuilder.build(from: blocks)+ let visible = structure.visibleBlocks(collapsedSectionIds: [])++ // Sanity: the document actually contains duplicate content.+ #expect(visible.count >= 6)++ // The underlying content-only block IDs DO collide for duplicates —+ // this is the intended design and the reason the composite ID exists.+ let blockIds = visible.map { $0.block.id }+ #expect(Set(blockIds).count < blockIds.count,+ "Expected duplicate content to share block IDs")++ // The view identities used by ForEach must nonetheless be unique.+ let visibleIds = visible.map { $0.id }+ #expect(Set(visibleIds).count == visibleIds.count,+ "VisibleBlock.id must be unique across duplicate-content blocks")+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 52ebd21..6ce3ca6 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Replaced `BlockIDCache`'s clear-on-overflow eviction with a non-promoting two-generation scheme (T-1165). The block-ID memoization cache (`prism/Models/MarkdownBlock.swift`) previously wiped all 10,000 entries at once on overflow, which could recur within a single SwiftUI layout pass for documents with more unique blocks than the cap (each block's `id` is read many times per pass), recomputing SHA-256 hashes repeatedly. The storage and eviction logic is extracted into a new internal `BlockIDStore` value type — `primary`/`secondary` generations; a lookup checks both and returns on hit without promoting; on overflow the `primary` rotates into `secondary` (discarding the older generation) and a fresh `primary` starts — guarded by the existing `Mutex`. Memory is bounded at ~2× the cap and the most-recent generation always survives a rotation, removing the cliff for working sets up to ~2× the cap. `block.id` values are unchanged (same content-only SHA-256), so persisted notes and saved scroll positions still resolve by exact match. T-1165 also proposed incorporating positional data (array index / byte offset) into the hash to fix duplicate-content `ForEach` collisions; that was deliberately dropped — the collision is already handled at the view layer (the document `ForEach` keys on the composite `VisibleBlock.id` = `block.id` + `sourceIndex`; nested rendering uses enumerated offsets), and adding positional data would change every `id` and orphan existing persisted notes and scroll positions. New `BlockIDStoreEvictionTests` cover bounded growth, generation retention across a rotation, and ID stability; new `VisibleBlockIdentityTests` lock in distinct view identities for duplicate paragraphs, code blocks, and thematic breaks. Smolspec and decision log (3 ADRs) under `specs/block-id-cache-eviction/`. - Extracted the imported-notes load path from `NotesManager` into a new `ImportedNotesProcessor` `@MainActor` enum (T-1217) - `NotesManager.swift` drops from 1333 to ~1077 lines; `loadImportedNotes` becomes a thin wrapper that delegates to `ImportedNotesProcessor.process` - No public-API change: `loadImportedNotes` signature and the published `importedNotes` state surface are preserved
For documents with more than ~20,000 unique block-hash inputs, the working set churns under any bounded policy; a secondary-only key is recomputed after the next rotation. This is bounded (one SHA-256 per key per rotation) and far better than the old full-wipe, but if profiling ever shows it matters, promotion-on-secondary-hit can be revisited.
The pre-push bar (zero warnings, full green suite) cannot be met today because of T-1440/T-1441/T-1442, which exist on main. This change adds no new failures or warnings; verify against those tickets rather than blocking this commit.