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.
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.start() seeding only covers remounts, not re-parses.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.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.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.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.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.
90084b1 Fix T-1775: Fragment navigation overwritten by persisted scroll 7c1ef6b Address PR #322 review: doc wording and termination-path test bbc4e21 T-1775: Key the navigation claim on its target, and pin search precedence f05122c Merge remote-tracking branch 'origin/main' e5c1e97 T-1775: Remove conflict markers left in the scroll-persistence note 64c5c75 T-1775: Don't let a synchronizer remount pose as a search navigation e262dea Merge remote-tracking branch 'origin/main' (024f3547) working-tree Stale-comment fix applied in this review 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.
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 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.
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.
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.
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.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".WebDocumentControllerFactory.restoreScrollPosition(to:session:) owns the id resolution (BlockDOMID.restoreDOMID) and the restoreScroll call; DocumentScrollContent routes both its load paths through it.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.
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.
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.
scrollTo can clear the claim on the straight-through pathundeliveredNavigationTarget = 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.
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.
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.
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.
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)
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)
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
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
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)
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.
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.
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.
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.
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.
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.)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 openRecentFile → session.restoreScrollPosition() → scrollPositionID → restoreDOMID → restoreScroll). Recent bugfixes in this repo are split roughly evenly on whether they add a report, so this is within convention.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | WebDocumentStateSynchronizer — re-parse reload | A 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. |
| major | WebDocumentController.swift:96-98 — stale docstring | The 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. |
| minor | Test-helper duplication | WebFragmentNavigationPrecedenceTests.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. |
| minor | Untested branch — the id-matching clear | The 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. |
| minor | In-page search auto-scroll on remount | On 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. |
| minor | CHANGELOG scope | The 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. |
| minor | Docs — CLAUDE.md and the 'never outlives delivery' claim | CLAUDE.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)'. |
| nit | Docs — attribution and enumeration slips | scroll-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). |
| nit | Naming foot-gun and a pass-through wrapper | scrollTo 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. |
| nit | BlockDOMID.restoreDOMID — wasted mapping walk | BlockDOMID.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. |
| nit | Test comment fidelity | WebFragmentNavigationPrecedenceTests.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). |
Click to expand.
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)) }
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 {
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
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
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"+ )+ }+}
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).
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
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`.
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.
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.
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_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.
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.
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.
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.