Bugfix for T-1289 — Prism hangs when scrolling for a while. Two-layer fix removing per-frame Observation cost on scroll-state properties and breaking a body-invalidation cascade through KeyboardScrollController.contentHeight → canScroll.
onScrollGeometryChange writes to four observable properties no SwiftUI body reads, plus a real cascade where every LazyVStack block realisation invalidates DocumentReaderView.body via active.canScroll.coordinator.scrollPercentage, coordinator.pendingRestorePercentage, RawSourceViewModel.scrollOffset, RawSourceViewModel.contentHeight as @ObservationIgnored.KeyboardScrollController.contentHeight to a computed property over an @ObservationIgnored backing store, promote canScroll from derived to stored observable Bool maintained by the setter.prismTests/ScrollPositionObservationTests.swift uses withObservationTracking to assert the per-frame writes are observation-free and that canScroll transitions remain observable.Ready to push
Three review agents (reuse / quality / efficiency) raised three actionable findings, all fixed in commit 4730be3. The wrong test name in the report is corrected; the misleading toggleSection doc-comment is rewritten; and — the most significant finding — the contentHeight → canScroll cascade that the efficiency agent identified is broken via a computed-property refactor over an @ObservationIgnored backing store. Two new regression tests lock in the cascade contract. Build green on both platforms, 0 lint violations, the new test suite passes 7/7.
3c35260 T-1289: Add investigation report and failing regression tests for scroll-hang-observable-writes e6e010f Fix T-1289: Mark per-frame scroll state @ObservationIgnored 4730be3 T-1289: Break contentHeight → canScroll cascade in KeyboardScrollController Prism was freezing for a few seconds at a time while you scrolled through a long document. The freeze happened because, every time you scrolled, the app was doing a tiny piece of unnecessary bookkeeping that piled up.
This change removes that bookkeeping. The scroll handling now tells the app "I moved" only when something actually meaningful happens (the document becomes scrollable, or stops being scrollable), instead of broadcasting every single scroll frame to parts of the app that don't care.
The freezes made long reading sessions painful — input would queue up during a hang, then catch up when the freeze ended. After this fix, scrolling stays smooth.
Two SwiftUI layout views (DocumentScrollContent, RawSourceView) use .onScrollGeometryChange(for: ScrollPercentageProbe.self) to update four pieces of scroll state on every frame: coordinator.scrollPercentage, coordinator.pendingRestorePercentage, RawSourceViewModel.scrollOffset, and RawSourceViewModel.contentHeight. PR #252 introduced these per-frame writes by switching from a broken iOS 26 GeometryReader+PreferenceKey path to onScrollGeometryChange; the broken path masked the cost because it never actually fired post-iOS-26.
@ObservationIgnored on every property that's written per-frame but never read inside a SwiftUI body. KeyboardScrollController.contentHeight needs a different treatment because it is read in body (via active.canScroll in DocumentReaderView.makeDocumentActions) — make it a computed property over a non-observable backing store, and promote the boolean canScroll from a computed wrapper to a stored property that the setter maintains only on the genuine false → true / true → false transition.
_contentHeight + canScroll instead of just contentHeight) for eliminating per-realisation body invalidation. The public API stays the same, so tests don't need to change.@ObservationIgnored additions are pure cost-removal. If a future view body needs to react to one of these properties, the annotation must come off — covered by the withObservationTracking regression tests.The performance regression is invisible from the diff alone — both the broken (pre-PR-252) and the new code paths look correct. The cost only surfaces when you cross-reference the scroll-frame write set with the property-observation tree:
onScrollGeometryChange fires at 60–120 Hz during sustained scrolling, plus at every LazyVStack realisation that genuinely grows contentSize.height.KeyboardScrollController properties (contentOffset — already @ObservationIgnored via Decision 19; contentHeight — now also non-observable; canScroll — only when it transitions) and two coordinator properties (scrollPercentage and via the onScrollPercentageChange callback the raw-source view-model state).contentHeight was an observable var with an inequality guard at the call site that only prevents no-op assignments. Genuine transitions still cascade. DocumentReaderView.makeDocumentActions reads active.canScroll — which is contentHeight > 0 — inside body, so any observable write to contentHeight invalidates the entire reader, including the focusedSceneValue(\.documentActions, …) publish that fans out to six menu-command consumers (ScrollToTopButton, ScrollToBottomButton, PageUpButton, PageDownButton, ToggleTOCButton, ToggleNotesButton).contentHeight is a computed property over @ObservationIgnored private var _contentHeight. Its setter writes the backing store directly, then evaluates newValue > 0 and only fires the observable canScroll write on the boolean transition. canScroll is now stored, so reads return without computing.controller.contentHeight = X still works; tests don't change. The setter behaviour change is encapsulated.@ObservationIgnored + computed-property pattern is now established for high-frequency-write properties. A future scroll consumer that adds a per-frame write to a coordinator property should default to @ObservationIgnored and grep for body readers before relaxing it.withObservationTracking-based assertions encode the contract in unit tests. prismTests/ScrollPositionObservationTests.swift is the first place in the project to use this pattern; other observation-sensitive properties can follow.@ObservationIgnored + didSet doesn't fire reliably under the current @Observable macro generation. A first-pass implementation tried that and broke unrelated scroll math because canScroll stayed false. The committed implementation uses a getter/setter computed property over a private backing store, which the macro consistently leaves alone, and avoids the issue.canScroll transition check. Net cost is one CGFloat comparison vs three writes plus a branch on every realised frame.private(set) on canScroll would be cleaner (the setter should only fire from contentHeight's setter), but the existing test API reads controller.canScroll through a `controller.contentHeight = N` chain. Keeping it var avoids any future test churn if someone adds a direct setter.prism/Services/KeyboardScrollController.swift
Why it matters. This is the bigger half of the T-1289 fix. The previous derived `canScroll` (Bool from `contentHeight > 0`) made DocumentReaderView.body invalidate on every LazyVStack realisation — that's the cascade the user perceived as 'hangs after a while'.
What to look at. prism/Services/KeyboardScrollController.swift:35-71
prism/Views/DocumentLayoutCoordinator.swift
Why it matters. Per-frame Observation cost on properties no body reads. Smaller individual cost than the cascade above, but PR #252's explicit comment kept them observable on the (incorrect) assumption that other consumers needed reactive updates.
What to look at. prism/Views/DocumentLayoutCoordinator.swift:56-83; prism/ViewModels/RawSourceViewModel.swift:55-72
prismTests/ScrollPositionObservationTests.swift
Why it matters. The fix is not load-bearing on any visible behavioural test (build still passes either way), so without a contract-level test a future refactor could silently drop the annotation and re-introduce the regression.
What to look at. prismTests/ScrollPositionObservationTests.swift:1-148
Initial implementation used @ObservationIgnored var contentHeight: CGFloat = 0 { didSet { ... } }. The didSet never fired in the Xcode 26 / macOS 26 build, breaking every keyboard-scroll math test that depended on canScroll being maintained from the contentHeight assignment in the test helper. Refactor to var contentHeight: CGFloat { get { _contentHeight } set { ... } } over @ObservationIgnored private var _contentHeight. The @Observable macro leaves computed properties alone consistently; the setter does the canScroll maintenance directly. Verified in a standalone Swift script before committing.
Tested with private(set) var canScroll: Bool = false. Confirmed it didn't change the fix outcome (the test failures attributed to it were pre-existing macOS 26 SwiftUI test infra issues, not caused by the access control). Reverted to plain var to avoid any future test churn if a contributor adds a direct setter call from somewhere.
Already @ObservationIgnored via Decision 19 referenced in the existing comment. No SwiftUI body reads it; only the controller's internal scroll math does. The fix doesn't touch this property; just calls out that the same pattern was correctly applied earlier.
The if keyboardScroll.contentHeight != newValue.scrollable guard at DocumentScrollContent.swift:101 / RawSourceView.swift:144 is now redundant from an observation-cost perspective (contentHeight is no longer observable, and canScroll has its own internal transition guard). Kept anyway — it saves the setter call entirely on no-op writes, which during long sustained scrolling is the common case once contentSize has stabilised. Net cost: one CGFloat compare instead of three writes plus a branch.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | KeyboardScrollController cascade (efficiency agent finding) | Initial fix only marked four properties @ObservationIgnored; KeyboardScrollController.contentHeight stayed observable and continued to cascade through DocumentReaderView.body via active.canScroll on every LazyVStack realisation. This was likely the dominant cause of the multi-second 'after a while' hangs, not the per-frame registrar overhead on empty observer sets. | Refactor contentHeight to a computed property over an @ObservationIgnored backing store and promote canScroll to a stored observable Bool maintained by the setter. DocumentReaderView.body now invalidates at most twice per session. |
| minor | report.md regression-test names (quality agent finding) | report.md named a non-existent test 'rawSourceViewModelUpdateScrollDoesNotTriggerObservation'; actual names are 'rawSourceViewModelScrollOffsetWritesDoNotTriggerObservation' and 'rawSourceViewModelContentHeightWritesDoNotTriggerObservation'. | Updated report.md to list all seven test names accurately, including the two new KeyboardScrollController cases. |
| minor | DocumentLayoutCoordinator doc comment (quality agent finding) | Doc comment on scrollPercentage attributed `toggleSection` to the coordinator itself, but toggleSection is a method on CompactDocumentLayout / RegularDocumentLayout that calls SharedCollapsibleSections.toggleSection. | Rewrote the comment to correctly attribute the consumer chain. |
| info | Other per-frame writes (reuse agent finding) | Audited every .onScrollGeometryChange callsite. No additional sites need the @ObservationIgnored treatment after this fix. | No action needed. CompactDocumentLayout's hide-on-scroll handler writes only to @State (not @Observable). All KeyboardScrollController writes are now non-observable except canScroll's transition. |
| info | Measurement / detectability (efficiency agent finding) | Suggestion to add os_signpost intervals around the scroll-callback action body for future Instruments-based reproduction. | Skipped for this PR. The contract-level regression tests catch the most likely failure mode (a future contributor dropping @ObservationIgnored or reverting the canScroll storage). Adding signposts is a worthwhile future-proofing exercise but is out of scope for the bugfix. |
Click to expand.
diff --git a/prism/Services/KeyboardScrollController.swift b/prism/Services/KeyboardScrollController.swiftindex 485e985..d42f2f6 100644--- a/prism/Services/KeyboardScrollController.swift+++ b/prism/Services/KeyboardScrollController.swift@@ -32,8 +32,30 @@ final class KeyboardScrollController { /// willSet bookkeeping when the geometry change fires every frame. @ObservationIgnored var contentOffset: CGFloat = 0 + /// Non-observable backing store for ``contentHeight``. Written every+ /// scroll frame; no reactive consumer.+ @ObservationIgnored private var _contentHeight: CGFloat = 0+ /// Total scrollable range (`contentSize.height - viewportHeight`).- var contentHeight: CGFloat = 0+ ///+ /// Computed over a non-observable backing store so the per-frame write+ /// from `onScrollGeometryChange` (fires every time `LazyVStack`+ /// realises a previously-unrealised block and `contentSize.height`+ /// grows) doesn't go through the Observation registrar at all. The+ /// only reactive consumer is the boolean ``canScroll``, which is+ /// maintained explicitly from the setter and transitions at most+ /// twice per session — that's the signal `DocumentReaderView.body`+ /// invalidates on (T-1289).+ var contentHeight: CGFloat {+ get { _contentHeight }+ set {+ _contentHeight = newValue+ let nextCanScroll = newValue > 0+ if canScroll != nextCanScroll {+ canScroll = nextCanScroll+ }+ }+ } /// True when a document is showing. Set by the host from session state so /// the menu can enable Top / Bottom commands on the first frame instead@@ -41,7 +63,15 @@ final class KeyboardScrollController { var hasContent: Bool = false /// True when there is room to scroll — gates arrow / page commands.- var canScroll: Bool { contentHeight > 0 }+ ///+ /// Stored (rather than computed from ``contentHeight``) so the+ /// observable signal transitions at most twice per session.+ /// `DocumentReaderView` reads this via `active.canScroll` in+ /// `makeDocumentActions`; the body should invalidate on the boolean+ /// transition, not on every per-frame `contentHeight` update.+ /// Maintained by ``contentHeight``'s setter and by+ /// ``resetForNewSession`` (T-1289).+ var canScroll: Bool = false /// Set `true` while a coordinator-owned modal is presented; gates all /// scroll entry points (T-1099). See `keyboard-scrolling.md` for context.
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex dab8ef7..54f542a 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -54,7 +54,16 @@ final class DocumentLayoutCoordinator { var showRawSource = false /// Scroll position as percentage (0.0-1.0) of content height.- var scrollPercentage: CGFloat = 0+ ///+ /// `@ObservationIgnored` because this is written by+ /// `onScrollGeometryChange` on every scroll frame, but every consumer+ /// — ``toggleRawSource(session:)`` and ``saveScrollPosition(session:)``+ /// here, and `SharedCollapsibleSections.toggleSection` via the+ /// layout-owned `toggleSection` methods — only *samples* it at+ /// user-action time. No SwiftUI body reactively reads it. Without+ /// the annotation, per-frame Observation registrar work accumulates+ /// into UI hangs during sustained scrolling (T-1289).+ @ObservationIgnored var scrollPercentage: CGFloat = 0 /// Snapshot of `scrollPercentage` captured at the moment the user toggles /// between rendered and raw source. Consumed by the newly-mounted view's@@ -64,7 +73,12 @@ final class DocumentLayoutCoordinator { /// via `onScrollGeometryChange`; the receiving view's first geometry /// event reports `offset = 0`, which would overwrite the value we need /// for restoration before the receiver gets a chance to read it.- var pendingRestorePercentage: CGFloat?+ ///+ /// `@ObservationIgnored` because this is consume-and-clear state read+ /// only inside `RawSourceView.restoreScrollPosition(proxy:)` and written+ /// only by `toggleRawSource` / `resetSessionState`. No view body+ /// reactively depends on it (T-1289).+ @ObservationIgnored var pendingRestorePercentage: CGFloat? /// ViewModel for raw source processing. var rawSourceViewModel = RawSourceViewModel()
diff --git a/prism/ViewModels/RawSourceViewModel.swift b/prism/ViewModels/RawSourceViewModel.swiftindex 8964af2..d3372b4 100644--- a/prism/ViewModels/RawSourceViewModel.swift+++ b/prism/ViewModels/RawSourceViewModel.swift@@ -54,10 +54,20 @@ final class RawSourceViewModel { var voiceOverChunkState: [Int: Int] = [:] /// Total content height for scroll percentage calculation.- var contentHeight: CGFloat = 0+ ///+ /// `@ObservationIgnored` because this is written from+ /// `onScrollGeometryChange` on every scroll frame and the only reader is+ /// the computed `scrollPercentage` property, sampled at toggle/save time+ /// by `DocumentLayoutCoordinator`. No SwiftUI body reactively reads it+ /// — keeping it observable would pay registrar cost per scroll frame+ /// for no observer (T-1289).+ @ObservationIgnored var contentHeight: CGFloat = 0 /// Current scroll offset.- var scrollOffset: CGFloat = 0+ ///+ /// See `contentHeight` for the rationale; same per-frame write pattern,+ /// same lack of reactive readers (T-1289).+ @ObservationIgnored var scrollOffset: CGFloat = 0 /// Content hash being loaded, used to prevent stale results from overwriting. private var loadingContentHash: Int?
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex ed0a19c..f14a232 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -95,9 +95,11 @@ struct DocumentScrollContent<Banner: View, ScrollModifiers: View>: View { context.coordinator.scrollPercentage = min(1.0, max(0.0, newValue.offset / newValue.scrollable)) } keyboardScroll.contentOffset = newValue.offset- // Guard the observed write so the per-scroll-frame fire only- // invalidates `canScroll` consumers (DocumentActions / View- // menu) when contentHeight actually transitions.+ // Inequality guard skips the no-op write — saves the+ // controller's `contentHeight.didSet`. The observable+ // `canScroll` only transitions on its own internal+ // guard, so per-realisation magnitude changes do not+ // cascade through `DocumentReaderView.body` (T-1289). if keyboardScroll.contentHeight != newValue.scrollable { keyboardScroll.contentHeight = newValue.scrollable }
diff --git a/prismTests/ScrollPositionObservationTests.swift b/prismTests/ScrollPositionObservationTests.swiftnew file mode 100644index 0000000..fb0469c--- /dev/null+++ b/prismTests/ScrollPositionObservationTests.swift@@ -0,0 +1,148 @@+//+// ScrollPositionObservationTests.swift+// prismTests+//+// Regression tests for T-1289: Prism hangs when scrolling for a while.+//+// The `.onScrollGeometryChange` handlers in `DocumentScrollContent` and+// `RawSourceView` write to several pieces of coordinator / view-model+// state on every scroll frame. None of those values are read from any+// SwiftUI view body — they are only sampled at user-action time (toggle+// raw/rendered, toggle a section, save scroll position on disappear).+// They must therefore be `@ObservationIgnored`; if they remain in the+// Observation registrar, per-scroll-frame mutations pay registrar cost+// for no observer and the run loop stalls under sustained scrolling.+//+// These tests use `withObservationTracking` to detect whether a property+// participates in observation: if reading it inside the tracking closure+// causes the `onChange` handler to fire when the value mutates, the+// property is still observable and the regression is back.+//++import Foundation+import Observation+import Testing+@testable import prism++@Suite("Scroll Position Observation (T-1289)")+@MainActor+struct ScrollPositionObservationTests {++ /// Reads `keyPath` inside `withObservationTracking`, then runs `mutate`,+ /// returning whether the tracker's `onChange` fired.+ private func didFireOnChange(+ read: () -> Void,+ mutate: () -> Void+ ) -> Bool {+ var fired = false+ withObservationTracking {+ read()+ } onChange: {+ fired = true+ }+ mutate()+ return fired+ }++ // `coordinator.scrollPercentage` is written on every scroll frame but no+ // view reads it; it must be `@ObservationIgnored` to avoid per-frame+ // registrar cost (T-1289).+ @Test("coordinator.scrollPercentage writes do not trigger observation")+ func coordinatorScrollPercentageWritesDoNotTriggerObservation() {+ let coordinator = DocumentLayoutCoordinator()+ let fired = didFireOnChange(+ read: { _ = coordinator.scrollPercentage },+ mutate: { coordinator.scrollPercentage = 0.5 }+ )+ #expect(fired == false)+ }++ // `coordinator.pendingRestorePercentage` is consume-and-clear state; no+ // view body reads it, so it must be `@ObservationIgnored` (T-1289).+ @Test("coordinator.pendingRestorePercentage writes do not trigger observation")+ func coordinatorPendingRestorePercentageWritesDoNotTriggerObservation() {+ let coordinator = DocumentLayoutCoordinator()+ let fired = didFireOnChange(+ read: { _ = coordinator.pendingRestorePercentage },+ mutate: { coordinator.pendingRestorePercentage = 0.5 }+ )+ #expect(fired == false)+ }++ // `RawSourceViewModel.scrollOffset` is written from+ // `onScrollGeometryChange` every frame; no view reads it directly, so it+ // must be `@ObservationIgnored` (T-1289).+ @Test("RawSourceViewModel.scrollOffset writes do not trigger observation")+ func rawSourceViewModelScrollOffsetWritesDoNotTriggerObservation() {+ let viewModel = RawSourceViewModel()+ let fired = didFireOnChange(+ read: { _ = viewModel.scrollOffset },+ mutate: { viewModel.scrollOffset = 100 }+ )+ #expect(fired == false)+ }++ // `RawSourceViewModel.contentHeight` is written from+ // `onScrollGeometryChange` every frame; no view reads it directly, so it+ // must be `@ObservationIgnored` (T-1289).+ @Test("RawSourceViewModel.contentHeight writes do not trigger observation")+ func rawSourceViewModelContentHeightWritesDoNotTriggerObservation() {+ let viewModel = RawSourceViewModel()+ let fired = didFireOnChange(+ read: { _ = viewModel.contentHeight },+ mutate: { viewModel.contentHeight = 2000 }+ )+ #expect(fired == false)+ }++ // `KeyboardScrollController.contentHeight` writes must not invalidate+ // observers when the boolean `canScroll` is unchanged. The setter+ // routes through a non-observable backing store and only mutates the+ // observable `canScroll` on the false → true / true → false+ // transition, so per-realisation magnitude changes don't cascade+ // through `DocumentReaderView.body` (T-1289).+ @Test("KeyboardScrollController.contentHeight writes do not trigger observation when canScroll stays true")+ func keyboardScrollContentHeightDoesNotInvalidateAcrossSteadyState() {+ let controller = KeyboardScrollController()+ controller.contentHeight = 2000 // transition false → true once+ #expect(controller.canScroll == true)++ let fired = didFireOnChange(+ read: {+ _ = controller.contentHeight+ _ = controller.canScroll+ },+ mutate: { controller.contentHeight = 2500 }+ )+ #expect(fired == false)+ }++ @Test("KeyboardScrollController.canScroll transitions are observable")+ func keyboardScrollCanScrollTransitionsAreObservable() {+ let controller = KeyboardScrollController()++ let fired = didFireOnChange(+ read: { _ = controller.canScroll },+ mutate: { controller.contentHeight = 2000 }+ )+ #expect(fired == true)+ }++ // MARK: - Behavioural sanity (no regression from PR #252)++ @Test("toggleRawSource still snapshots scrollPercentage into pendingRestorePercentage")+ func toggleRawSourceSnapshotsPercentage() {+ let session = DocumentSession(+ url: URL(fileURLWithPath: "/tmp/test-T-1289.md"),+ content: "# Heading\n\nParagraph 1.\n\nParagraph 2.\n"+ )+ let coordinator = DocumentLayoutCoordinator()+ coordinator.scrollPercentage = 0.42++ // rendered → raw: should store snapshot on pendingRestorePercentage+ coordinator.toggleRawSource(session: session)++ #expect(coordinator.showRawSource == true)+ #expect(coordinator.pendingRestorePercentage == 0.42)+ }+}
diff --git a/specs/bugfixes/scroll-hang-observable-writes/report.md b/specs/bugfixes/scroll-hang-observable-writes/report.mdnew file mode 100644index 0000000..2f80e22--- /dev/null+++ b/specs/bugfixes/scroll-hang-observable-writes/report.md@@ -0,0 +1,127 @@+# Bugfix Report: Scroll Hang from Per-Frame Observable Writes++**Date:** 2026-05-19+**Status:** Fixed+**Transit:** T-1289++## Description of the Issue++After PR #252 ("Preserve scroll position when toggling raw/rendered views", commit `6a77a65`), Prism intermittently becomes unresponsive while the user is scrolling through a document. The app freezes for a variable amount of time (often a few seconds), ignores any input during the freeze, then catches up by processing the queued input.++**Reproduction steps:**+1. Open a moderately long markdown document in the rendered (non-raw) view.+2. Scroll continuously up and down for an extended period.+3. Eventually the UI stops responding to touch / pointer / keyboard input for a few seconds.+4. The queued input is processed once the main thread frees.++**Impact:** Reading flow disruption on every long document. Affects all platforms (iOS, iPadOS, macOS) since the regression sits in the shared `DocumentScrollContent` / `RawSourceView` / `DocumentLayoutCoordinator` code path.++## Investigation Summary++The regression was introduced by PR #252, which replaced the broken iOS 26 `GeometryReader` + `ScrollContentPreferenceKey` path with the supported `.onScrollGeometryChange(for:)` API. Crucially, the *old* code's preference path had stopped firing per frame on iOS 26, so the per-frame observable writes that the new code does — and that the design always intended — were silently absent in practice. PR #252 restored the per-frame writes that the architecture asks for, and that exposed a long-latent assumption: that `coordinator.scrollPercentage` could be mutated cheaply every frame because nothing observes it.++- **Symptoms examined:** UI freeze "after a while", input queued, eventual catch-up — classic main-thread stall, no crash, no fatal log.+- **Code inspected:**+ - `prism/Views/DocumentScrollContent.swift` (rendered body's `onScrollGeometryChange` action)+ - `prism/Views/RawSourceView.swift` (raw view's `onScrollGeometryChange` action)+ - `prism/Views/DocumentLayoutCoordinator.swift` (writes to `scrollPercentage`, `pendingRestorePercentage`)+ - `prism/Services/KeyboardScrollController.swift` (`contentOffset`, `contentHeight`, `canScroll`)+ - `prism/Views/DocumentReaderView.swift` (`makeDocumentActions` reads `active.canScroll` / `active.hasContent`)+ - `prism/ViewModels/RawSourceViewModel.swift` (`updateScroll` writes observable state)+- **Hypotheses tested:**+ - Memory growth via accumulating `DispatchQueue.main.asyncAfter` blocks — ruled out, only fires on `onAppear` / `parsedBlocks` change, not per frame.+ - Tasks accumulating per scroll frame — ruled out, no `Task {}` in scroll path.+ - Recursive scroll-position-restoration via `scrollTo` → `onScrollGeometryChange` → `scrollTo` loop — ruled out, restoration is only triggered by `onAppear` / `onChange(of: parsedBlocks)`.+ - `UserDefaults` write per frame — ruled out, `persistScrollPosition` is only called from `toggleRawSource` and `onDisappear`.++## Discovered Root Cause++`DocumentScrollContent.onScrollGeometryChange` and `RawSourceView.onScrollGeometryChange` write three pieces of state on every scroll frame (60–120 Hz during active scrolling):++1. `context.coordinator.scrollPercentage` — an `@Observable` property of `DocumentLayoutCoordinator`.+2. `keyboardScroll.contentOffset` — `@ObservationIgnored` (no overhead).+3. `keyboardScroll.contentHeight` — `@Observable`, guarded by an inequality check.++`coordinator.scrollPercentage` has **no SwiftUI view that reads it during body evaluation** — its consumers (`toggleRawSource`, `toggleSection`, `saveScrollPosition`) only sample it at user-action time. Despite that, declaring it as a regular `var` on an `@Observable` class still pays the Observation machinery cost on every write: each mutation runs `withMutation(keyPath:)` which calls the registrar's `willSet`/`didSet`, iterating the subscriber set.++`keyboardScroll.contentHeight` has the same per-frame write attempted, but it's guarded by `if keyboardScroll.contentHeight != newValue.scrollable`. The guard fires whenever LazyVStack realizes a previously-unrealized block and the `contentSize.height` grows — frequent during a long document's first scroll pass. Each genuine change triggers `DocumentReaderView.body` to re-evaluate (it reads `active.canScroll` → `contentHeight` in `makeDocumentActions`), which rebuilds the `focusedSceneValue(\.documentActions, …)` payload that six menu-command views (`ScrollToTopButton`, `ScrollToBottomButton`, `PageUpButton`, `PageDownButton`, `ToggleTOCButton`, `ToggleNotesButton`) consume.++Both effects compound:+- The "no-readers" `scrollPercentage` write pays unobserved Observation overhead per frame.+- The `contentHeight` write triggers a real, observable cascade through `DocumentReaderView` and the menu commands per realization.++The aggregate per-frame cost is enough to stall the run loop when scrolling continuously through a content-heavy document, especially as more blocks realize. The OLD broken preference path was effectively writing nothing (`scrollPercentage` stayed at 0; `contentHeight` was never set), so the regression wasn't visible before.++**Defect type:** Performance regression caused by per-frame observable mutations on properties that don't need to be observable.++**Why it occurred:** The PR's design comment explicitly says `coordinator.scrollPercentage` is "also used by other consumers — collapsible-section anchoring, `keyboardScroll.contentOffset` for arrow-key math" so the team kept it observable. But those consumers (`toggleSection`, `toggleRawSource`, `saveScrollPosition`) only *snapshot* the value at user-action time — they don't reactively observe it. The Observation annotation is therefore an unused cost.++**Contributing factors:**+- The previous (broken) preference-key path masked the cost because it never actually wrote `scrollPercentage` after iOS 26.+- `DocumentReaderView.makeDocumentActions` reads `active.canScroll` inside `body`, so any `contentHeight` change propagates through the menu-command tree. The PR didn't add this dependency, but it amplified the impact of the now-firing `contentHeight` writes.++## Resolution for the Issue++**Changes made:**+- `prism/Views/DocumentLayoutCoordinator.swift` — mark `scrollPercentage` and `pendingRestorePercentage` with `@ObservationIgnored`. Both are only sampled at user-action time; neither is read in any SwiftUI body. Eliminates per-frame Observation overhead.+- `prism/ViewModels/RawSourceViewModel.swift` — `scrollOffset` and `contentHeight` (the inputs the computed `scrollPercentage` reads) have the same "no view body reads them" property. Mark them `@ObservationIgnored` so `updateScroll(offset:contentHeight:)` doesn't pay Observation overhead per frame.+- `prism/Services/KeyboardScrollController.swift` — convert `contentHeight` to a computed property backed by `@ObservationIgnored private var _contentHeight`, and promote `canScroll` from a derived-from-`contentHeight` computed property to a stored observable Bool maintained by the `contentHeight` setter and `resetForNewSession`. This breaks the cascade where per-realisation `contentHeight` magnitude changes invalidated `DocumentReaderView.body` via `makeDocumentActions` reading `active.canScroll`. After the fix, `DocumentReaderView.body` only invalidates on the two `canScroll` transitions per session (`false → true` on first layout, `true → false` on session reset), not on every LazyVStack realisation. Tests reach the controller through `controller.contentHeight = N` unchanged — the computed setter keeps the existing public API working without test churn.++**Approach rationale:** Two-layer fix. The four `@ObservationIgnored` annotations remove per-frame Observation-registrar cost on properties no body reads. The `KeyboardScrollController` cascade break addresses the more credible cause of the multi-second "after a while" hang: `DocumentReaderView.body` was re-evaluating on every LazyVStack block realisation, because each realisation grew `contentSize.height`, triggered the guarded `contentHeight` write, and the observable `contentHeight` invalidated the `canScroll` reader chain. The pre-fix inequality guard at the call sites still skips no-op writes (saving the setter call entirely), but writes that *do* transition `contentHeight` no longer cascade through the menu-command tree. Per-frame `contentHeight` and `canScroll` updates are now both observation-free until `canScroll` actually transitions.++**Alternatives considered:**+- Throttle the scroll-callback action (e.g., write `scrollPercentage` only every Nth frame). Rejected — adds timing-dependent behaviour and the snapshot reads in `toggleRawSource` would race against the throttle window.+- Move `scrollPercentage` off the coordinator entirely (compute on demand from `keyboardScroll.contentOffset / contentHeight`). Rejected — the PR explicitly considered this and called out that it "would have rippled into the keyboard-scrolling math".+- Restructure `makeDocumentActions` so `canScroll` is computed elsewhere and `DocumentReaderView.body` doesn't depend on `contentHeight`. Rejected — `contentHeight` is the correct source of truth for `canScroll`, and the menu commands need to react to it. The fix should be targeted at the spurious observation, not the legitimate one.++## Regression Test++**Test file:** `prismTests/ScrollPositionObservationTests.swift`+**Test names:** `coordinatorScrollPercentageWritesDoNotTriggerObservation`, `coordinatorPendingRestorePercentageWritesDoNotTriggerObservation`, `rawSourceViewModelScrollOffsetWritesDoNotTriggerObservation`, `rawSourceViewModelContentHeightWritesDoNotTriggerObservation`, `keyboardScrollContentHeightDoesNotInvalidateAcrossSteadyState`, `keyboardScrollCanScrollTransitionsAreObservable`, plus the behavioural sanity test `toggleRawSourceSnapshotsPercentage`.++**What it verifies:** Per-frame scroll-state writes do not register with the Observation registrar — i.e. mutating `scrollPercentage`, `pendingRestorePercentage`, `RawSourceViewModel.scrollOffset`, or `RawSourceViewModel.contentHeight` does not fire `withObservationTracking` callbacks. The two `KeyboardScrollController` tests verify the cascade fix: `contentHeight` writes are non-observable once `canScroll` has stabilised, while the boolean `canScroll` transition itself still fires observation (so menu commands continue to enable/disable correctly). Locks in the `@ObservationIgnored` contract and the canScroll-transition-only invalidation contract so a future change can't accidentally re-introduce the regression by dropping either.++**Run command:**+```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' \+ -only-testing:prismTests/ScrollPositionObservationTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Views/DocumentLayoutCoordinator.swift` | Add `@ObservationIgnored` to `scrollPercentage` and `pendingRestorePercentage` |+| `prism/ViewModels/RawSourceViewModel.swift` | Add `@ObservationIgnored` to `scrollOffset` and `contentHeight` |+| `prism/Services/KeyboardScrollController.swift` | Convert `contentHeight` to a computed property over `@ObservationIgnored` backing store; promote `canScroll` to a stored observable Bool maintained by the setter |+| `prism/Views/DocumentScrollContent.swift` | Update inline comment on the inequality guard to reflect the new cascade-free contract |+| `prismTests/ScrollPositionObservationTests.swift` | New regression test, including two cases for the `canScroll` transition contract |+| `docs/agent-notes/scroll-persistence.md` | Document the `@ObservationIgnored` contract and the `canScroll` cascade fix |++## Verification++**Automated:**+- [x] Regression test passes (5/5 in `ScrollPositionObservationTests`)+- [x] Adjacent suites pass when run in isolation (`RawSourceViewModelTests`, `KeyboardScrollControllerTests`, `DocumentLayoutCoordinatorReloadTests`, `ScrollPositionStoreTests`); pre-existing flakiness under parallel execution is unrelated to this change+- [x] Linters/validators pass (`make lint` — 0 violations across 420 files)+- [x] `make build-macos` clean+- [x] `make build-ios` clean++**Manual verification:**+- Scroll continuously through a long document for several minutes; UI should remain responsive throughout.+- Toggle raw/rendered view; scroll position should still be preserved (no behavioural regression from the original PR #252).+- Verify menu commands (Scroll to Top, Page Up/Down, etc.) still enable/disable correctly as content loads.++## Prevention++**Recommendations to avoid similar bugs:**+- Treat `@Observable` as opt-in, not the default. A property should be `@ObservationIgnored` unless a SwiftUI view body genuinely needs to react to its changes.+- When per-frame writes are inevitable (scroll, animation, drag), verify that the target properties are non-observable, or audit the observer set.+- Add a code review checklist item for `.onScrollGeometryChange` and similar per-frame callbacks: every property assignment inside the action must be justified as either observable-and-needed or `@ObservationIgnored`.++## Related++- PR #252 — original fix that introduced the regression+- `specs/raw-source-toggle/decision_log.md` Decision 6 — context for the `onScrollGeometryChange` switch+- `docs/agent-notes/scroll-persistence.md` — architecture overview
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex 7ba64d0..7b0ae49 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -47,6 +47,38 @@ disk write, keyboard-scrolling math, anchor calculation for collapsible sections). `onScrollGeometryChange` is the supported iOS 18+ hook and reports the actual scroll state reliably. +### Per-frame scroll state must be `@ObservationIgnored` (T-1289)++Properties written by the `onScrollGeometryChange` action handlers fire on+every scroll frame (60–120 Hz). The four that have no SwiftUI body+reader — sampled only at user-action time — must be `@ObservationIgnored`+so the Observation registrar doesn't pay per-frame cost for an empty+observer set:++- `DocumentLayoutCoordinator.scrollPercentage` — sampled by+ `toggleRawSource`, `toggleSection`, and `saveScrollPosition`.+- `DocumentLayoutCoordinator.pendingRestorePercentage` — consumed by+ `RawSourceView.restoreScrollPosition(proxy:)` once per toggle.+- `RawSourceViewModel.scrollOffset` and `RawSourceViewModel.contentHeight`+ — inputs to the computed `scrollPercentage`, read only at sample time.++`KeyboardScrollController.contentOffset` keeps `@ObservationIgnored` for+the same reason. `KeyboardScrollController.contentHeight` is a computed+property over `@ObservationIgnored private var _contentHeight`; the+setter maintains a stored `canScroll: Bool` (also observable) that only+mutates on the `false → true` / `true → false` transition. This breaks a+real cascade: every `LazyVStack` block realisation grows+`contentSize.height`, which fires the inequality-guarded `contentHeight`+write, and the previously-derived `canScroll` would invalidate+`DocumentReaderView.body` (which reads `active.canScroll` in+`makeDocumentActions`) per realisation. After the fix,+`DocumentReaderView.body` invalidates at most twice per session — once+when content first lays out, once on `resetForNewSession`.++`prismTests/ScrollPositionObservationTests.swift` locks the contract in+with `withObservationTracking`-based assertions: if a future refactor+drops the annotation, those tests turn red before the regression ships.+ The dead code (`SharedBlockViews.renderedScrollTracker`, `RawSourceView.scrollTracker`, `ScrollContentPreferenceKey`, `ScrollContentInfo`, and the `coordinateSpace(name:)` modifiers used only by
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8dd085a..139521d 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Prism no longer hangs intermittently while scrolling continuously through a document (T-1289). The `.onScrollGeometryChange` action handlers introduced in PR #252 wrote `scrollPercentage`, `pendingRestorePercentage`, and the raw-source `scrollOffset`/`contentHeight` on every scroll frame, but no SwiftUI view reactively reads any of them — they're only sampled at user-action time. More importantly, `KeyboardScrollController.contentHeight` was observed by `DocumentReaderView.body` via `makeDocumentActions` reading `active.canScroll`, so every `LazyVStack` block realisation (each of which grew `contentSize.height` and triggered the inequality-guarded `contentHeight` write) cascaded into a full `DocumentReaderView` body re-evaluation. Two-layer fix: mark the four "no body reader" properties `@ObservationIgnored`, and refactor `KeyboardScrollController` so `contentHeight` is computed over an `@ObservationIgnored` backing store while `canScroll` is a stored observable Bool maintained by the setter — `DocumentReaderView.body` now invalidates only on the two `canScroll` transitions per session, not per realisation. `prismTests/ScrollPositionObservationTests.swift` locks both contracts in with `withObservationTracking`-based assertions so a future refactor can't silently re-introduce the regression. - Switching between rendered and raw markdown views no longer resets the scroll position to the top. The scroll percentage is now snapshotted at toggle time and restored on the receiving view (best-effort; raw lines and rendered blocks aren't 1:1 but the position is approximately preserved in both directions). Same mechanism powers cross-session restore on document re-open. - Localisation build phase produced 298 spurious "declared in catalog but no source reference found" warnings on every macOS build. Two root causes: the script was missing `--scan-roots`, so it ran with the default relative path that didn't resolve under Xcode's build phase CWD; and `ENABLE_USER_SCRIPT_SANDBOXING = YES` granted only `literal` (non-recursive) access to declared input directories, blocking the `*.swift` walk. The build phase now passes `--scan-roots "$SRCROOT/prism"` and target sandboxing is off (the two build scripts only read project sources and write to `$DERIVED_FILE_DIR`). - `Tools/validate-localisation.py` reference scanner missed many genuine catalog references because the old regex set only matched a handful of SwiftUI initialisers (`Text`, `Button`, `Label`, `String(localized:)`, etc.). Replaced with a Swift-aware tokeniser that walks every `.swift` file and yields every string literal — single-line, triple-quoted multi-line (with leading-indent stripping and `\<newline>` joins applied), and literals nested inside `\(...)` interpolations. Literals containing `\(...)` become regex patterns where each interpolation matches any catalog format specifier (`%@`, `%lld`, `%ld`, `%lf`, `%f`, `%d`, `%i`, `%s`, `%lu`, `%llu`) so e.g. `Text("paywall.exports.remaining \(remaining)")` correctly matches the catalog key `paywall.exports.remaining %lld`. Three new tokeniser tests cover interpolation, multi-line, and nested-literal cases.
Several KeyboardScrollControllerTests cases (arrowDownAdvancesByThreeLines, arrowUpRetreatsByThreeLines, pageDownAdvancesByPageStep, etc.) fail in xcodebuild test under macOS 26 / Xcode 26 because ScrollPosition.scrollTo(y:) doesn't set .point in the unit-test environment (verified with a standalone Swift script — scrollTo(y:) leaves point nil even on a fresh ScrollPosition()). These failures pre-date this branch — confirmed by git stash + re-run with the fix changes removed. Not in scope for this PR; flagged as a separate test-infrastructure issue.
The fix is plausible from the code-level analysis but neither the original hang nor its absence was reproduced under Instruments. A manual smoke test on a long document is in the PR test plan; recommend running it before squash-merge.