prism branch T-1775/bugfix-…-persisted-scroll PR #322 (MERGEABLE / CLEAN) commits 6 + 1 merge files 9 touched lines +653 / -27 tests 84 pass in 8 web suites

Pre-push review: T-1775 fragment navigation vs persisted scroll

An explicit navigation target (heading link, ToC entry, note target, search match) now outranks the persisted reading position for the load it is queued on. This artifact replaces an earlier, incorrect pre-push review of this branch — that one approved a state in which a remount-with-active-search regression had been pinned as intended behaviour by a test. That was corrected in 64c5c75; this review covers what will actually land.

At a glance

  • The fix works and is well-pinned. Eight regression tests drive the real production assembly (WebDocumentStateSynchronizer.makeAssembly) and the real restore seam; the previously-inverted remountWithActiveSearchStillRestoresSavedPosition now asserts the correct outcome, and the three search-related tests are mutually consistent.
  • Must decide before push: a re-parse reload with an active search can still raise the navigation claim without a user navigation, dropping that reload's restore. start() seeding only covers remounts, not re-parses.
  • Fixed in this review (editorial): the docstring on undeliveredNavigationTarget claimed "a raw→rendered remount with an active search lands on the current match rather than where the reader stopped" — the exact behaviour 64c5c75 removed, and the opposite of what the branch's own test now asserts.
  • Verification: make lint 0 violations; make build-ios and make build-macos succeed (one pre-existing ImageDimension Swift-6 warning, untouched by this diff); 84 tests across 8 web suites pass serially.
  • Not this branch's fault: WebScrollPositionRetentionTests fails under xcodebuild parallel testing even when run alone (shared ScrollPositionStore/UserDefaults across worker processes) and passes 10/10 serially. Pre-existing infra flake.
  • Out of scope, filed: handleProcessTermination has no production caller, so WebContent recovery never runs in production (T-1943). The branch's termination test correctly scopes itself to the controller contract.

Verdict

Needs fixes

The core fix is sound, well-tested and correctly documented, and the round-3 correction (64c5c75) does resolve the regression the previous review missed. Builds are clean on both platforms, SwiftLint reports 0 violations, and all eight web scroll/navigation/search suites (84 tests) pass serially.

One unresolved item blocks a clean push. The branch states a principle — only a user navigation may raise the claim — and closes the remount hole that violated it. But the same false-navigation path is still open on a re-parse reload: DocumentScrollContent mounts the assembly with .task(id: session.id), so the synchronizer (and its lastSearchScrollDOMID) survives a re-parse, while parsedBlocks.didSet re-runs recomputeMatchCounts() and clampCurrentMatchIndex() keeps the current match. If the match block's occurrence-qualified DOM id shifts (any edit above it changes sourceIndex), the diff fires, scrollTo raises the claim, and that reload's restore is dropped — with no user navigation. It is undocumented and untested. This is the same shape of hole as the one 64c5c75 fixed, so it needs an explicit decision rather than being inherited silently.

One stale comment stating the opposite of shipped behaviour was fixed in this review (editorial only). Everything else is minor or nit.

Review findings

11 raised · 1 fixed · 10 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you follow a link to a specific heading inside a document you have read before, Prism now takes you to that heading instead of dumping you back at your saved reading position.

Why it mattered

Prism remembers where you stopped reading in each document. It also lets you jump straight to a heading — from a table of contents, from a #section link in another document, from a search result, or from a note. Both of those end up asking the rendered page to do the same thing: scroll to this block. The page only remembers one such request at a time, so whichever arrived last won.

A recent performance change (building the document's HTML on a background thread) shifted the timing so that the saved position now routinely arrived last — and quietly threw away the heading you had actually asked for.

The fix, in one sentence

The page-controller now writes down which heading a real navigation asked for, and refuses to apply the saved reading position until that heading has actually been handed over.

Key concepts

  • Block DOM id — every paragraph and heading in the rendered document has a stable id. Both scrolling paths speak in these ids, which is why they were indistinguishable.
  • Event vs. level state — "the user just tapped a heading" is a one-off event. "The current search match is in block 7" is an ongoing fact. The second one looks like a brand-new event to any freshly-created object that has never seen it before — which is the bug the second round of this branch had to fix.

Architecture

The WebKit document path keeps native as the source of truth. WebDocumentStateSynchronizer runs a withObservationTracking pass that diffs native state against per-instance "last pushed" fields and pushes deltas to WebDocumentController, which either dispatches over the bridge or queues until the page reports ready/layoutSettled. Every push also folds into latestSnapshot, a coalesced replay used after reload or WebContent recovery.

The defect

WebDocumentStateSnapshot carries exactly one scrollTargetBlockID. Explicit navigation and stored-position restore both called WebDocumentController.scrollTo, so precedence was last-writer-wins with no representation of intent. T-1681 made the load task await precomputeDocumentHTML before load + restore; during that suspension the synchronizer's pass consumes pendingAnchorScroll and queues the navigation, so the restore now runs last and overwrites it.

Patterns used

  • Intent lives at the entry point, not in the payload. A new restoreScroll(blockID:) sits beside scrollTo(blockID:); both emit the identical .scrollToBlock command. The controller holds undeliveredNavigationTarget: String?, raised by scrollTo when the command cannot dispatch immediately, and restoreScroll no-ops while it is set.
  • Key the claim on its target, not a boolean. The claim clears only when that same id is handed to the bridge, so a restore command that happened to be queued ahead of the navigation cannot release it on the way out.
  • Seed derived level state at construction. WebDocumentStateSynchronizer.start() seeds lastSearchScrollDOMID from session.currentMatch before the first pass, so a fresh instance's nil cannot masquerade as "the user just moved to a new match".
  • One seam, two call sites. WebDocumentControllerFactory.restoreScrollPosition(to:session:) owns the id resolution (BlockDOMID.restoreDOMID) and the restoreScroll call; DocumentScrollContent routes both its load paths through it.

Trade-offs

A skipped restore is dropped, not deferred or retried — there is no suspension point in restoreScroll, deliberately. If the page never reports ready, the claim sticks for the controller's lifetime and every restore on it is dropped (logged at debug). Both are documented in-code.

Deep dive: the ordering window

DocumentScrollContent's .task(id: WebLoadKey(hasController:revision:)) now runs await WebDocumentControllerFactory.precomputeDocumentHTML(...) before controller.load(...) + restore. DocumentReaderView restores session.scrollPositionID, awaits parseContent(), then consumes pendingFragment into pendingAnchorScroll. The parseRevision bump arms the load task; the synchronizer's observation pass — scheduled on a main-actor hop from the willSet callback — fires during the precompute suspension, consumes pendingAnchorScroll, resolves it through BlockDOMID.navigationDOMID, and calls scrollTo. The load task then resumes and the restore lands last into the single scrollTargetBlockID.

Why the claim is an id, not a Bool

flushPending preserves queue order. With a Bool, a restore queued ahead of the navigation would dispatch first and clear the flag, releasing the claim before the navigation was ever delivered. Matching domID == undeliveredNavigationTarget in dispatch makes the release strictly causal. Notably this exact-match branch is the one part of the mechanism with no direct test.

Why scrollTo can clear the claim on the straight-through path

undeliveredNavigationTarget = canDispatch(command) ? nil : blockID is safe because canDispatch being true implies isReady && isLayoutSettled, and flushPending has therefore already drained every queued .scrollToBlock — so setting the claim to nil cannot orphan an outstanding target.

Recovery

resetForNavigation() deliberately does not clear the claim: the target has reached no page yet. scheduleSnapshotReplay() re-queues it from latestSnapshot.coalescedCommands(), which orders .scrollToBlock last, so the claim is scoped to the navigation's delivery (possibly spanning loads), never to a single load. Note that if isReady { flushPending() } in scheduleSnapshotReplay is dead — both callers clear isReady first — which is precisely what guarantees no replay can synchronously release the claim inside load. Pre-existing on main.

Edge case still open: the re-parse path

DocumentScrollContent mounts the assembly with .task(id: context.session.id), so the synchronizer — and its lastSearchScrollDOMID — survives a re-parse; start()'s seeding only runs per mount. DocumentSession.parsedBlocks.didSet calls search.recomputeMatchCounts(), whose clampCurrentMatchIndex() preserves a still-valid currentGlobalMatchIndex. Block DOM ids are b-{contentHash}-{sourceIndex}, so any edit above the match shifts its id. The diff then fires with no user navigation, scrollTo raises the claim, and the reload's restore is dropped — the T-1639 reload contract, violated by exactly the mechanism the branch says only user navigations may trigger.

Concurrency

All of it is @MainActor. The claim's clear happens synchronously in dispatch before the async callJavaScript Task, so bridge ordering matches dispatch ordering. The seeding read in start() is deliberately outside withObservationTracking — it registers no dependency, and computePass() re-reads all three search fields inside the tracked block, so nothing is lost. It primes cachedMapping from an untracked read, sound only because parsedBlocks and parseRevision are assigned in the same synchronous stretch — the invariant the mapping cache already documents.

Important changes — detailed

WebDocumentController: the undelivered-navigation claim

prism/ViewModels/WebDocumentController.swift

Why it matters. This is the whole fix. It introduces the only piece of state that distinguishes a user navigation from a stored-position restore, and it decides which one the page ends up honouring.

What to look at. WebDocumentController.swift:91-107 (the field), :275-281 (id-matched clear in dispatch), :392-428 (scrollTo / restoreScroll)

Takeaway. When two producers emit an identical command and the payload cannot carry precedence, put the discrimination at the entry point and keep it private to the receiver — do not widen the wire contract. Adding an intent discriminant to OutboundBridgeCommand would have changed its synthesised Equatable (which tests assert against) and forced the coalescing snapshot to answer "which intent wins on replay?", a question with no good answer when it holds exactly one scroll target.
Rationale. Keyed on the target id rather than a Bool specifically so a restore command queued ahead of the navigation cannot release the claim as it flushes — flushPending preserves order, so a Bool would be cleared by the wrong command.

WebDocumentStateSynchronizer.start(): seed lastSearchScrollDOMID

prism/ViewModels/WebDocumentStateSynchronizer.swift

Why it matters. The round-3 correction, and the reason this review exists. Without it, a raw→rendered toggle with an active search raised the claim from a nil→matchID diff and silently dropped the reading position toggleRawSource had just written — a T-1639 regression that an earlier iteration of this branch had pinned as intended behaviour.

What to look at. WebDocumentStateSynchronizer.swift:107-127 (seeding), :295-304 (the diff site)

Takeaway. Classify every synchronizer domain as an EVENT or a LEVEL before wiring it to something with side effects. One-shot events (pendingAnchorScroll, noteNavigationTarget) are consumed-and-cleared and are safe from a fresh instance. Edges on derived level state are not: a per-instance 'last' field starting at nil makes a pre-existing level look like a brand-new transition on the first pass. Seed such fields at construction.
Rationale. Seeding at start() rather than suppressing the first pass keeps a genuine post-remount move to a different match working — pinned by searchNavigationAfterRemountStillOutranksRestore.

The re-parse path still fakes a navigation (unresolved)

prism/ViewModels/WebDocumentStateSynchronizer.swift

Why it matters. The seeding closes the remount hole but not the re-parse hole. DocumentScrollContent mounts the assembly with .task(id: session.id), so the synchronizer survives a re-parse and start() does not run again; parsedBlocks.didSet re-runs recomputeMatchCounts() and clampCurrentMatchIndex() keeps a still-valid current match. Block DOM ids are b-{hash}-{sourceIndex}, so any edit above the match changes its id, the diff fires with no user navigation, and that reload's restore is dropped.

What to look at. WebDocumentStateSynchronizer.swift:107-127 vs DocumentScrollContent.swift:90; DocumentSession.swift:55-63; SearchCoordinator.swift:252-287

Takeaway. A per-instance diff field is only as well-scoped as the instance's lifetime. If the instance outlives the coordinate system its values are expressed in (here: block DOM ids, which change every parse), the field needs re-seeding on that boundary too — not just at construction.
Open question. Rationale not stated by the author and not inferable from the diff.

WebDocumentControllerFactory.restoreScrollPosition: the shared seam

prism/ViewModels/WebDocumentControllerFactory.swift

Why it matters. Moves id resolution and the restoreScroll call out of the view so both DocumentScrollContent load paths, and the live-page test, go through one place. This is what makes the precedence rule enforceable rather than a convention.

What to look at. WebDocumentControllerFactory.swift:319-338; DocumentScrollContent.swift:267-278

Takeaway. When you split one call into two with different semantics, give the new semantics a named seam rather than leaving the choice inline at each call site — otherwise the next call site reaches for the neutral-sounding one (scrollTo) and silently reintroduces the bug.
Rationale. Verified: post-diff the only remaining scrollTo call sites in prism/ are the two genuine navigation producers in the synchronizer, and they resolve through navigationDOMID rather than restoreDOMID, so they are correctly not routed through this seam.

WebFragmentNavigationPrecedenceTests: eight regression tests over the real assembly

prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift

Why it matters. Pins the precedence end to end without a live WebPage, including the boundary case (a remount is not a navigation) that the earlier iteration got backwards. All eight pass; the three search-related tests were re-examined for pinned-regression-as-intent and are mutually consistent.

What to look at. WebFragmentNavigationPrecedenceTests.swift:1-494 — notably :285 (remount restores) vs :246 and :330 (real navigations win)

Takeaway. When a fix hinges on distinguishing two look-alike triggers, write the negative test ("this trigger must NOT do it") in the same file as the positive ones. Here the discriminator is visible as a table: seeded-value-at-mount, then diff or no diff, then outcome.
Rationale. Tests drive WebDocumentStateSynchronizer.makeAssembly and WebDocumentControllerFactory.restoreScrollPosition — the real production seams — rather than reimplementing the wiring, which is the T-1719 regression-class lesson this repo already learned.

Key decisions

A separate <code>restoreScroll</code> entry point rather than an intent parameter on <code>scrollTo</code>.

Intent could not live in the payload: WebDocumentStateSnapshot holds exactly one scrollTargetBlockID, and adding a discriminant to OutboundBridgeCommand would change its synthesised Equatable (asserted against in existing tests) and force the coalescing replay to arbitrate intents. Keeping the discrimination private to the controller leaves the bridge contract untouched.

The claim is an id, not a Bool.

flushPending preserves queue order, so a restore queued ahead of the navigation would dispatch first and clear a Bool flag before the navigation was ever delivered. Matching domID == undeliveredNavigationTarget makes the release strictly causal. Stated in the commit message for bbc4e21.

A skipped restore is dropped, never deferred or retried.

restoreScroll is a synchronous guard with no suspension point, by design — the in-code comment explicitly warns against "fixing" it into an await/Task.yield() loop. The next restore is whatever the caller issues later (reload, remount, re-parse). Trade-off accepted: a reload landing inside an outstanding claim loses that reload's restore.

The claim deliberately survives <code>load</code> and WebContent recovery.

resetForNavigation() does not clear it, because the target has reached no page yet; scheduleSnapshotReplay() re-queues it from the coalesced snapshot. The claim is scoped to the navigation's delivery, not to any one load. Pinned by terminationMidNavigationScrollKeepsFragmentThenReleases.

The precedence is widened to every <code>scrollTo</code> producer, not just fragments.

ToC entries, cross-document #fragment links, note targets and search matches all outrank the saved position for the load they are queued on. Deliberate and tested (searchMatchNavigationOutranksSavedPosition), but the CHANGELOG entry describes only the heading case.

Seeding is done at <code>start()</code> only, not on every parse revision.

This closes the remount hole. Whether the re-parse hole is intended to stay open is not stated anywhere in the commits, code comments, or the agent note — see the open question on the third important-change card.

(inferred — not stated by the author.)
No <code>specs/bugfixes/&lt;name&gt;/report.md</code> for T-1775.

The fix is documented via docs/agent-notes/scroll-persistence.md plus an amendment to specs/bugfixes/webkit-state-integration/report.md. That amendment also corrects a factually wrong claim in the older report ("restore-from-recent uses the same field" — it does not; verified by tracing openRecentFilesession.restoreScrollPosition()scrollPositionIDrestoreDOMIDrestoreScroll). Recent bugfixes in this repo are split roughly evenly on whether they add a report, so this is within convention.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorWebDocumentStateSynchronizer — re-parse reloadA re-parse reload with an active search can still raise the navigation claim without a user navigation, dropping that reload's stored-position restore. The synchronizer survives a re-parse (.task(id: session.id)), so start()'s seeding does not re-run; parsedBlocks.didSet re-runs recomputeMatchCounts() and clampCurrentMatchIndex() keeps a still-valid current match; block DOM ids embed sourceIndex, so any edit above the match changes the id and fires the diff. Triggered by an external file-change reload or a URL refresh. Contradicts the branch's own stated principle that only a user navigation may raise the claim, and violates the T-1639 reload-restores-position contract in that case. Undocumented and untested. Note the iOS image-access reload does NOT bump parseRevision and so is unaffected.REPORTED, NOT FIXED — needs a production or test-logic change, which this review is scoped out of. Author must pick one: (a) re-seed lastSearchScrollDOMID when the mapping revision changes, (b) accept it and say so explicitly in docs/agent-notes/scroll-persistence.md plus a test pinning the accepted outcome, or (c) file a follow-up ticket. Option (b) is defensible — landing on the active search match after a reload is arguably fine — but it must be a decision, not an inheritance.
majorWebDocumentController.swift:96-98 — stale docstringThe docstring on undeliveredNavigationTarget read "That is why a raw-to-rendered remount with an active search lands on the current match rather than where the reader stopped." That is the exact behaviour commit 64c5c75 removed, and the direct opposite of what remountWithActiveSearchStillRestoresSavedPosition now asserts. It was added in bbc4e21 and never revisited when 64c5c75 landed. Independently flagged by two review agents as the most load-bearing and most misleading comment in the diff.FIXED in this review (comment only, no logic change): replaced with the post-fix rule — only a user navigation raises the claim, and start() seeds the synchronizer's search diff so an already-active search cannot fake one. make lint clean and macOS build clean after the edit.
minorTest-helper duplicationWebFragmentNavigationPrecedenceTests.swift:59-122 forks scaffolding from WebStateSynchronizerAssemblyTests.swift:66-120. waitUntil(timeout:_:) is byte-identical apart from its doc comment; the Assembly struct and makeAssembly() differ only in fixture text and session URL; domIDs(for:) is now a third verbatim copy (also in WebScrollPositionRetentionTests.swift:62-64 and WebCollapsedSectionScrollTests.swift:68-70). The pattern now has four instances across the WebRendering suite.REPORTED, NOT FIXED (test-logic change, out of scope for this review). Suggested follow-up: a shared prismTests/WebRendering/WebAssemblySupport.swift — the directory already uses that convention (ParityFixtureSupport.swift, WebDocumentLiveHarness.swift). Carry over the new file's UUID-based session URL as the shared default; it is the variant that avoids ScrollPositionStore collisions. The new remount(session:) helper is genuinely new and worth keeping as a parameter of the shared factory.
minorUntested branch — the id-matching clearThe exact-match condition at WebDocumentController.swift:277 (domID == undeliveredNavigationTarget) is the one part of the mechanism with no direct test. Simplifying it to an unconditional clear would still pass all eight new tests, silently reintroducing the Bool-flag defect bbc4e21 fixed.REPORTED, NOT FIXED (test addition is out of scope). Suggested test: with the controller not ready, restoreScroll(B) queues B, then scrollTo(A) raises the claim; after test_markReady/test_markLayoutSettled, dispatching B must not release the claim and the final scrollTargetBlockID must be A.
minorIn-page search auto-scroll on remountOn a raw-to-rendered toggle with an active search, DocumentScrollContent still pushes setSearchState on mount, and prism-search.js applyState unconditionally calls scrollCurrentIntoView. setSearchState does not require layoutSettled so it dispatches at ready, while the restore scrollToBlock waits for layoutSettled and coalescedCommands() orders it last — so the END STATE is correct (the restore wins), but the page may visibly jump to the match and back. Before this branch the two authorities agreed on the match, so the flicker is new.REPORTED, NOT FIXED. Either accept and record it in scroll-persistence.md (which currently implies the seeding fully settles remount-vs-search), or carry a scrollToCurrent flag in the setSearchState payload that the feeder sets false when the current match has not changed since the last push. Not a blocker — the final position is right.
minorCHANGELOG scopeThe entry describes only "an explicit heading target", but the shipped precedence covers every scrollTo producer — ToC entries, notes-panel targets and search matches all outrank a restore for the load they are queued on. Separately, the repo's convention over the last several CHANGELOG commits is to prepend new Fixed entries at the top; this one was inserted second.NOT CHANGED. Left for the author to decide alongside the re-parse question, since the precise wording depends on which way that goes — in particular the search half must not imply that returning from raw source jumps to the match, because 64c5c75 deliberately makes it restore instead. Prose is otherwise accurate and matches the surrounding entries' symptom-cause-behaviour style.
minorDocs — CLAUDE.md and the 'never outlives delivery' claimCLAUDE.md:53 still names controller.scrollTo as THE route for navigation targets and says only that "scroll restore waits for layoutSettled" — nothing tells a future agent that restore now has its own entry point with different precedence, so wiring a new restore-ish call to scrollTo is the obvious and wrong move. Separately, scroll-persistence.md:46-48 says the claim "never outlives delivery", which holds only when delivery happens: if the page never reports ready the claim sticks for the controller's lifetime and every restore is dropped. The production code is honest about this (WebDocumentController.swift:420-422 logs exactly that case); the agent note is not.REPORTED, NOT FIXED — bundled with the re-parse decision, since both doc edits depend on its outcome. Suggested CLAUDE.md clause: '; the saved-position restore goes through WebDocumentControllerFactory.restoreScrollPosition then controller.restoreScroll, which yields to a queued-but-undelivered scrollTo target (T-1775)'.
nitDocs — attribution and enumeration slipsscroll-persistence.md:55 calls noteNavigationTarget one of the events 'the session hands over once' — it lives on DocumentLayoutCoordinator, which is @State at DocumentReaderView.swift:113 and therefore SURVIVES the remount the paragraph is about. The conclusion (consume-and-clear) is right; the stated reason is wrong for half the sentence. WebDocumentControllerFactory.swift:320-322 enumerates the load paths but omits the iOS image-access reload, which calls the same seam at DocumentScrollContent.swift:265 (scroll-persistence.md lists all four).REPORTED, NOT FIXED (bundled with the doc pass above).
nitNaming foot-gun and a pass-through wrapperscrollTo and restoreScroll are two internal methods with the identical signature on the same type, distinguished only by doc comment, where picking the wrong one silently reintroduces this bug — and scrollTo reads as the neutral default (its pre-branch docstring even said 'also used for restore'). Separately, DocumentScrollContent.restoreScrollPosition(with:) is now a bare one-statement forward to the factory, and shadows three other same-named things in the scroll path (the factory's, DocumentSession.restoreScrollPosition(), RawSourceView.restoreScrollPosition(proxy:)).REPORTED, NOT FIXED. Cheap mitigations: rename scrollTo to navigateTo(blockID:), or add one negative line to its docstring — 'Not the restore path: stored-position restore must go through restoreScroll (T-1775).' The enum-intent refactor is NOT recommended (see the decisions section). Note the module has only three production call sites, so the residual risk is small.
nitBlockDOMID.restoreDOMID — wasted mapping walkBlockDOMID.swift:164-166's blocks: overload runs map(blocks:) unconditionally, but the mapped: overload's first two statements never touch mapped: the empty guard (fresh open, no saved position) and the 'b-' prefix pass-through (the modern stored format, i.e. essentially every restore). So every open, toggle and reload pays a full walk on the MainActor — rebuilding each block's hashing string and taking the global BlockIDCache mutex per block — and discards it. Pre-existing on main, but this diff moved the call into a new shared seam.REPORTED, NOT FIXED (production change, out of scope). Behaviour-preserving follow-up: hoist the two early-outs into the convenience overload. Verified against the existing assertions in WebScrollPositionRetentionTests.swift:209-214.
nitTest comment fidelityWebFragmentNavigationPrecedenceTests.swift:78-81 says remount(session:) is 'exactly what a raw-to-rendered toggle does', but it builds a fresh DocumentLayoutCoordinator() and AppSettings(); in production both survive the toggle (the coordinator is @State at DocumentReaderView.swift:113) and only controller/router/synchronizer are recreated. Immaterial to the assertions. Also, after remount the FIRST assembly is still alive with its observation armed on the same session, so two synchronizers react to navigateToMatch in searchNavigationAfterRemountStillOutranksRestore — harmless (the stale one pushes to its own dead controller) but not what production does.REPORTED, NOT FIXED (test-comment change; bundled with any follow-up test pass).

Per-file diffs

Click to expand.

prism/ViewModels/WebDocumentController.swift Modified +58 / -2
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex 7d45300..113df70 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -88,6 +88,24 @@ final class WebDocumentController {     /// native truth in one shot rather than a queued history (design contract).     private(set) var latestSnapshot = WebDocumentStateSnapshot() +    /// The DOM id of an explicit navigation scroll (`scrollTo`) that is queued but+    /// has not yet been handed to the bridge. While it is non-nil, `restoreScroll`+    /// is a no-op, so the stored reading position never replaces a target the user+    /// asked for (T-1775). Every `scrollTo` producer raises it, not just fragments:+    /// a TOC entry, a cross-document `#fragment`, a note target, and a search match+    /// all outrank the saved position for the load they are queued on. Only a *user*+    /// navigation raises it, though: a raw→rendered remount with an active search+    /// still lands where the reader stopped, because+    /// `WebDocumentStateSynchronizer.start()` seeds its search-match diff so an+    /// already-active search cannot fake a navigation out of a fresh instance.+    ///+    /// It is cleared only by dispatching *that same* target, so a restore command+    /// that happened to be queued ahead of it cannot release the claim on its way+    /// out. It deliberately survives `load`/recovery — `scheduleSnapshotReplay`+    /// re-queues the target from the coalesced snapshot, so the claim is scoped to+    /// the navigation's delivery, which may span several loads, not to any one load.+    @ObservationIgnored private var undeliveredNavigationTarget: String?+     // MARK: - Observation hooks (set by the view/coordinator)      /// Called with each accepted inbound message so the layout/coordinator can@@ -255,6 +273,12 @@ final class WebDocumentController {     /// Invokes the bridge function for `command` in the bridge world. The     /// generation tag travels with every command so JS can drop a stale push.     private func dispatch(_ command: OutboundBridgeCommand) {+        // The claimed navigation target is on its way to the page, so nothing is+        // waiting on it and the next restore may proceed (T-1775). Matching the id+        // keeps an unrelated queued scroll from releasing the claim.+        if case .scrollToBlock(let domID) = command, domID == undeliveredNavigationTarget {+            undeliveredNavigationTarget = nil+        }         let arguments = Self.arguments(for: command, generation: currentGeneration)         let functionName = command.functionName         Task { [page] in@@ -365,9 +389,41 @@ final class WebDocumentController {         send(.setSectionState(collapsedIDs: collapsedIDs))     } -    /// Scrolls to a block by occurrence-qualified DOM id (also used for restore).-    /// No-op until `ready`; held until `layoutSettled` for restore semantics.+    /// Scrolls to a block by occurrence-qualified DOM id in response to an explicit+    /// navigation (TOC entry, cross-document `#fragment`, note or search target).+    /// No-op until `ready`; held until `layoutSettled`.     func scrollTo(blockID: String) {+        let command = OutboundBridgeCommand.scrollToBlock(domID: blockID)+        // A navigation the page cannot take yet must outrank a stored-position+        // restore issued before it is delivered (T-1775). When it dispatches+        // straight through there is nothing left waiting, so the claim clears.+        undeliveredNavigationTarget = canDispatch(command) ? nil : blockID+        send(command)+    }++    /// Restores the session's stored reading position into a freshly-loaded+    /// document (T-1639) — unless an explicit navigation scroll is still waiting+    /// to be delivered, in which case this call does nothing at all (T-1775).+    ///+    /// The load path suspends on the off-main HTML precompute (T-1681), so a+    /// cross-document `#fragment` is routinely consumed and queued BEFORE+    /// `load` + restore run. Both targets fold into the snapshot's single+    /// `scrollTargetBlockID`, so an unconditional restore silently replaced the+    /// heading the user asked for.+    ///+    /// Every skipped restore is dropped outright — never suspended, queued,+    /// deferred, or retried — and the next restore is whatever the caller issues+    /// later (a reload, remount, or re-parse). The claim is released once the+    /// navigation command is handed to the bridge, so a later reload restores the+    /// reading position normally. Do not "fix" this into an `await`/`Task.yield()`+    /// loop — there is no suspension point here by design.+    func restoreScroll(blockID: String) {+        guard undeliveredNavigationTarget == nil else {+            // Rare but silent otherwise: if a page never reports `ready`, the claim+            // stays up and every restore is dropped. Leave a trail for that case.+            Self.logger.debug("Restore scroll skipped: navigation target still undelivered")+            return+        }         send(.scrollToBlock(domID: blockID))     } 
prism/ViewModels/WebDocumentStateSynchronizer.swift Modified +21 / -0
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex 9944aaf..f1ace55 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -71,6 +71,9 @@ final class WebDocumentStateSynchronizer {     /// matches inside the same block does not re-jump to the block top     /// (matching the pre-cutover `onChange(of: currentMatch?.blockId)`     /// semantics). Cleared when the current match clears.+    ///+    /// Seeded from the session in `start()`, NOT left at nil: see the comment+    /// there — an unseeded remount would read as a fresh navigation (T-1775).     private var lastSearchScrollDOMID: String?      /// The blocks→DOM id mapping and the `parseRevision` it was built for. Plain@@ -104,6 +107,22 @@ final class WebDocumentStateSynchronizer {     func start() {         guard !isStarted else { return }         isStarted = true+        // A remount is not a navigation (T-1775). Search-match scrolling is an+        // EDGE on derived level state, unlike the other domains here (which are+        // level state the first pass must push so a recovery replay has it) and+        // unlike `pendingAnchorScroll` / `noteNavigationTarget` (genuine one-shot+        // events the session hands over exactly once). A synchronizer is rebuilt+        // per mount, so on a raw→rendered toggle with a search still active the+        // unseeded nil → matchID diff was indistinguishable from the user+        // navigating to a new match: it raised the controller's undelivered-+        // navigation claim before the load task ran, and the reading position the+        // toggle had just written to `session.scrollPositionID` was dropped.+        // Seeding from the session makes the first pass see no change; a genuine+        // move to a different match afterwards still diffs and still outranks a+        // stored-position restore.+        lastSearchScrollDOMID = currentSearchMatchDOMID(+            mapped: mapping(for: session.parsedBlocks)+        )         synchronize()     } @@ -275,6 +294,8 @@ final class WebDocumentStateSynchronizer {         }         // Search-match navigation (Req 6.3): level state, deduped by block DOM         // id so navigating between matches inside one block does not re-jump.+        // `lastSearchScrollDOMID` is seeded in `start()`, so the first pass after+        // a remount cannot fake a navigation out of an already-active search.         if pass.searchScrollDOMID != lastSearchScrollDOMID {             lastSearchScrollDOMID = pass.searchScrollDOMID             if let domID = pass.searchScrollDOMID {
prism/ViewModels/WebDocumentControllerFactory.swift Modified +21 / -0
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex e84f4b2..344fd1e 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -316,6 +316,27 @@ enum WebDocumentControllerFactory {         controller.setSearchState(json: searchStateJSON(session: session, settings: settings))     } +    /// Replays the session's stored reading position into a freshly-loaded document+    /// (T-1639). Every load lands at the top otherwise: initial open (restored from+    /// ScrollPositionStore in DocumentReaderView), the raw→rendered remount, and+    /// re-parse reloads. Called by `DocumentScrollContent` right after `load`; the+    /// controller holds the scroll until the page posts `layoutSettled`.+    ///+    /// Routed through `restoreScroll` rather than `scrollTo` so the call becomes a+    /// no-op while an explicit navigation target is still waiting to be delivered —+    /// a cross-document `#fragment` consumed while the off-main HTML precompute was+    /// suspended must not be replaced by the saved position (T-1775).+    static func restoreScrollPosition(+        to controller: WebDocumentController,+        session: DocumentSession+    ) {+        guard let restoreID = BlockDOMID.restoreDOMID(+            forStored: session.scrollPositionID,+            blocks: session.parsedBlocks+        ) else { return }+        controller.restoreScroll(blockID: restoreID)+    }+     /// The `setSearchState` JSON for the session's current search state: the     /// SearchStateFeeder translation of SearchCoordinator's native truth (query,     /// per-block counts, current global match) under the current comment-visibility
prism/Views/DocumentScrollContent.swift Modified +7 / -10
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 92c01c6..20269e7 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -267,17 +267,14 @@ struct DocumentScrollContent: View {     #endif      /// Replays the session's reading position into the freshly-loaded document-    /// (T-1639). Every load lands at the top otherwise: initial open (restored-    /// from ScrollPositionStore in DocumentReaderView), the raw→rendered-    /// remount (this view's @State controller is recreated), and re-parse-    /// reloads. The controller holds `scrollToBlock` until the page posts-    /// `layoutSettled`, so sending immediately after `load` is safe.+    /// (T-1639), yielding to an undelivered navigation target (T-1775). The+    /// contract lives on `WebDocumentControllerFactory.restoreScrollPosition`;+    /// this is the view-side call site for it.     private func restoreScrollPosition(with webController: WebDocumentController) {-        guard let restoreID = BlockDOMID.restoreDOMID(-            forStored: context.session.scrollPositionID,-            blocks: context.session.parsedBlocks-        ) else { return }-        webController.scrollTo(blockID: restoreID)+        WebDocumentControllerFactory.restoreScrollPosition(+            to: webController,+            session: context.session+        )     }      /// Recomputes and re-pushes the per-block search-highlight state from the
prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift Added +494 / -0
diff --git a/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swiftnew file mode 100644index 0000000..6720fb2--- /dev/null+++ b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift@@ -0,0 +1,494 @@+//+//  WebFragmentNavigationPrecedenceTests.swift+//  prismTests+//+//  T-1775 regression tests: an explicit fragment/anchor navigation must take+//  precedence over the stored reading position for the load it arrives on.+//+//  Existing suites cover fragment resolution (DocumentSessionAnchorTests,+//  AnchorNavigationDuplicateHeadingsTests) and stored-position restore+//  (WebScrollPositionRetentionTests) separately. Neither covers their ORDERING+//  across the off-main HTML precompute suspension T-1681 introduced:+//+//    1. DocumentReaderView restores `session.scrollPositionID` (block B), awaits+//       `parseContent()`, then consumes `pendingFragment` into+//       `pendingAnchorScroll` (heading A).+//    2. The parseRevision bump starts DocumentScrollContent's load task, which+//       now awaits `precomputeDocumentHTML` BEFORE calling `load` + restore.+//    3. While that await is suspended, the synchronizer's observation pass+//       consumes `pendingAnchorScroll` and sends `scrollTo(A)` — so by the time+//       the load task resumes, `pendingAnchorScroll` is already nil.+//    4. The restore then sent B, overwriting the snapshot's single+//       `scrollTargetBlockID`, and the page settled at B instead of A.+//+//  These tests mount the REAL production assembly+//  (`WebDocumentStateSynchronizer.makeAssembly`, as DocumentScrollContent does)+//  and drive the same restore seam production uses+//  (`WebDocumentControllerFactory.restoreScrollPosition`), so the precedence+//  rule is pinned end to end without a live WebPage: commands queue before+//  `ready` and fold into `latestSnapshot`, which is what the load replays.+//++import Foundation+import Testing+@testable import prism++@MainActor+struct WebFragmentNavigationPrecedenceTests {++    // MARK: - Fixture++    private static let fixture = """+    # Getting Started++    Intro paragraph.++    ## Installation++    Install paragraph.++    ## Configuration++    Configuration paragraph.++    ## Troubleshooting++    Troubleshooting paragraph.+    """++    private struct Assembly {+        let session: DocumentSession+        let coordinator: DocumentLayoutCoordinator+        let settings: AppSettings+        let controller: WebDocumentController+        let synchronizer: WebDocumentStateSynchronizer+    }++    /// A parsed file-backed session with the production assembly mounted, at a+    /// unique path so ScrollPositionStore entries never collide across tests.+    private func makeAssembly() async -> Assembly {+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/t1775-\(UUID().uuidString).md"),+            content: Self.fixture+        )+        await session.parseContent()+        return remount(session: session)+    }++    /// Mounts a SECOND production assembly over an existing session — exactly what+    /// a raw→rendered toggle does: `DocumentScrollContent` is unmounted and+    /// re-created, so controller and synchronizer are fresh while the session (and+    /// its search state) survives.+    private func remount(session: DocumentSession) -> Assembly {+        let coordinator = DocumentLayoutCoordinator()+        let settings = AppSettings()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: settings,+            coordinator: coordinator,+            notesManager: NotesManager()+        )+        made.synchronizer.start()+        return Assembly(+            session: session,+            coordinator: coordinator,+            settings: settings,+            controller: made.controller,+            synchronizer: made.synchronizer+        )+    }++    // MARK: - Helpers++    /// The occurrence-qualified DOM ids the emitter stamps for this session.+    private func domIDs(for session: DocumentSession) -> [String] {+        BlockDOMID.map(blocks: session.parsedBlocks).map(\.domID)+    }++    /// Polls the main actor until `condition` holds or the timeout elapses.+    /// The synchronizer's pushes land on later main-actor turns.+    private func waitUntil(+        timeout: Duration = .seconds(2),+        _ condition: () -> Bool+    ) async -> Bool {+        let clock = ContinuousClock()+        let deadline = clock.now.advanced(by: timeout)+        while clock.now < deadline {+            if condition() { return true }+            await Task.yield()+            try? await Task.sleep(for: .milliseconds(10))+        }+        return condition()+    }++    /// The DOM id of the `## Troubleshooting` heading — the fragment target.+    private func anchorDOMID(in session: DocumentSession) throws -> String {+        let index = try #require(+            session.parsedBlocks.firstIndex { block in+                if case .heading(_, let text) = block { return text == "Troubleshooting" }+                return false+            },+            "fixture must contain the Troubleshooting heading"+        )+        return domIDs(for: session)[index]+    }++    // MARK: - The ordering regression++    // The exact interleave the ticket describes: saved position restored, parse+    // done, fragment consumed by the synchronizer DURING the precompute+    // suspension, then the load task resumes and offers the saved position.+    // Pre-fix the restore overwrote the snapshot's single scrollTargetBlockID+    // and the page settled at the saved block.+    @Test("A fragment consumed during the HTML precompute survives the saved-position restore")+    func fragmentBeatsSavedPositionAcrossPrecompute() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let savedBlockID = domIDs(for: session)[1]      // block B: the stored reading position+        let anchorID = try anchorDOMID(in: session)     // block A: the requested heading++        // DocumentReaderView: restore the persisted position, then consume the+        // cross-document fragment after parseContent returns.+        session.scrollPositionID = savedBlockID+        session.pendingFragment = "troubleshooting"+        let fragment = try #require(session.pendingFragment)+        session.pendingFragment = nil+        session.scrollToAnchor(fragment)++        // The precompute suspension: the synchronizer's observation pass runs+        // and consumes the anchor target before the load task resumes.+        let anchorRouted = await waitUntil {+            assembly.controller.latestSnapshot.scrollTargetBlockID == anchorID+                && session.pendingAnchorScroll == nil+        }+        #expect(anchorRouted, "the fragment target must reach the controller")++        // The load task resumes: load, then offer the stored reading position.+        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        assembly.controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(+            to: assembly.controller, session: session+        )++        #expect(+            assembly.controller.latestSnapshot.scrollTargetBlockID == anchorID,+            "the saved reading position must not replace the requested fragment target"+        )+        #expect(+            !assembly.controller.pendingCommands.contains {+                if case .scrollToBlock(let domID) = $0 { return domID == savedBlockID }+                return false+            },+            "no queued scroll may still land on the saved position"+        )+    }++    // Reverse arrival order (the pre-T-1681 timing, where the load task ran to+    // completion before the synchronizer's scheduled pass): the fragment still+    // wins because it is issued last.+    @Test("A fragment arriving after the restore still wins")+    func fragmentArrivingAfterRestoreWins() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        session.scrollPositionID = domIDs(for: session)[1]+        let anchorID = try anchorDOMID(in: session)++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        assembly.controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(+            to: assembly.controller, session: session+        )++        session.scrollToAnchor("troubleshooting")++        let routed = await waitUntil {+            assembly.controller.latestSnapshot.scrollTargetBlockID == anchorID+        }+        #expect(routed, "a fragment issued after the restore must take effect")+    }++    // The claim is only raised when the page cannot take the scroll yet. A+    // navigation that dispatches straight through leaves nothing waiting, so the+    // very next restore must be free — otherwise the guard would be sticky for+    // any controller that has ever navigated.+    @Test("A navigation that dispatches straight through does not block the next restore")+    func deliveredNavigationDoesNotBlockRestore() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let controller = assembly.controller+        let savedBlockID = domIDs(for: session)[1]+        session.scrollPositionID = savedBlockID+        let anchorID = try anchorDOMID(in: session)++        // The page is already up, so `scrollTo` dispatches immediately.+        controller.test_markReady()+        controller.test_markLayoutSettled()+        controller.scrollTo(blockID: anchorID)+        #expect(controller.latestSnapshot.scrollTargetBlockID == anchorID)++        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)+        #expect(+            controller.latestSnapshot.scrollTargetBlockID == savedBlockID,+            "an already-delivered navigation must not suppress the next restore"+        )+    }++    // MARK: - Every navigation producer, not just fragments++    // `scrollTo` is also the search-match and note-navigation entry point, so the+    // precedence applies to them too: a user moving to a new match while the page+    // is up outranks the saved position. Pinned because it is a deliberate+    // widening of the T-1639 restore contract, not a fragment-only fix.+    @Test("A search-match navigation also outranks the saved reading position")+    func searchMatchNavigationOutranksSavedPosition() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let controller = assembly.controller+        session.scrollPositionID = domIDs(for: session)[1]++        // The user runs a search on the mounted document and steps to a match —+        // a genuine navigation event, not a remount artefact.+        session.search.setActiveSearchQueryForTesting("Troubleshooting")+        session.search.navigateToMatch(at: 0)+        let matchIndex = try #require(session.currentMatch?.blockIndex, "search must select a match")+        let matchID = domIDs(for: session)[matchIndex]++        let routed = await waitUntil {+            controller.latestSnapshot.scrollTargetBlockID == matchID+        }+        #expect(routed, "the search match must reach the controller")++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)++        #expect(+            controller.latestSnapshot.scrollTargetBlockID == matchID,+            "the saved reading position must not replace an undelivered search match"+        )+    }++    // MARK: - A remount is not a navigation (T-1775 round-2 regression)++    // The counterpart to the test above, and the boundary of the precedence rule:+    // the synchronizer's search diff-tracking var is per-instance, so a remount+    // (raw→rendered toggle, same session, search still active) used to see+    // nil → matchID and raise the navigation claim before the load task ran —+    // silently dropping the reading position `toggleRawSource` had just written.+    // No user navigation happened, so the restore must win.+    @Test("A remount with an active search does not suppress the saved-position restore")+    func remountWithActiveSearchStillRestoresSavedPosition() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session++        // The reader searches in the first (rendered) mount.+        session.search.setActiveSearchQueryForTesting("Troubleshooting")+        session.search.navigateToMatch(at: 0)+        let matchIndex = try #require(session.currentMatch?.blockIndex, "search must select a match")+        let matchID = domIDs(for: session)[matchIndex]+        let firstRouted = await waitUntil {+            assembly.controller.latestSnapshot.scrollTargetBlockID == matchID+        }+        #expect(firstRouted, "the search match must reach the first controller")++        // Toggle to raw and back: a fresh assembly over the same session, with the+        // raw reading position translated into `scrollPositionID` by+        // `DocumentLayoutCoordinator.toggleRawSource`.+        let savedBlockID = domIDs(for: session)[3]+        session.scrollPositionID = savedBlockID+        let remounted = remount(session: session)+        let controller = remounted.controller++        // `start()` runs its first pass synchronously, and nothing tracked has+        // mutated since, so the fresh controller must still be untouched.+        await Task.yield()+        #expect(+            controller.latestSnapshot.scrollTargetBlockID == nil,+            "a remount must not queue a search scroll of its own"+        )++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)++        #expect(+            controller.latestSnapshot.scrollTargetBlockID == savedBlockID,+            "the reading position must survive a remount that carries an active search"+        )+    }++    // The seeding must not disarm search navigation for the rest of the mount:+    // after a remount, stepping to a DIFFERENT match is a real navigation again.+    @Test("After a remount, moving to a new search match still outranks the restore")+    func searchNavigationAfterRemountStillOutranksRestore() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        // "paragraph" matches once in each of the fixture's four paragraphs, so+        // the first and last matches are in different blocks.+        session.search.setActiveSearchQueryForTesting("paragraph")+        session.search.navigateToMatch(at: 0)+        let firstIndex = try #require(session.currentMatch?.blockIndex, "search must select a match")++        let remounted = remount(session: session)+        let controller = remounted.controller+        session.scrollPositionID = domIDs(for: session)[1]++        // The reader steps to another match — a real navigation on the new mount.+        session.search.navigateToMatch(at: session.search.totalMatchCount - 1)+        let newIndex = try #require(session.currentMatch?.blockIndex)+        try #require(newIndex != firstIndex, "fixture must offer matches in two blocks")+        let newMatchID = domIDs(for: session)[newIndex]++        let routed = await waitUntil {+            controller.latestSnapshot.scrollTargetBlockID == newMatchID+        }+        #expect(routed, "a post-remount search navigation must still reach the controller")++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)++        #expect(+            controller.latestSnapshot.scrollTargetBlockID == newMatchID,+            "seeding must not stop a genuine post-remount navigation from outranking the restore"+        )+    }++    // MARK: - The restore path must still work++    // Guards against over-fixing: with no navigation target in flight the saved+    // reading position must still be restored (the T-1639 contract).+    @Test("Without a fragment the saved reading position is still restored")+    func savedPositionRestoresWithoutFragment() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let savedBlockID = domIDs(for: session)[1]+        session.scrollPositionID = savedBlockID++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        assembly.controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(+            to: assembly.controller, session: session+        )++        #expect(assembly.controller.latestSnapshot.scrollTargetBlockID == savedBlockID)+    }++    // The precedence is scoped to the load that the navigation was queued for.+    // Once the page has taken the navigation scroll, a later reload restores the+    // reading position normally — the claim must not be sticky for the session.+    @Test("After the navigation scroll is delivered a later reload restores the reading position")+    func restoreResumesAfterNavigationIsDelivered() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let controller = assembly.controller+        let anchorID = try anchorDOMID(in: session)++        session.scrollPositionID = domIDs(for: session)[1]+        session.scrollToAnchor("troubleshooting")+        let anchorRouted = await waitUntil {+            controller.latestSnapshot.scrollTargetBlockID == anchorID+        }+        #expect(anchorRouted)++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)+        #expect(controller.latestSnapshot.scrollTargetBlockID == anchorID)++        // The page comes up and takes the queued fragment scroll.+        controller.test_markReady()+        controller.test_markLayoutSettled()++        // The reader scrolls on; a later reload must restore where they are now.+        let newPosition = domIDs(for: session)[3]+        session.scrollPositionID = newPosition+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)++        #expect(+            controller.latestSnapshot.scrollTargetBlockID == newPosition,+            "the fragment must not keep blocking restores after it has been delivered"+        )+    }++    // MARK: - WebContent termination while the navigation scroll is still queued++    // `resetForNavigation` deliberately does NOT clear the undelivered-navigation+    // claim, because the queued fragment target has not reached any page yet. The+    // coalesced snapshot still carries it, so `scheduleSnapshotReplay` re-queues+    // the same `.scrollToBlock` for the recovered page, and dispatching it there+    // clears the claim — the docstring on `undeliveredNavigationTarget` says the+    // claim "survives load/recovery", and this pins that end to end.+    //+    // Scope note: this drives `handleProcessTermination` directly, as the other+    // controller tests do. That method currently has no production caller (no live+    // WKWebView/WebPage termination signal is wired to it — pre-existing gap,+    // tracked as T-1943), so this test pins the controller's recovery contract,+    // NOT that WebContent recovery runs in production.+    @Test("A WebContent termination mid-navigation-scroll still lands on the fragment, then releases")+    func terminationMidNavigationScrollKeepsFragmentThenReleases() async throws {+        let assembly = await makeAssembly()+        let session = assembly.session+        let controller = assembly.controller+        let savedBlockID = domIDs(for: session)[1]+        let anchorID = try anchorDOMID(in: session)++        // A fragment navigation is queued but never delivered: the page dies+        // before it is ever ready, so the claim is still outstanding.+        session.scrollPositionID = savedBlockID+        session.scrollToAnchor("troubleshooting")+        let anchorRouted = await waitUntil {+            controller.latestSnapshot.scrollTargetBlockID == anchorID+        }+        #expect(anchorRouted, "the fragment target must reach the controller")+        #expect(!controller.isReady, "the page must not have come up yet")++        let documentURL = WebDocumentControllerFactory.documentURL(+            session: session, parseRevision: session.parseRevision+        )+        controller.handleProcessTermination(documentURL: documentURL)++        // Recovery re-queues the coalesced snapshot, fragment target included.+        #expect(+            controller.pendingCommands.contains(.scrollToBlock(domID: anchorID)),+            "the undelivered fragment target must be replayed to the recovered page"+        )++        // The reloading document offers the saved position again — it must still+        // lose, because the fragment scroll is STILL undelivered after recovery.+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)+        #expect(+            controller.latestSnapshot.scrollTargetBlockID == anchorID,+            "the claim must survive recovery, so the saved position still cannot win"+        )++        // The recovered page comes up and takes the replayed fragment scroll.+        controller.test_markReady()+        controller.test_markLayoutSettled()++        // Delivery releases the claim: a later reload restores the reading position.+        let newPosition = domIDs(for: session)[3]+        session.scrollPositionID = newPosition+        controller.load(documentURL: documentURL, parseRevision: session.parseRevision)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)+        #expect(+            controller.latestSnapshot.scrollTargetBlockID == newPosition,+            "once the recovered page took the fragment, restores must work again"+        )+    }+}
prismTests/WebRendering/WebScrollPositionRetentionTests.swift Modified +5 / -6
diff --git a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift b/prismTests/WebRendering/WebScrollPositionRetentionTests.swiftindex 449e8c7..93f8b8f 100644--- a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift+++ b/prismTests/WebRendering/WebScrollPositionRetentionTests.swift@@ -264,16 +264,15 @@ struct WebScrollPositionRetentionTests {             router.handle(message)         } -        // The exact open sequence DocumentScrollContent runs: load, then queue-        // the restore (the controller holds scrollToBlock until layoutSettled).+        // The exact open sequence DocumentScrollContent runs: load, then queue the+        // restore through the shared seam (the controller holds scrollToBlock until+        // layoutSettled). Routing through the real seam keeps this — the only test+        // that asserts a live page actually moved — covering `restoreScroll` (T-1775).         let documentURL = WebDocumentControllerFactory.documentURL(             session: session, parseRevision: session.parseRevision         )         controller.load(documentURL: documentURL, parseRevision: session.parseRevision)-        let resolvedID = try #require(BlockDOMID.restoreDOMID(-            forStored: session.scrollPositionID, blocks: session.parsedBlocks-        ))-        controller.scrollTo(blockID: resolvedID)+        WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session)          // Wait for the full cadence: ready → layoutSettled → restore scroll →         // 600ms suppression release → settled visibleBlock (120ms debounce).
docs/agent-notes/scroll-persistence.md Modified +33 / -5
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex 90a2e8e..3307d74 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -29,12 +29,40 @@ The document body renders in a WebView, so the scroll contract has two halves:   `coordinator.scrollPercentage` (the raw toggle's snapshot source). Dropping   either half regresses T-1639. - **Native → JS (restore):** `DocumentScrollContent` calls-  `webController.scrollTo(blockID:)` right after every `webController.load(...)`-  (initial open, raw→rendered remount, re-parse reload, iOS image-access-  reload). The controller holds `scrollToBlock` until the page posts-  `layoutSettled`. The stored id goes through+  `WebDocumentControllerFactory.restoreScrollPosition(to:session:)` right after+  every `webController.load(...)` (initial open, raw→rendered remount, re-parse+  reload, iOS image-access reload). That seam resolves the id and calls+  `webController.restoreScroll(blockID:)` — **not** `scrollTo`, see the+  precedence rule below. The controller holds `scrollToBlock` until the page+  posts `layoutSettled`. The stored id goes through   `BlockDOMID.restoreDOMID(forStored:blocks:)`, which passes DOM-format ids   through and migrates legacy pre-cutover composite ids (`{hash}-{sourceIndex}`).+- **Navigation outranks restore (T-1775):** `scrollTo` (navigation: TOC entry,+  cross-document `#fragment`, note target, search match) and `restoreScroll`+  (saved position) both emit the same `scrollToBlock` command and fold into the+  snapshot's single `scrollTargetBlockID`, so precedence cannot be inferred from+  the payload — it comes from which entry point was used. The controller records+  an undelivered navigation target and `restoreScroll` no-ops while one is+  outstanding; the claim clears when that exact target is handed to the bridge,+  so it can span several loads (the snapshot replays it) but never outlives+  delivery. A skipped restore is **dropped**, not deferred or retried.+  Why this is needed at all: since T-1681 the load task `await`s+  `precomputeDocumentHTML` *before* `load` + restore, and during that suspension+  the synchronizer consumes `pendingAnchorScroll` and queues the navigation — so+  the restore now routinely runs last. Pre-T-1681 it happened to run first, and+  the right outcome was never actually enforced.+- **A remount is not a navigation (T-1775):** only a *user* navigation may raise+  the claim. `pendingAnchorScroll` / `noteNavigationTarget` are genuine one-shot+  events the session hands over once, but the search-match scroll is an **edge on+  derived level state** — `WebDocumentStateSynchronizer` diffs+  `session.currentMatch`'s DOM id against the per-instance+  `lastSearchScrollDOMID`. A synchronizer is rebuilt per mount, so on a+  raw→rendered toggle with a search still active the unseeded `nil → matchID`+  diff looked exactly like a fresh navigation: it raised the claim before the+  load task ran and dropped the position `toggleRawSource` had just written.+  `start()` therefore **seeds** `lastSearchScrollDOMID` from the session before+  the first pass. Any new navigation domain added to the synchronizer must decide+  which of the two it is; level-state domains need the same seeding. - **Non-laid-out sections must be filtered out of the scan (T-1851):**   `topmostBlockID()` picks the section with the greatest `rect.top <= 1`. A   `display:none` element's rect is **all zeros**, so its `top` of 0 beats every@@ -75,7 +103,7 @@ The document body renders in a WebView, so the scroll contract has two halves: ## Key Design Decisions  - Clipboard sessions are excluded from `ScrollPositionStore` to avoid double-persistence (they already have `StatePersistence`)-- Restore happens before parse so `scrollPositionID` is available when the web load task fires `webController.scrollTo(blockID:)`+- Restore happens before parse so `scrollPositionID` is available when the web load task fires `WebDocumentControllerFactory.restoreScrollPosition(to:session:)` - `session.scrollPositionID` holds the **occurrence-qualified DOM id** on the web path; every producer (`visibleBlock`, `applyScrollPositionID`) writes that format so the page's `getElementById` can resolve it - `DocumentIdentifierResolver` is instantiated per-call in `persistScrollPosition()`/`restoreScrollPosition()` — it's a lightweight value type 
specs/bugfixes/webkit-state-integration/report.md Modified +13 / -4
diff --git a/specs/bugfixes/webkit-state-integration/report.md b/specs/bugfixes/webkit-state-integration/report.mdindex da2ac51..c6c5e81 100644--- a/specs/bugfixes/webkit-state-integration/report.md+++ b/specs/bugfixes/webkit-state-integration/report.md@@ -259,9 +259,15 @@ scheme. Traced producers: - In-document `#fragment` links — `WebDocumentMessageRouter.swift:296` calls   `session.scrollToAnchor(_:)`, which resolves the slug against the ToC and sets   the same `pendingAnchorScroll` (`DocumentSession.swift:359`).-- Restore-from-recent uses the same field.--All of them are consumed by `WebDocumentStateSynchronizer.scrollToTarget`, which+- Restore-from-recent does **not** use this field (corrected under T-1775): the+  saved reading position lives in `session.scrollPositionID`, resolves through+  `BlockDOMID.restoreDOMID` rather than `navigationDOMID`, and reaches the page+  via `WebDocumentControllerFactory.restoreScrollPosition(to:session:)` →+  `WebDocumentController.restoreScroll`. It is a separate path, and it+  deliberately yields to an undelivered navigation target.++The navigation targets above are consumed by+`WebDocumentStateSynchronizer.scrollToTarget`, which routes through `BlockDOMID.navigationDOMID` — the seam that verifies composite `{hash}-{sourceIndex}` ids, resolves bare hashes to a visible first occurrence, maps sub-block ids to their parent, and drops stale targets. Covered by@@ -373,5 +379,8 @@ sensitive to the defect, so the four that flip are pinning it specifically. - Transit T-1719 (this fix), T-1680 (search highlights — parallel, owns   `setSearchState`), T-1662 (TOC/fragment DOM ids — superseded in part by the   translation seam here), T-1639 (scroll restore, introduced `restoreDOMID`),-  T-1542 (the WebKit cutover).+  T-1681 (off-main HTML emit — added the suspension that reordered navigation+  against restore), T-1775 (navigation now outranks restore; corrected the+  "restore-from-recent uses the same field" claim above), T-1542 (the WebKit+  cutover). - `specs/webview-rendering/` (spec), `docs/agent-notes/webview-rendering-status.md`.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 81bb239..4d6b4c3 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed  - The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers keep the standard palette.+- Following a link to a specific heading in a document you have read before now lands on that heading instead of your saved reading position (T-1775). The requested heading was queued while the document's HTML was being prepared in the background, and the saved position — restored a moment later — replaced it, so the page settled where you last stopped reading. An explicit heading target now takes precedence for that load, while ordinary reopens still restore where you left off. - Adding a note to text selected after a footnote badge now quotes the text you actually selected (T-1876). In a paragraph like `before[^1] after`, selecting `after` quoted words from near the start of the paragraph instead, and saving stored a wrong source range, which the note then carried into relocation and inline-note export. The rendered document's text-to-source map now keeps its offsets in the block's own coordinates across any number of footnote badges. Footnotes inside list items and table cells are tracked separately. - The saved reading position is no longer corrupted by content the document does not show (T-1851). Hidden content measures as sitting exactly at the top of the window, which beat every genuinely visible block, so the document reported a block the reader could not see as the block being read. That happened with any heading collapsed, and — because the carrier holding a document's YAML frontmatter is hidden the same way — on every document that starts with frontmatter, collapsed or not. Reopening the document, returning from raw source, or recovering from a rendering-process restart then landed on the wrong block or did nothing at all. Content the document does not lay out is now skipped when working out the reading position. - Opening or reloading a large or HTML-heavy document no longer freezes the UI (T-1681). Since the WebKit rendering cutover, the full document HTML — including a SwiftSoup sanitisation pass per raw-HTML block and per inline HTML run — was built synchronously on the main thread on every serve, so a big or markup-dense document blocked the app while it rendered, and re-built the HTML on every reload. The build now runs off the main thread and its result is cached per parse: the UI stays responsive, and reloads (external file change, URL refresh, WebContent-process recovery, the iOS folder-access retry) reuse the cached HTML instead of re-emitting. Very HTML-dense documents can still take a noticeable moment to appear; making that incremental is tracked separately.

Things to double-check

The three search tests — re-examined for pinned-regression-as-intent.

This is the failure mode that made the previous artifact wrong, so all eight tests were re-read with fresh eyes. The discriminator is principled and the three search tests are mutually consistent:

  • searchMatchNavigationOutranksSavedPosition — seed is nil (no match at mount); a real user step produces nil→matchID; match wins. Correct.
  • remountWithActiveSearchStillRestoresSavedPosition — seed is matchID (from session); no diff; restore wins. This is the one that was inverted before 64c5c75; it now asserts the correct outcome.
  • searchNavigationAfterRemountStillOutranksRestore — seed is match₀; a real user step produces match₀→matchₙ; match wins. Correct — proves seeding does not disarm navigation for the rest of the mount.

No remaining assertion pins an outcome a user would experience as a bug.

Assertion altitude.

The new tests assert on controller.latestSnapshot.scrollTargetBlockID and controller.pendingCommands — controller-internal state, one level below observable behaviour. Justified here (no live WebPage, and latestSnapshot is precisely what a load/recovery replays), and backstopped by WebScrollPositionRetentionTests's live-page test, which the diff correctly retargeted onto the new restoreScroll seam. Worth re-checking if the snapshot representation ever changes.

Test-suite hygiene — nothing new leaked into production.

test_markReady / test_markLayoutSettled (WebDocumentController.swift:514-515) and setActiveSearchQueryForTesting (SearchCoordinator.swift:442) are all pre-existing and untouched by this diff. The branch adds no test-only production hooks.

Parallel-test noise is pre-existing, not this branch.

WebScrollPositionRetentionTests fails under xcodebuild parallel testing even when run entirely alone (activatingAnotherSessionPersistsOutgoingPosition, closeDocumentPersistsPosition, renderedToRawPersistsReportedPosition, all at roughly 0.002 s), and passes 10/10 with -parallel-testing-enabled NO. Both suites already use UUID-unique fixture paths, so the contention is on the shared ScrollPositionStore / UserDefaults across worker processes. The failing tests do not touch the restore seam. Worth a separate cleanup ticket, not a blocker here.

CI is not validation on this repo.

PR #322 is MERGEABLE / CLEAN with four green checks — but they are File Checks, SwiftLint, Stylelint and the locale sweep. Nothing in CI builds the app or runs the test suite. Local verification is the only signal: make lint 0 violations, make build-ios and make build-macos both succeed (one pre-existing ImageDimension Swift-6 conformance warning, unrelated to this diff), 84 tests across 8 web suites pass serially.

Out of scope, already filed.

handleProcessTermination has no production caller — no live WebPage termination signal is wired to it — so WebContent recovery never actually runs in production (Transit T-1943). The branch's termination test scopes itself honestly to the controller contract rather than claiming production coverage. Pre-existing; not this PR's to fix.