PR #337 — resolves the dual search-scroll ownership: prism-search.js becomes the single scroll owner, native declares intent via a reveal flag classified by WebSearchStateKey.isNavigation plus a SearchCoordinator.navigationNonce, and the controller keeps T-1775 restore precedence through a second claim (hasUndeliveredSearchReveal). ADR: Decision 8 in specs/search/decision_log.md.
prism-search.js scrolls the match's range to the viewport third only on reveal: true; the synchronizer's search branch (and its three-rounds-of-T-1775 seeding) is deleted outright.reveal: false; isNavigation vetoes any parse-revision change — remount/re-parse can no longer fake a navigation by construction.hasUndeliveredSearchReveal, blocks restoreScroll, survives loads, and replays reveal-carrying and last — mirroring undeliveredNavigationTarget.Ready to push
The single-owner inversion is implemented exactly as Decision 8 describes, the deleted synchronizer seeding machinery leaves no stale references, and the claim/replay logic in the controller was verified line-by-line against the snapshot and flush ordering. All 73 targeted tests pass (WebSearchScrollOwnershipTests, WebFragmentNavigationPrecedenceTests, WebDocumentControllerTests, WebSearchWiringTests, WebStateSynchronizerAssemblyTests), SwiftLint is clean. The two residual edge cases found are timing-window curiosities that are self-healing and documented, not fix-worthy.
36a4ce4 T-1918: Add failing regression tests for search-scroll single ownership 1fba290 Fix T-1918: Search-match scrolling gets a single owner (prism-search.js reveal) 00ec295 T-1918: Re-reveal same-index search navigations and add the CHANGELOG entry b60921c T-1918: Pin the navigation-nonce provenance invariant and document the revision-veto race When you press “next match” in search, the app scrolls the page so the match is visible. Before this fix, two separate pieces of code both tried to do that scroll: one native (Swift) part scrolled to the top of the paragraph containing the match, and one inside the web page (JavaScript) scrolled the exact matched words to about a third of the way down the window. They raced — the page could visibly jump twice, and which position stuck depended on timing. If the paragraph was taller than the window, the paragraph-top scroll could leave the match below the bottom edge, invisible.
Now only the JavaScript side scrolls. The native side attaches a flag — reveal: true — to the search data it sends, meaning “the user just navigated, bring the match into view”. Updates that are not a user navigation (page rebuilt, file reloaded) send reveal: false and repaint highlights without moving the page.
prism-search.js: applyState(state, reveal) scrolls only when reveal is true; the current match's section bypasses the ±2000px highlight window.WebBridgeContract: setSearchState(json:reveal:); a reveal-carrying push requiresLayoutSettled, so queue order does the precedence work.SearchCoordinator: navigationNonce bumped only by the three navigate methods after their guards.DocumentScrollContent / WebSearchStateKey: the key gains the nonce; isNavigation(from:to:) = changed index, query, or nonce within one parseRevision, non-empty query. The .onChange derives reveal from the old/new diff.WebDocumentController: hasUndeliveredSearchReveal mirrors undeliveredNavigationTarget — raised when a reveal cannot dispatch, blocks restoreScroll, released on dispatch; snapshot replay strips the reveal unless the claim is up, in which case the push is re-queued reveal-carrying and last.WebDocumentStateSynchronizer: the search-scroll branch and its per-mount/per-parse seeding are deleted.Ownership is inverted: native issues intent, the JS owner acts on it — only JS can resolve the match's range geometry (native addresses blocks; block-top cannot centre a match inside a tall block). The T-1775 “a remount/re-parse is not a navigation” rule becomes structural instead of seeded: the mount push hardcodes reveal: false and the revision veto handles re-parses. The nonce closes the same-index hole (a single-match “next” leaves query/counts/index all equal — without it no push would fire at all).
setSearchState computes hasUndeliveredSearchReveal = !canDispatch(command) before send; a synchronous dispatch makes the release in dispatch a no-op on an already-false flag. resetForNavigation clears the queue but not the claims, so both survive load/recovery.coalescedCommands() emits the stripped search push and scrollToBlock last; scheduleSnapshotReplay removes the stripped push and appends a reveal-carrying one after the restore target while the claim is up. Both gate on layoutSettled and flushPending preserves order, so the reveal's scroll lands last — T-1775 precedence without a shared code path. The claim can only be up after a send, so the latestSnapshot.searchStateJSON guard never drops it.renderHighlights admits the current section regardless of sectionInWindow, and admits textCount <= 0 entries whose current is text-kind. Badge-kind currents were never windowed. scrollCurrentIntoView no-ops when the range is already fully on-screen. The scroll re-window handler calls renderHighlights, not applyState, so re-windowing can never scroll.onlyNavigateMethodsBumpTheNonce pins every non-navigation mutator (recomputes, the cursor-resetting visibility recompute, the clamp path, query re-set, clearSearch) at baseline, then each navigate method's single bump, then the failed-guard cases. The coordinator is @Observable, so the nonce read in the key registers and .onChange fires.The synchronizer sheds its only “edge on derived level state” domain; every remaining domain is level state or a genuine one-shot event, simplifying the contract for future domains. The bridge now carries intent alongside state in one command with the snapshot storing only the state half — the pattern to copy if another command needs delivery-scoped intent.
applyState treats empty query as a clear, so no ghost scroll. A restore in that window is dropped (see findings).onMatchSelected → expandAncestorsForCurrentMatch expansion races the reveal in the DOM, which is part of why it stays open.prism/Resources/WebRenderer/prism-search.js
Why it matters. This is the single scroll owner now. The reveal gate is what stops replays/remounts from moving the reading position, and the window bypass is what makes far matches reachable at all (the old code had no range to scroll to outside ±2000px).
What to look at. applyState(state, reveal), renderHighlights lines 313-318, scrollCurrentIntoView
prism/Views/DocumentScrollContent.swift
Why it matters. Replaces the synchronizer's per-instance diff seeding that took three T-1775 regression rounds. The revision veto makes 'a re-parse is not a navigation' impossible to get wrong rather than guarded.
What to look at. DocumentScrollContent.swift:358-364 (isNavigation), .onChange at line 192
prism/Services/SearchCoordinator.swift
Why it matters. Without it, a single-match “next” (wrap-around to the same index) changes no key field — no push fires, and the match cannot be brought back after the reader scrolls away. Its provenance (only the three navigate methods bump it) is the invariant the classification rests on.
What to look at. SearchCoordinator.swift:86-96 (declaration), bumps in the three navigate methods
prism/ViewModels/WebDocumentController.swift
Why it matters. Search left the scrollTo path, so the T-1775 precedence (user navigation outranks stored-position restore) needed a new carrier. The claim mirrors undeliveredNavigationTarget's raise/release rules exactly, and the replay re-queues an undelivered reveal last so its scroll wins on the recovered page.
What to look at. WebDocumentController.swift:105-120 (claim), 304-310 (release), 402-409 (raise), 551-569 (replay)
prism/ViewModels/WebDocumentStateSynchronizer.swift
Why it matters. The other half of single ownership: lastSearchScrollDOMID, lastSeededParseRevision, seedSearchScrollBaseline, and currentSearchMatchDOMID are all gone (verified: zero remaining references anywhere). The synchronizer no longer scrolls for search under any input.
What to look at. WebDocumentStateSynchronizer.swift — deletions throughout; tombstone comment at the old dispatch site
prismTests/WebRendering/WebSearchScrollOwnershipTests.swift
Why it matters. 484 lines pinning every leg: live-page viewport-third geometry (including the far-match window bypass), the full isNavigation truth table, nonce provenance against every non-navigation mutator, the claim's raise/release/replay, and the synchronizer's non-ownership.
What to look at. prismTests/WebRendering/WebSearchScrollOwnershipTests.swift (new file)
Native declares intent (reveal) instead of scrolling. Alternatives rejected in the ADR: keep-native-drop-JS (native cannot centre a range and would need intra-block ordinals shipped to JS anyway) and keep-both-layered (the status quo's accidental behaviour made official, double-jump remains).
A user navigation that coalesces with a re-parse into one SwiftUI update loses its reveal: the push carries both the nonce bump and the revision change, and the veto wins — the match highlights but does not scroll. Documented as a negative consequence in Decision 8: inherited from the T-1775 rule (a re-parse must never masquerade as a navigation) and self-healing on the next navigation.
It scrolls, so it must measure settled geometry — and gating it there makes queue order do the precedence work: a restore queued before it dispatches first, the reveal's scroll lands last (T-1775 ordering without a shared code path). Stated in the WebBridgeContract doc comment.
A new call site must state its intent rather than silently never (or always) scrolling — explicitly following the T-1829 un-defaulted-parameter rule already established for this codebase.
clearSearch, recomputes, and clamps leave it untouched so none can masquerade as a navigation; the empty-query guard in isNavigation is what vetoes clears. &+= on UInt64 makes overflow defined (only inequality is ever read).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | WebDocumentController claim lifecycle | A reveal queued while the page is loading is not released if the user then clears the search: the clearing push is reveal-false and leaves the claim up, so a stored-position restore arriving in that window is dropped, and the replay re-queues a reveal carrying the now-empty search JSON. | Verified harmless: applyState treats an empty query as a clear and never scrolls, and the dispatch releases the claim — self-healing, no ghost jump, only a dropped restore in a sub-second timing window. Adding a release rule to the clearing push would complicate the claim for negligible benefit. Documented in the review instead. |
| minor | scheduleSnapshotReplay dual-claim ordering | If both undeliveredNavigationTarget (TOC/fragment/note) and hasUndeliveredSearchReveal are up across one load, the replay always orders the reveal after the restore/navigation target, so the search scroll wins regardless of which navigation the user performed last. | Both are user navigations and recency information is genuinely lost by the coalesced-snapshot design; picking a fixed winner is consistent with the snapshot being state, not history. Noted for the double-check list; not a fix. |
| nit | Code reuse / quality / efficiency passes | No duplication (the claim deliberately mirrors the existing undeliveredNavigationTarget pattern and is documented side by side); no stringly-typed or parameter-sprawl issues (the un-defaulted reveal is a deliberate rule); the window bypass adds one extra section's textNodeMap walk per push, bounded and off the hot path. | Nothing to change. |
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b51cadf..878195c 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stepping to a search match now scrolls there in one motion and leaves the match itself in view (T-1918). Two parts of the app both moved the page on the same step — one lining the top of the match's block up with the top of the window, the other placing the match about a third of the way down — so the page could visibly jump twice, and which position stuck depended on timing. When the block was taller than the window, the block-top alignment could even settle with the match below the fold. One owner scrolls now, resting the match about a third of the way down the window every time. Two more gaps closed with it: stepping to a match far outside the part of the document being highlighted found no highlight to scroll to and relied on the other, block-based jump to get anywhere near it; and with a single match found, pressing next or previous after scrolling away to read something else did nothing — it now brings the match back into view. A match inside a collapsed section still does not scroll; that remains tracked separately. - Searching for text that lives in a footnote's definition now highlights and scrolls to the right reference badge when the same footnote is referenced more than once (T-1853). Badges were looked up document-wide by footnote number, so stepping to the match belonging to a later reference always marked and scrolled to the document's first badge — the one actually selected never even showed as matched. Each reference's badge is now resolved within its own block, and repeated references inside a single block are told apart by position, so stepping through matches visits each badge in turn. Text that merely looks like a reference — `` `[^1]` `` written in inline code — still occupies its place in the match order but renders no badge, so stepping onto it marks the nearest following badge in the block, or the last one when none follows. - Search highlights survive a reload that rewords the matching text (T-1751). If a file changed on disk — or a URL document was refreshed — while a search was active, and the edit reworded the matching passages without changing how many matches each block had, the reloaded page showed stale highlights or none at all, while the match counter and navigation stayed correct. Highlights are addressed to blocks by their content, which the rewrite changed, but the trigger that re-sends them only watched the match numbers, which the rewrite did not. The trigger now watches block identity as well, so the reloaded page is sent highlights that address the blocks it actually shows. - Writing a footnote reference so readers can see it now shows it (T-1716). `` `[^1]` `` in inline code came out as an empty code span followed by a tappable footnote badge, so a document explaining footnote syntax could not display it — and the text on screen no longer matched the text you could select or copy. Inline code now renders the reference exactly as written, and so does the other way of writing one literally, `\[\^1\]`, whichever order the two forms appear in and however closely they sit together. Two more faults went with the same change: text after a reference followed by four or more spaces (`word[^1] more`) was silently dropped (T-1945), and emphasis wrapped around a reference (`*em [^1] end*`) leaked literal asterisks instead of italicising. The space separating a badge from the following word is rendered again too, so selecting the text after a badge selects what you see. A reference written inside a link address, inside an image's alt text, or as HTML character references (`[^1]`) stays literal text as well. A link whose caption contains a reference — `[the citation [^1]](https://example.com)` — now renders as a working link showing the reference as written: a badge cannot go there, because a footnote badge is itself a link and one link cannot sit inside another. Before this release that caption was not a link at all; the reference broke it into plain text either side of a badge. Footnotes inside list items and table cells still carry the separate note-anchoring limitation tracked under T-1941.
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex 3077861..3b7515b 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -38,51 +38,43 @@ The document body renders in a WebView, so the scroll contract has two halves: `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.+ cross-document `#fragment`, note target) 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.+- **Search matches take a different route (T-1918, search Decision 8):** search+ scrolling never goes through `scrollTo` any more — its single owner is the+ reveal-carrying `setSearchState` push (prism-search.js scrolls the match's+ RANGE to the viewport third, Req 6.3). The same precedence holds via a second+ controller claim, `hasUndeliveredSearchReveal`: raised when a `reveal: true`+ push cannot dispatch yet, blocks `restoreScroll` exactly like the target claim,+ released when the reveal command is handed to the bridge. On load/recovery the+ snapshot replays search state with the reveal stripped — unless it is still+ undelivered, in which case it is re-queued reveal-carrying and LAST (after the+ restore target) so its scroll still wins. - **Neither a remount nor a re-parse is 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`. That id moves for two reasons that are not the reader:- - **A new mount.** 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.- - **A new parse.** `DocumentScrollContent` mounts the assembly with- `.task(id: session.id)`, so the synchronizer and its baseline **survive a- re-parse** — per-mount seeding alone does not cover it. On an external- file-change reload or a URL refresh with a search active,- `parsedBlocks.didSet` re-runs `recomputeMatchCounts()`, whose- `clampCurrentMatchIndex()` keeps a still-valid match index while the block- that index resolves to — and so its content-hash DOM id — moves. Same false- navigation, same silently dropped restore, and a behaviour change versus the- pre-claim code, where the restore won. (The iOS image-access reload does not- bump `parseRevision`, so it is unaffected.)-- The baseline is therefore keyed on `lastSeededParseRevision` and **re-seeded**- — silently, without dispatching — by `start()` and again by the first- `dispatch` of each new parse revision. Everything else still diffs: stepping to- a different match within one mount *and* one parse remains a real navigation- that outranks the restore (pinned by- `searchNavigationAfterRemountStillOutranksRestore` and- `searchNavigationAfterReparseStillOutranksRestore`; a navigation landing in the- same turn as a re-parse folds into the re-seed, which is the safe direction).- Any new navigation domain added to the synchronizer must decide which of the- two it is; level-state domains need the same per-mount **and** per-parse- seeding.+ navigation may raise either claim. `pendingAnchorScroll` /+ `noteNavigationTarget` are genuine one-shot events the session hands over+ once. For search this used to be a fragile per-instance DOM-id diff in the+ synchronizer needing per-mount AND per-parse re-seeding (a remount's unseeded+ `nil → matchID` diff, and a re-parse whose `clampCurrentMatchIndex()` keeps+ the index while the block's content-hash DOM id moves, both read as false+ navigations and dropped the restore). Since T-1918 the classification is+ structural: the mount-time push passes `reveal: false` by construction, and+ `WebSearchStateKey.isNavigation(from:to:)` vetoes any change whose+ `parseRevision` moved — only a changed match index or query within one parse+ reveals. (The iOS image-access reload does not bump `parseRevision`, so it was+ never affected.) Pinned by `WebFragmentNavigationPrecedenceTests` and+ `WebSearchScrollOwnershipTests`. - **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
diff --git a/docs/agent-notes/search.md b/docs/agent-notes/search.mdindex 9b5bf06..ee1145b 100644--- a/docs/agent-notes/search.md+++ b/docs/agent-notes/search.md@@ -26,7 +26,7 @@ SearchCoordinator (activeSearchQuery / matchCountsPerBlock / currentGlobalMatchI ``` - `DocumentScrollContent` also pushes once at controller assembly so a search active at (re)mount renders, and the payload replays after WebContent recovery via the coalesced snapshot.-- `prism-search.js` scrolls the pushed current match into view (Req 6.2/6.3) — there is no separate native scroll command for search matches; the layouts' old ScrollViewProxy search-scroll closures are dead code (T-1719's seam).+- **Scrolling has ONE owner (T-1918, search Decision 8)**: `prism-search.js` scrolls the current match to the viewport third (Req 6.3), but only when the push carries `reveal: true` — navigation intent computed natively by `WebSearchStateKey.isNavigation(from:to:)` in the `.onChange` (changed index/query — or a bumped `SearchCoordinator.navigationNonce`, which covers a navigation resolving to the SAME index, e.g. a single-match "next" after scrolling away — within one parseRevision; a re-parse or remount is structurally NOT a navigation, replacing the old synchronizer seeding). The synchronizer has no search branch; `scrollTo(blockID:)` is never used for search. The current match's section bypasses the ±2000px highlight window so far matches are reachable. An undelivered reveal blocks the stored-position restore (`hasUndeliveredSearchReveal`, mirror of `undeliveredNavigationTarget`, T-1775) and is re-queued last on replay; delivered reveals replay as `reveal: false`. Regression suite: `WebSearchScrollOwnershipTests`. Still open: a match inside a collapsed (zero-rect) section silently fails to scroll (T-1944). - Clearing the search pushes `{query:"", blocks:{}}`, which clears the page's highlights. - Regression guard: `prismTests/WebRendering/WebSearchWiringTests.swift` drives the real production assembly + `prism-doc://` load and asserts highlights are registered. If a state push is added to `DocumentScrollContent`'s assembly, mirror it in that test's `assembleProductionController`.
diff --git a/prism/Resources/WebRenderer/prism-search.js b/prism/Resources/WebRenderer/prism-search.jsindex 5dc8f33..e951b88 100644--- a/prism/Resources/WebRenderer/prism-search.js+++ b/prism/Resources/WebRenderer/prism-search.js@@ -14,9 +14,16 @@ * single current match). Highlights are WINDOWED to the viewport ± a margin and * re-windowed on scroll, because registering thousands of global ranges is not viable * (design "Search rendering"); every match is highlighted whenever it can be on screen- * (the deliberate satisfaction of Req 6.2). The query spans formatting boundaries- * because matching runs over the block's concatenated text content, not per element- * (Req 6.4).+ * (the deliberate satisfaction of Req 6.2). The CURRENT match's section bypasses the+ * window (T-1918/T-1839): its range must exist however far away it is, or navigation+ * could not reach it. The query spans formatting boundaries because matching runs+ * over the block's concatenated text content, not per element (Req 6.4).+ *+ * This script is the SINGLE owner of search-match scrolling (T-1918). Native declares+ * intent: a push whose payload carries `reveal: true` is a user search navigation and+ * scrolls the current match to the viewport third (Req 6.3 approximate centring);+ * replay/remount/re-parse pushes carry false and re-render highlights without moving+ * the reading position. No other component scrolls for search. * * Footnote-content matches are NOT text-highlighted (the appended footnote text has no * rendered equivalent in the host block); they are indicated on the footnote badge via@@ -302,7 +309,13 @@ continue; } var section = document.getElementById(domID);- if (!section || !sectionInWindow(section)) { continue; }+ if (!section) { continue; }+ // The current match's section is ALWAYS resolved, however far outside+ // the viewport window it lies (T-1918/T-1839): navigation needs its+ // range to exist so the reveal scroll has a target. Windowing stays+ // for every other section — it only bounds highlight volume.+ var holdsCurrentText = entry.current && entry.current.kind === "text";+ if (!holdsCurrentText && !sectionInWindow(section)) { continue; } var map = textNodeMap(section); var matches = findMatchRanges(map.text, state.query);@@ -321,7 +334,10 @@ } } - // Scrolls the current match (text range or footnote badge) into view (Req 6.2).+ // Scrolls the current match (text range or footnote badge) into view — only on+ // a reveal push, i.e. a user search navigation (Req 6.2/6.3, T-1918). The text+ // range rests at the viewport third: the approximate centring Req 6.3 asks for,+ // and a geometry only this side can produce (native knows blocks, not ranges). function scrollCurrentIntoView(state) { if (currentRangeForScroll) { var rect = currentRangeForScroll.getBoundingClientRect();@@ -345,7 +361,7 @@ } } - function applyState(state) {+ function applyState(state, reveal) { activeState = state; clearBadges(); if (!state || !state.query) {@@ -354,7 +370,10 @@ } renderBadges(state); renderHighlights(state);- scrollCurrentIntoView(state);+ // Only a user navigation moves the viewport (T-1918): a remount, re-parse,+ // or recovery replay re-renders highlights in place, so the stored reading+ // position — restored by native's scrollToBlock — is never clobbered.+ if (reveal) { scrollCurrentIntoView(state); } } // ---- setSearchState command ------------------------------------------@@ -370,12 +389,13 @@ bridge.registerCommand("setSearchState", function (payload) { var state = parsePayloadJSON(payload && payload.state);+ var reveal = !!(payload && payload.reveal === true); if (!state || typeof state !== "object") {- applyState({ query: "", blocks: {} });+ applyState({ query: "", blocks: {} }, false); return true; } if (!state.blocks) { state.blocks = {}; }- applyState(state);+ applyState(state, reveal); return true; });
diff --git a/prism/Services/SearchCoordinator.swift b/prism/Services/SearchCoordinator.swiftindex aee354d..fdab4a0 100644--- a/prism/Services/SearchCoordinator.swift+++ b/prism/Services/SearchCoordinator.swift@@ -83,6 +83,17 @@ final class SearchCoordinator: SearchActions { /// The globally selected match index (0-based across all matches). var currentGlobalMatchIndex: Int? + /// Monotonic count of user search navigations (T-1918). Bumped by the three+ /// navigate methods — and nothing else — every time they actually select a+ /// match, including a wrap-around that lands on the index already selected+ /// (a single-match "next"). `WebSearchStateKey` carries it so that such a+ /// same-index navigation still changes the push key (firing the view's+ /// `.onChange`) and classifies as a navigation, re-revealing the current+ /// match after the reader scrolled away. Never reset: recomputes, clamps,+ /// re-parses, and `clearSearch` leave it untouched, so none of them can+ /// masquerade as a user navigation.+ private(set) var navigationNonce: UInt64 = 0+ /// Tracks whether VoiceOver has announced results for the current query. private var hasAnnouncedResultsForQuery: Bool = false @@ -159,6 +170,7 @@ final class SearchCoordinator: SearchActions { } else { currentGlobalMatchIndex = 0 }+ navigationNonce &+= 1 onMatchSelected() announceCurrentMatchPosition() }@@ -177,6 +189,7 @@ final class SearchCoordinator: SearchActions { } else { currentGlobalMatchIndex = total - 1 }+ navigationNonce &+= 1 onMatchSelected() announceCurrentMatchPosition() }@@ -191,6 +204,7 @@ final class SearchCoordinator: SearchActions { let total = totalMatchCount guard index >= 0, index < total else { return } currentGlobalMatchIndex = index+ navigationNonce &+= 1 onMatchSelected() announceCurrentMatchPosition() }
diff --git a/prism/ViewModels/WebBridgeContract.swift b/prism/ViewModels/WebBridgeContract.swiftindex d9b5b1d..8cb2def 100644--- a/prism/ViewModels/WebBridgeContract.swift+++ b/prism/ViewModels/WebBridgeContract.swift@@ -194,7 +194,12 @@ enum OutboundBridgeCommand: Equatable, Sendable { ) case applyTypography(variables: [String: String]) case setCommentVisibility(Bool)- case setSearchState(json: String)+ /// Search-highlight state (T-1680). `reveal` is navigation INTENT (T-1918):+ /// true exactly when the push is a user search navigation (query typed /+ /// match stepped), in which case prism-search.js — the single scroll owner+ /// for search matches — brings the current match to the viewport third+ /// (Req 6.3). Replay/remount/re-parse pushes carry false and never scroll.+ case setSearchState(json: String, reveal: Bool) case setDetailsState(expandedIDs: [String]) case setTableModes(modesByBlockID: [String: String]) case setNoteIndicators(json: String)@@ -224,10 +229,18 @@ enum OutboundBridgeCommand: Equatable, Sendable { /// Whether this command must wait for `layoutSettled` (not just `ready`): /// scroll restore can only run once layout is stable (Req 2.2).+ ///+ /// A reveal-carrying search push waits too (T-1918): it scrolls, so it must+ /// measure settled geometry — and holding it here keeps queue order doing the+ /// precedence work, since a restore queued before it dispatches first and the+ /// reveal's scroll lands last (the navigation outranks the stored position,+ /// T-1775). A non-reveal push stays a plain state push gated on `ready`. var requiresLayoutSettled: Bool { switch self { case .scrollToBlock, .scrollToEdge: return true+ case .setSearchState(_, let reveal):+ return reveal default: return false }
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex 6a6714a..e186f39 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -92,13 +92,8 @@ final class WebDocumentController { /// 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, or a reload/refresh that- /// re-parses in place, still lands where the reader stopped — an already-active- /// search cannot fake a navigation out of either, because- /// `WebDocumentStateSynchronizer` re-seeds its search-match diff per mount AND- /// per parse revision.+ /// a TOC entry, a cross-document `#fragment`, and a note target all outrank the+ /// saved position for the load they are queued on. /// /// 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@@ -107,6 +102,22 @@ final class WebDocumentController { /// the navigation's delivery, which may span several loads, not to any one load. @ObservationIgnored private var undeliveredNavigationTarget: String? + /// The search-navigation analogue of `undeliveredNavigationTarget` (T-1918):+ /// true while a reveal-carrying `setSearchState` — a user search navigation —+ /// is queued but not yet handed to the bridge. Search matches no longer route+ /// through `scrollTo` (prism-search.js is the single scroll owner and needs the+ /// match's range geometry, not a block id), so the T-1775 precedence — a user+ /// navigation outranks the stored-position restore for the load it is queued+ /// on — is kept by this claim instead. Only a *user* navigation raises it: a+ /// raw→rendered remount or a re-parse pushes `reveal: false` by construction+ /// (`WebSearchStateKey.isNavigation`), so an already-active search cannot fake+ /// a navigation out of either.+ ///+ /// Cleared when a reveal-carrying `setSearchState` dispatches. Like the target+ /// above it survives `load`/recovery: `scheduleSnapshotReplay` re-queues the+ /// snapshot's search state reveal-carrying (and last) while the claim is up.+ @ObservationIgnored private var hasUndeliveredSearchReveal = false+ // MARK: - Observation hooks (set by the view/coordinator) /// Called with each accepted inbound message so the layout/coordinator can@@ -290,6 +301,13 @@ final class WebDocumentController { if case .scrollToBlock(let domID) = command, domID == undeliveredNavigationTarget { undeliveredNavigationTarget = nil }+ // Same release rule for the search-reveal claim (T-1918): the reveal is on+ // its way to the page, so nothing is waiting on it any more. Non-reveal+ // search pushes leave the claim alone — they are plain state, not the+ // navigation the claim guards.+ if case .setSearchState(_, reveal: true) = command {+ hasUndeliveredSearchReveal = false+ } let arguments = Self.arguments(for: command, generation: currentGeneration) let functionName = command.functionName Task { [page] in@@ -329,8 +347,8 @@ final class WebDocumentController { return ["variables": variables] case .setCommentVisibility(let visible): return ["visible": visible]- case .setSearchState(let json):- return ["state": json]+ case .setSearchState(let json, let reveal):+ return ["state": json, "reveal": reveal] case .setDetailsState(let expandedIDs): return ["expandedIDs": expandedIDs] case .setTableModes(let modes):@@ -376,8 +394,17 @@ final class WebDocumentController { send(.setCommentVisibility(visible)) } - func setSearchState(json: String) {- send(.setSearchState(json: json))+ /// Pushes the search-highlight state (T-1680). `reveal: true` marks the push a+ /// user search navigation (T-1918): prism-search.js scrolls the current match to+ /// the viewport third, and — like `scrollTo` — an undelivered reveal outranks a+ /// stored-position restore issued before it is delivered (T-1775 precedence).+ /// When it dispatches straight through there is nothing waiting, so no claim.+ func setSearchState(json: String, reveal: Bool) {+ let command = OutboundBridgeCommand.setSearchState(json: json, reveal: reveal)+ if reveal {+ hasUndeliveredSearchReveal = !canDispatch(command)+ }+ send(command) } func setDetailsState(expandedIDs: [String]) {@@ -401,8 +428,10 @@ final class WebDocumentController { } /// 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`.+ /// navigation (TOC entry, cross-document `#fragment`, note target). Search+ /// matches do NOT route through here (T-1918): their scroll owner is the+ /// reveal-carrying `setSearchState`, which addresses the match's range, not a+ /// block. 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@@ -435,6 +464,12 @@ final class WebDocumentController { Self.logger.debug("Restore scroll skipped: navigation target still undelivered") return }+ guard !hasUndeliveredSearchReveal else {+ // A user search navigation is still waiting (T-1918): the reveal's+ // scroll must not be replaced by the saved position, same rule as above.+ Self.logger.debug("Restore scroll skipped: search reveal still undelivered")+ return+ } send(.scrollToBlock(domID: blockID)) } @@ -514,7 +549,21 @@ final class WebDocumentController { /// Re-queues the coalesced snapshot as the commands to send once the reloaded /// page is ready. One pass over native truth — not a replayed event history. private func scheduleSnapshotReplay() {- pendingCommands = latestSnapshot.coalescedCommands()+ var commands = latestSnapshot.coalescedCommands()+ // An undelivered search reveal survives the load like an undelivered+ // navigation target (T-1918): re-queue the snapshot's search state+ // reveal-carrying, and LAST — after the restore target — so its scroll+ // still outranks the stored position on the recovered page. A delivered+ // reveal replays as plain state (`coalescedCommands` strips the reveal),+ // so a crash recovery never re-jumps to a long-since-visited match.+ if hasUndeliveredSearchReveal, let json = latestSnapshot.searchStateJSON {+ commands.removeAll {+ if case .setSearchState = $0 { return true }+ return false+ }+ commands.append(.setSearchState(json: json, reveal: true))+ }+ pendingCommands = commands // If the page is already ready (e.g. a synchronous test reload), flush now. if isReady { flushPending() } }@@ -600,7 +649,12 @@ struct WebDocumentStateSnapshot: Equatable, Sendable { typography = variables case .setCommentVisibility(let visible): commentVisibility = visible- case .setSearchState(let json):+ case .setSearchState(let json, _):+ // The reveal flag is delivery-scoped navigation intent, not state+ // (T-1918): the snapshot stores only the state, so a recovery replay+ // re-renders highlights without re-scrolling to the match. The+ // controller re-attaches the reveal on replay only while it is still+ // undelivered (`scheduleSnapshotReplay`). searchStateJSON = json case .setDetailsState(let expandedIDs): detailsExpandedIDs = expandedIDs@@ -639,7 +693,9 @@ struct WebDocumentStateSnapshot: Equatable, Sendable { if let tableModes { commands.append(.setTableModes(modesByBlockID: tableModes)) } if let noteIndicatorsJSON { commands.append(.setNoteIndicators(json: noteIndicatorsJSON)) } if let inlineNotesJSON { commands.append(.setInlineNotes(json: inlineNotesJSON)) }- if let searchStateJSON { commands.append(.setSearchState(json: searchStateJSON)) }+ if let searchStateJSON {+ commands.append(.setSearchState(json: searchStateJSON, reveal: false))+ } if let scrollTargetBlockID { commands.append(.scrollToBlock(domID: scrollTargetBlockID)) } return commands }
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 4a7c945..1e80b26 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -321,12 +321,24 @@ enum WebDocumentControllerFactory { /// created and again whenever the coordinator's query, per-block counts, or /// current-match selection change. An empty query pushes the clearing payload /// so a prior highlight state is removed on re-push.+ ///+ /// `reveal` is navigation intent (T-1918): true exactly when the push is a user+ /// search navigation, in which case prism-search.js — the single scroll owner+ /// for search matches — scrolls the current match to the viewport third+ /// (Req 6.3). The mount-time push and replays pass false; the on-change push+ /// derives it via `WebSearchStateKey.isNavigation(from:to:)`. No default, so a+ /// new call site must state its intent rather than silently never (or always)+ /// scrolling — the T-1829 un-defaulted-parameter rule. static func pushSearchState( to controller: WebDocumentController, session: DocumentSession,- settings: AppSettings+ settings: AppSettings,+ reveal: Bool ) {- controller.setSearchState(json: searchStateJSON(session: session, settings: settings))+ controller.setSearchState(+ json: searchStateJSON(session: session, settings: settings),+ reveal: reveal+ ) } /// Replays the session's stored reading position into a freshly-loaded document
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex ad8a6bd..b4c12db 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -13,9 +13,6 @@ // targets) and `coordinator.noteNavigationTarget` (notes panel/sidebar // targets), translate native ids to occurrence-qualified DOM ids via // `BlockDOMID`, and drive `controller.scrollTo`.-// - Search navigation: observe the session's current match and scroll the-// rendered document to it. (Search HIGHLIGHT state — setSearchState — is-// owned by T-1680 and intentionally not pushed here.) // - State pushes: section collapse, details open-state, table display // modes, note indicators/inline notes, typography, comment visibility — // pushed on change so the controller's coalesced snapshot can replay@@ -81,27 +78,6 @@ final class WebDocumentStateSynchronizer { private var lastDetailsOpenDOMIDs: Set<String>? private var lastTableModes: [String: String]? - /// The last DOM id search navigation scrolled to, so navigating between- /// 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 `parseRevision` the `lastSearchScrollDOMID` baseline was established- /// for. A re-parse is not a navigation either (T-1775): seeding in `start()`- /// runs once per MOUNT, but `DocumentScrollContent` mounts the assembly with- /// `.task(id: session.id)`, so this instance — and its baseline — survive a- /// re-parse. On an external file change or a URL refresh with a search still- /// active, `recomputeMatchCounts` keeps a still-valid match index while the- /// block that index resolves to (and so its content-hash DOM id) can move.- /// Diffing that against the pre-parse id raised the navigation claim with no- /// user navigation anywhere and silently dropped that reload's restore. The- /// first pass of each new revision therefore RE-seeds instead of dispatching.- private var lastSeededParseRevision: UInt64?- /// The blocks→DOM id mapping and the `parseRevision` it was built for. Plain /// stored state on a non-`@Observable` class, so writing it inside the tracked /// read registers nothing and cannot re-trigger a pass.@@ -126,10 +102,11 @@ final class WebDocumentStateSynchronizer { /// armed for changes. Idempotent; safe to call once after the assembly is /// built. ///- /// Search HIGHLIGHT state (`setSearchState` via `SearchStateFeeder`) is- /// intentionally NOT part of the pass: T-1680 owns the highlight pipeline- /// and adds its domain at this seam. Search NAVIGATION (scroll-to-match)- /// is wired below.+ /// Search state (`setSearchState` via `SearchStateFeeder`) is intentionally+ /// NOT part of the pass: T-1680 owns the highlight pipeline as a view-fed+ /// seam, and since T-1918 that same push is also the single owner of+ /// search-match SCROLLING (a `reveal` flag carries navigation intent) — the+ /// synchronizer no longer scrolls for search at all. /// /// `dynamicTypeSize` is a required parameter rather than a stored default the /// caller may or may not overwrite afterwards (T-1828): the first pass pushes@@ -141,35 +118,9 @@ final class WebDocumentStateSynchronizer { guard !isStarted else { return } isStarted = true self.dynamicTypeSize = dynamicTypeSize- // 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. `dispatch` re-seeds on every later parse- // revision for the same reason — see `lastSeededParseRevision`.- seedSearchScrollBaseline(- domID: currentSearchMatchDOMID(mapped: mapping(for: session.parsedBlocks)),- parseRevision: session.parseRevision- ) synchronize() } - /// Establishes the search-scroll diff baseline for `parseRevision` WITHOUT- /// dispatching a scroll: the id records where the search already is, not- /// somewhere the user asked to go.- private func seedSearchScrollBaseline(domID: String?, parseRevision: UInt64) {- lastSearchScrollDOMID = domID- lastSeededParseRevision = parseRevision- }- /// Pushes the effective palette: the theme key plus the system Increase Contrast /// state (T-1829). Called by the hosting view, which owns both — the theme key is /// colorScheme-resolved and `colorSchemeContrast` is a view-world environment value@@ -222,11 +173,6 @@ final class WebDocumentStateSynchronizer { var tableModes: [String: String] var anchorTarget: String? var noteNavigationTarget: String?- var searchScrollDOMID: String?- /// The parse the values above were computed from, so `dispatch` can tell a- /// search-match id that moved because the user navigated from one that- /// moved only because the document was re-parsed.- var parseRevision: UInt64 /// Navigation context, not desired state: the pass's single blocks→DOM id /// walk, reused by `scrollToTarget`. var mapped: BlockDOMID.Mapping@@ -285,8 +231,6 @@ final class WebDocumentStateSynchronizer { tableModes: translatedTableModes(mapped: mapped), anchorTarget: session.pendingAnchorScroll, noteNavigationTarget: coordinator.noteNavigationTarget,- searchScrollDOMID: currentSearchMatchDOMID(mapped: mapped),- parseRevision: session.parseRevision, mapped: mapped ) }@@ -357,24 +301,13 @@ final class WebDocumentStateSynchronizer { coordinator.noteNavigationTarget = nil scrollToTarget(target, pass: pass) }- // Search-match navigation (Req 6.3): level state, deduped by block DOM- // id so navigating between matches inside one block does not re-jump.- // Only a move between matches within one mount AND one parse is a user- // navigation. A fresh mount (`start()`) and a fresh parse both re-seed- // the baseline silently instead, so neither can fake a navigation out of- // an already-active search and suppress that load's stored-position- // restore. A navigation landing in the same turn as a re-parse folds into- // the re-seed — the safe direction, since the restore then wins.- if pass.parseRevision != lastSeededParseRevision {- seedSearchScrollBaseline(- domID: pass.searchScrollDOMID, parseRevision: pass.parseRevision- )- } else if pass.searchScrollDOMID != lastSearchScrollDOMID {- lastSearchScrollDOMID = pass.searchScrollDOMID- if let domID = pass.searchScrollDOMID {- controller.scrollTo(blockID: domID)- }- }+ // Search-match navigation is deliberately ABSENT here (T-1918): the+ // view-fed search-state push is the single scroll owner — a reveal-+ // carrying setSearchState scrolls the current match's RANGE to the+ // viewport third (Req 6.3), which a block-id scroll cannot do. The+ // remount/re-parse seeding this branch needed (T-1775 rounds 2-5) went+ // with it: `WebSearchStateKey.isNavigation` makes those cases+ // structurally non-navigations. } /// The typography domain's single push site: dirty-diffed against the last@@ -403,16 +336,6 @@ final class WebDocumentStateSynchronizer { return modes } - /// The DOM id of the current search match's block, or nil when there is no- /// current match. `session.currentMatch` is computed from the search- /// coordinator's `currentGlobalMatchIndex` + `matchCountsPerBlock` and the- /// parsed blocks; reading it registers all three.- private func currentSearchMatchDOMID(mapped: BlockDOMID.Mapping) -> String? {- guard let match = session.currentMatch else { return nil }- guard match.blockIndex < mapped.count else { return nil }- return mapped[match.blockIndex].domID- }- /// Translates a native navigation target to a DOM id and drives the web /// scroll. A stale/unresolvable target skips the scroll (T-1719). /// Collapsed sections hide their blocks in the DOM, so duplicate-content
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 4f9d4db..1cf1ff0 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -145,10 +145,14 @@ struct DocumentScrollContent: View { // here. Search highlights stay a view-fed seam: the effective payload // depends on the debounced coordinator state this view already observes // (T-1680), so push the current one on mount and re-push on change below.+ // A mount is never a navigation (T-1775/T-1918): the reveal is false, so+ // an already-active search cannot scroll a remount away from the stored+ // reading position. WebDocumentControllerFactory.pushSearchState( to: controller, session: context.session,- settings: context.settings+ settings: context.settings,+ reveal: false ) webController = controller }@@ -178,12 +182,15 @@ struct DocumentScrollContent: View { // comment-visibility changes), current-match selection, or the parse // revision change — the payload is keyed by content-hash block DOM ids, so // a reparse invalidates it even when the native numbers are equal (T-1751).- // Both layouts mutate the same SearchCoordinator, so this single feed covers the inline bar- // and the compact overlay; prism-search.js also scrolls the pushed current- // match into view. Clearing the search pushes the empty payload, which clears- // the page's highlights.- .onChange(of: searchStateKey) { _, _ in- pushSearchState()+ // Both layouts mutate the same SearchCoordinator, so this single feed covers+ // the inline bar and the compact overlay. This push is the SINGLE owner of+ // search-match scrolling (T-1918): the old/new key diff decides whether the+ // change is a user navigation, and only then does prism-search.js scroll the+ // current match into view — a re-parse or count recompute re-renders+ // highlights without moving the reading position. Clearing the search pushes+ // the empty payload, which clears the page's highlights.+ .onChange(of: searchStateKey) { old, new in+ pushSearchState(reveal: WebSearchStateKey.isNavigation(from: old, to: new)) } // A single load trigger keyed on (controller present, parse revision) fires // exactly once per distinct state: the initial load after the controller is@@ -301,12 +308,15 @@ struct DocumentScrollContent: View { /// Recomputes and re-pushes the per-block search-highlight state from the /// session's SearchCoordinator (T-1680). No-op until the controller exists.- private func pushSearchState() {+ /// `reveal` marks the push a user search navigation (T-1918) — see+ /// `WebSearchStateKey.isNavigation`.+ private func pushSearchState(reveal: Bool) { guard let webController else { return } WebDocumentControllerFactory.pushSearchState( to: webController, session: context.session,- settings: context.settings+ settings: context.settings,+ reveal: reveal ) } @@ -350,6 +360,13 @@ struct WebSearchStateKey: Equatable { let matchCounts: [Int] let currentIndex: Int? let parseRevision: UInt64+ /// The coordinator's monotonic user-navigation count (T-1918). Without it, a+ /// navigation that lands on the index already selected — a single-match+ /// "next", or any wrap-around cycling back to the current match — leaves+ /// every other field equal: the key would not change, `.onChange` would+ /// never fire, and the match could not be re-revealed after the reader+ /// scrolled away.+ let navigationNonce: UInt64 @MainActor init(session: DocumentSession) {@@ -357,6 +374,51 @@ struct WebSearchStateKey: Equatable { matchCounts = session.search.matchCountsPerBlock currentIndex = session.search.currentGlobalMatchIndex parseRevision = session.parseRevision+ navigationNonce = session.search.navigationNonce+ }++ /// Test seam: build a key from raw values so the navigation classification+ /// below can be pinned without a session.+ init(+ query: String,+ matchCounts: [Int],+ currentIndex: Int?,+ parseRevision: UInt64,+ navigationNonce: UInt64 = 0+ ) {+ self.query = query+ self.matchCounts = matchCounts+ self.currentIndex = currentIndex+ self.parseRevision = parseRevision+ self.navigationNonce = navigationNonce+ }++ /// Whether the change from `old` to `new` is a USER search navigation — the+ /// only trigger that may scroll the rendered document to the current match+ /// (T-1918, Req 6.3). A navigation is a changed match selection or query —+ /// or a bumped navigation nonce, which covers a navigation that resolves to+ /// the SAME index (a single-match "next" after scrolling away) — within one+ /// parse of the document:+ ///+ /// - a parse-revision change is a re-parse, never a navigation, even when+ /// `recomputeMatchCounts` keeps the match index while its block (and so+ /// its DOM id) moves — the reload's stored-position restore must win+ /// (T-1775);+ /// - a counts-only change (e.g. the comment-visibility toggle recomputing+ /// per-block counts) is not a navigation;+ /// - clearing the search is not a navigation (nothing to reveal — the+ /// nonce is never reset, so the empty-query guard is what vetoes here).+ ///+ /// A remount never reaches this function at all: the mount-time push passes+ /// `reveal: false` by construction, which is what makes "a remount is not a+ /// navigation" structural rather than a seeded diff (the T-1775 round-2/3+ /// machinery this replaces).+ static func isNavigation(from old: WebSearchStateKey, to new: WebSearchStateKey) -> Bool {+ new.parseRevision == old.parseRevision+ && !new.query.isEmpty+ && (new.currentIndex != old.currentIndex+ || new.query != old.query+ || new.navigationNonce != old.navigationNonce) } }
diff --git a/prismTests/WebRendering/WebDocumentControllerTests.swift b/prismTests/WebRendering/WebDocumentControllerTests.swiftindex 0437877..78d7cf7 100644--- a/prismTests/WebRendering/WebDocumentControllerTests.swift+++ b/prismTests/WebRendering/WebDocumentControllerTests.swift@@ -341,7 +341,7 @@ struct WebDocumentControllerTests { var snapshot = WebDocumentStateSnapshot() snapshot.apply(.scrollToBlock(domID: "b-9-0")) snapshot.apply(.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: ""))- snapshot.apply(.setSearchState(json: "{}"))+ snapshot.apply(.setSearchState(json: "{}", reveal: false)) let commands = snapshot.coalescedCommands() #expect(commands.last == .scrollToBlock(domID: "b-9-0")) #expect(commands.first == .applyTheme(theme: "prism-dark", contrast: .standard, variables: [:], mermaidConfig: ""))@@ -362,7 +362,7 @@ struct WebDocumentControllerTests { let controller = makeController() // Establish some native truth, then become ready and drain it. controller.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "1"])- controller.setSearchState(json: "{\"q\":1}")+ controller.setSearchState(json: "{\"q\":1}", reveal: false) controller.scrollTo(blockID: "b-3-0") controller.test_markReady() controller.test_markLayoutSettled()@@ -377,7 +377,7 @@ struct WebDocumentControllerTests { // The coalesced snapshot is re-queued: theme, search, then scroll last. let queued = controller.pendingCommands #expect(queued.contains(.applyTheme(theme: "prism-dark", contrast: .standard, variables: ["--a": "1"], mermaidConfig: "")))- #expect(queued.contains(.setSearchState(json: "{\"q\":1}")))+ #expect(queued.contains(.setSearchState(json: "{\"q\":1}", reveal: false))) #expect(queued.last == .scrollToBlock(domID: "b-3-0")) }
diff --git a/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swiftindex e365377..5547c81 100644--- a/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift+++ b/prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift@@ -273,28 +273,32 @@ struct WebFragmentNavigationPrecedenceTests { // 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.+ // Search matches no longer route through `scrollTo` (T-1918): the single+ // scroll owner is the reveal-carrying `setSearchState` push. The T-1775+ // precedence survives through the controller's search-reveal claim: a user+ // search navigation still outranks the saved position for the load it is+ // queued on. 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]+ let savedBlockID = domIDs(for: session)[1]+ session.scrollPositionID = savedBlockID - // The user runs a search on the mounted document and steps to a match —- // a genuine navigation event, not a remount artefact.+ // The user runs a search and steps to a match while the page is still+ // coming up — a genuine navigation, pushed exactly as the view's+ // `.onChange(of: searchStateKey)` pushes it (reveal derived true).+ let oldKey = WebSearchStateKey(session: session) 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")+ try #require(session.currentMatch != nil, "search must select a match")+ let newKey = WebSearchStateKey(session: session)+ #expect(WebSearchStateKey.isNavigation(from: oldKey, to: newKey))+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: assembly.settings, reveal: true+ ) let documentURL = WebDocumentControllerFactory.documentURL( session: session, parseRevision: session.parseRevision@@ -302,20 +306,27 @@ struct WebFragmentNavigationPrecedenceTests { controller.load(documentURL: documentURL, parseRevision: session.parseRevision) WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session) + // The restore was dropped by the reveal claim, and the replay carries the+ // undelivered reveal LAST so its scroll still wins on the loaded page. #expect(- controller.latestSnapshot.scrollTargetBlockID == matchID,- "the saved reading position must not replace an undelivered search match"+ controller.latestSnapshot.scrollTargetBlockID == nil,+ "the saved reading position must not replace an undelivered search reveal" )+ guard case .setSearchState(_, let reveal)? = controller.pendingCommands.last else {+ Issue.record("the undelivered reveal must be re-queued last for the load")+ return+ }+ #expect(reveal, "the replayed search push must keep its navigation intent") } // 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.+ // a remount (raw→rendered toggle, same session, search still active) must not+ // fake a navigation out of the already-active search and drop the reading+ // position `toggleRawSource` just wrote. Since T-1918 this is structural: the+ // mount-time push passes `reveal: false` by construction, so there is no+ // per-instance diff to seed — 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()@@ -324,23 +335,20 @@ struct WebFragmentNavigationPrecedenceTests { // 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")+ try #require(session.currentMatch != nil, "search must select a match") // Toggle to raw and back: a fresh assembly over the same session, with the // raw reading position translated into `scrollPositionID` by- // `DocumentLayoutCoordinator.toggleRawSource`.+ // `DocumentLayoutCoordinator.toggleRawSource`. The mount pushes the active+ // search with reveal: false, exactly as DocumentScrollContent does. let savedBlockID = domIDs(for: session)[3] session.scrollPositionID = savedBlockID let remounted = remount(session: session) let controller = remounted.controller+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: remounted.settings, reveal: false+ ) - // `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,@@ -359,8 +367,9 @@ struct WebFragmentNavigationPrecedenceTests { ) } - // 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.+ // The structural rule must not disarm search navigation for the rest of the+ // mount: after a remount, stepping to a DIFFERENT match is a real navigation+ // again (the key diff classifies it), and its reveal outranks the restore. @Test("After a remount, moving to a new search match still outranks the restore") func searchNavigationAfterRemountStillOutranksRestore() async throws { let assembly = await makeAssembly()@@ -375,16 +384,20 @@ struct WebFragmentNavigationPrecedenceTests { let controller = remounted.controller session.scrollPositionID = domIDs(for: session)[1] - // The reader steps to another match — a real navigation on the new mount.+ // The reader steps to another match — a real navigation on the new mount,+ // classified by the same key diff the view's onChange uses.+ let oldKey = WebSearchStateKey(session: session) 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 newKey = WebSearchStateKey(session: session)+ #expect(+ WebSearchStateKey.isNavigation(from: oldKey, to: newKey),+ "a post-remount match step must classify as a navigation"+ )+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: remounted.settings, reveal: true+ ) let documentURL = WebDocumentControllerFactory.documentURL( session: session, parseRevision: session.parseRevision@@ -393,47 +406,50 @@ struct WebFragmentNavigationPrecedenceTests { WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session) #expect(- controller.latestSnapshot.scrollTargetBlockID == newMatchID,- "seeding must not stop a genuine post-remount navigation from outranking the restore"+ controller.latestSnapshot.scrollTargetBlockID == nil,+ "a genuine post-remount navigation must still outrank the restore" )+ guard case .setSearchState(_, let reveal)? = controller.pendingCommands.last else {+ Issue.record("the undelivered reveal must be re-queued last for the load")+ return+ }+ #expect(reveal) } // MARK: - A re-parse is not a navigation either (T-1775 round-5 regression) - // The remount seeding above runs per MOUNT, but `DocumentScrollContent` mounts- // the assembly with `.task(id: session.id)` — so the synchronizer, and its- // per-instance search diff, SURVIVE a re-parse. An external file change or a- // URL refresh while a search is active re-parses in place:- // `recomputeMatchCounts` keeps a still-valid match index, but the block that- // index resolves to (and therefore its content-hash DOM id) can move. The- // unguarded diff then read as a fresh navigation and suppressed that reload's- // restore — again with no user navigation anywhere.+ // An external file change or a URL refresh while a search is active re-parses+ // in place: `recomputeMatchCounts` keeps a still-valid match index, but the+ // block that index resolves to (and therefore its content-hash DOM id) can+ // move — with no user navigation anywhere. Since T-1918 the classification is+ // the push key's diff: a parse-revision change vetoes the reveal, so the+ // reload's restore must win. @Test("A re-parse with an active search does not suppress the saved-position restore") func reparseWithActiveSearchStillRestoresSavedPosition() async throws { let assembly = await makeAssembly() let session = assembly.session+ let controller = assembly.controller // The reader searches in the mounted document. session.search.setActiveSearchQueryForTesting("Troubleshooting") session.search.navigateToMatch(at: 0) let matchIndex = try #require(session.currentMatch?.blockIndex, "search must select a match") - // The rendered document is mounted with that search already active, so the- // seeded baseline leaves the fresh controller idle (the round-3 fix).- let mounted = remount(session: session)- let controller = mounted.controller- await Task.yield()- try #require(controller.latestSnapshot.scrollTargetBlockID == nil)-- // The file changes on disk. The synchronizer survives it — only the parse- // revision moves — and the current match lands on a different block.+ // The file changes on disk: only the parse revision moves, and the current+ // match lands on a different block. The view's onChange fires on the key+ // change and must classify it as NOT a navigation.+ let oldKey = WebSearchStateKey(session: session) await session.reloadContent(markdownString: Self.reparsedFixture) await settleSynchronizer() let newIndex = try #require(session.currentMatch?.blockIndex, "the match must survive the re-parse") try #require(newIndex != matchIndex, "the re-parse must move the current match's block")+ let newKey = WebSearchStateKey(session: session) #expect(- controller.latestSnapshot.scrollTargetBlockID == nil,- "a re-parse must not queue a search scroll of its own"+ !WebSearchStateKey.isNavigation(from: oldKey, to: newKey),+ "a re-parse must not classify as a search navigation"+ )+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: assembly.settings, reveal: false ) // The reload's load task now offers the reading position the reader was at.@@ -452,35 +468,37 @@ struct WebFragmentNavigationPrecedenceTests { } // The re-parse counterpart of `searchNavigationAfterRemountStillOutranksRestore`:- // re-seeding on a parse-revision change must not disarm search navigation for- // the rest of the document's life either.+ // the revision veto must not disarm search navigation for the rest of the+ // document's life — a match step AFTER the re-parse settles is a navigation. @Test("After a re-parse, moving to a new search match still outranks the restore") func searchNavigationAfterReparseStillOutranksRestore() async throws { let assembly = await makeAssembly() let session = assembly.session+ let controller = assembly.controller // "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) - // A fresh mount, so the only scroll this controller ever sees is the one- // under test. Then the file changes underneath it.- let mounted = remount(session: session)- let controller = mounted.controller+ // The file changes underneath the mounted document. await session.reloadContent(markdownString: Self.reparsedFixture) await settleSynchronizer() let afterReparseIndex = try #require(session.currentMatch?.blockIndex) - // The reader steps to another match — a real navigation, after the re-parse.+ // The reader steps to another match — a real navigation, after the re-parse+ // (same revision on both sides of the diff now).+ let oldKey = WebSearchStateKey(session: session) session.search.navigateToMatch(at: session.search.totalMatchCount - 1) let newIndex = try #require(session.currentMatch?.blockIndex) try #require(newIndex != afterReparseIndex, "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-re-parse search navigation must still reach the controller")+ let newKey = WebSearchStateKey(session: session)+ #expect(+ WebSearchStateKey.isNavigation(from: oldKey, to: newKey),+ "a post-re-parse match step must classify as a navigation"+ )+ WebDocumentControllerFactory.pushSearchState(+ to: controller, session: session, settings: assembly.settings, reveal: true+ ) session.scrollPositionID = domIDs(for: session)[1] let documentURL = WebDocumentControllerFactory.documentURL(@@ -490,9 +508,14 @@ struct WebFragmentNavigationPrecedenceTests { WebDocumentControllerFactory.restoreScrollPosition(to: controller, session: session) #expect(- controller.latestSnapshot.scrollTargetBlockID == newMatchID,- "re-seeding must not stop a genuine post-re-parse navigation from outranking the restore"+ controller.latestSnapshot.scrollTargetBlockID == nil,+ "a genuine post-re-parse navigation must still outrank the restore" )+ guard case .setSearchState(_, let reveal)? = controller.pendingCommands.last else {+ Issue.record("the undelivered reveal must be re-queued last for the load")+ return+ }+ #expect(reveal) } // MARK: - The restore path must still work
diff --git a/prismTests/WebRendering/WebSearchBridgeTests.swift b/prismTests/WebRendering/WebSearchBridgeTests.swiftindex 4fe1a62..44d5338 100644--- a/prismTests/WebRendering/WebSearchBridgeTests.swift+++ b/prismTests/WebRendering/WebSearchBridgeTests.swift@@ -290,7 +290,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 0, context: plainContext() )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(150)) // The CSS Custom Highlight registry has a populated `prism-search` highlight.@@ -324,11 +324,11 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 0, context: plainContext() )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(120)) // An empty-query state clears everything (Req 6.3 recompute / clear).- try await harness.send(.setSearchState(json: "{\"query\":\"\",\"blocks\":{}}"))+ try await harness.send(.setSearchState(json: "{\"query\":\"\",\"blocks\":{}}", reveal: false)) try await Task.sleep(for: .milliseconds(120)) let size = try await harness.evalString(@@ -359,7 +359,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: nil, context: footnoteContext() )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(120)) let matched = try await harness.evalString(@@ -429,7 +429,8 @@ struct WebSearchBridgeTests { featureScripts: ["prism-search"] ) let counts = blocks.map { SearchService.countMatches(query: "alpha", in: $0, context: context) }- // One footnote-content match per host block; global index 1 is the later host's.+ // One footnote-content match per host block; global index 1 is the later+ // host's. The user navigated here, so the push reveals (T-1918). let json = SearchStateFeeder.searchStateJSON( query: "alpha", blocks: blocks,@@ -437,7 +438,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 1, context: context )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: true)) try await Task.sleep(for: .milliseconds(150)) // Document order: badge 0 in the first host, badge 1 in the later host.@@ -477,7 +478,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 1, context: context )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(150)) let attributes = try await harness.evalString(@@ -635,7 +636,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 2, context: context )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(150)) let attributes = try await harness.evalString(@@ -669,7 +670,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 1, context: context )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(150)) let attributes = try await harness.evalString(@@ -706,7 +707,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 2, context: context )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(150)) let attributes = try await harness.evalString(@@ -737,7 +738,7 @@ struct WebSearchBridgeTests { currentGlobalMatchIndex: 0, context: plainContext() )- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) try await Task.sleep(for: .milliseconds(120)) let size = try await harness.evalString( "var h = (typeof CSS !== 'undefined' && CSS.highlights) ? CSS.highlights.get('prism-search') : null;"
diff --git a/prismTests/WebRendering/WebSearchParityTests.swift b/prismTests/WebRendering/WebSearchParityTests.swiftindex 00323cf..43f1b6c 100644--- a/prismTests/WebRendering/WebSearchParityTests.swift+++ b/prismTests/WebRendering/WebSearchParityTests.swift@@ -52,7 +52,7 @@ struct WebSearchParityTests { featureScripts: ["prism-search"] ) _ = try await harness.waitForMessage(type: "ready")- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) let count = try await harness.page.callJavaScript( "return window.__prismBridge && window.__prismBridge.searchHighlightCount "@@ -144,7 +144,7 @@ struct WebSearchParityTests { #expect(nativeTextMatches == 2, "query '\(query)': native should count both spellings") let json = SearchStateFeeder.encode(query: query, states: states)- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) let count = try await harness.page.callJavaScript( "return window.__prismBridge && window.__prismBridge.searchHighlightCount "@@ -221,7 +221,7 @@ struct WebSearchParityTests { let nativeTextMatches = states.reduce(0) { $0 + $1.textMatchCount } #expect(nativeTextMatches == 1, "query '\(query)': native should match the NFD spelling") - try await harness.send(.setSearchState(json: SearchStateFeeder.encode(query: query, states: states)))+ try await harness.send(.setSearchState(json: SearchStateFeeder.encode(query: query, states: states), reveal: false)) // Scalar-exact comparison: Swift String == uses canonical // equivalence, which cannot distinguish NFC from NFD; the assertion@@ -289,7 +289,7 @@ struct WebSearchParityTests { ) _ = try await harness.waitForMessage(type: "ready") try await harness.send(.setCommentVisibility(false))- try await harness.send(.setSearchState(json: json))+ try await harness.send(.setSearchState(json: json, reveal: false)) let count = try await harness.page.callJavaScript( "return window.__prismBridge && window.__prismBridge.searchHighlightCount "
diff --git a/prismTests/WebRendering/WebSearchReloadTests.swift b/prismTests/WebRendering/WebSearchReloadTests.swiftindex 0252747..4c51697 100644--- a/prismTests/WebRendering/WebSearchReloadTests.swift+++ b/prismTests/WebRendering/WebSearchReloadTests.swift@@ -118,7 +118,7 @@ struct WebSearchReloadTests { // final snapshot and the assertions below hold either way. var lastPushedKey = WebSearchStateKey(session: session) WebDocumentControllerFactory.pushSearchState(- to: controller, session: session, settings: settings+ to: controller, session: session, settings: settings, reveal: false ) controller.load( documentURL: WebDocumentControllerFactory.documentURL(@@ -135,9 +135,10 @@ struct WebSearchReloadTests { // equal, so nothing re-pushes. let key = WebSearchStateKey(session: session) if key != lastPushedKey {+ let reveal = WebSearchStateKey.isNavigation(from: lastPushedKey, to: key) lastPushedKey = key WebDocumentControllerFactory.pushSearchState(- to: controller, session: session, settings: settings+ to: controller, session: session, settings: settings, reveal: reveal ) }
diff --git a/prismTests/WebRendering/WebSearchScrollOwnershipTests.swift b/prismTests/WebRendering/WebSearchScrollOwnershipTests.swiftnew file mode 100644index 0000000..df1bc39--- /dev/null+++ b/prismTests/WebRendering/WebSearchScrollOwnershipTests.swift@@ -0,0 +1,484 @@+//+// WebSearchScrollOwnershipTests.swift+// prismTests+//+// T-1918 regression tests: search-match scrolling has exactly ONE owner.+//+// Before the fix, two independent owners scrolled on the same trigger:+// - native: WebDocumentStateSynchronizer issued `controller.scrollTo(blockID:)`+// whenever the current match's block changed (block-top geometry via+// prism-scroll.js scrollToBlock), and+// - JS: prism-search.js `scrollCurrentIntoView` ran on every `setSearchState`+// push (viewport-third geometry, only when the match was off-screen).+// The final resting position depended on bridge-command ordering, a visible+// double-jump was possible, and on a tall block the native block-top could+// leave the match below the fold (against specs/search Req 6.3).+//+// The fix keeps ONE owner — prism-search.js, which alone knows the match's+// range geometry — and native declares intent: a `setSearchState` push carries+// `reveal: true` exactly when the user navigated (query typed / match stepped),+// never for a remount/re-parse replay. The synchronizer's search-scroll branch+// is deleted. The current match's section bypasses the ±2000px highlight+// window so a far match is always reachable (subsumes T-1839's core case).+//++import Foundation+import SwiftUI+import Testing+import WebKit+@testable import prism++@MainActor+struct WebSearchScrollOwnershipTests {++ // MARK: - Fixtures++ /// A document with a match in the FIRST block, a long run of non-matching+ /// filler (far beyond prism-search.js's ±2000px highlight window), a match in+ /// a far block, and trailing filler after it — so the far match is nowhere+ /// near either document edge and the viewport-third geometry is achievable+ /// (a match at the very end clamps at max scroll and cannot centre).+ private static func farMatchBlocks() -> [MarkdownBlock] {+ func filler(_ label: String, _ count: Int) -> [MarkdownBlock] {+ (0..<count).map { index in+ .paragraph(markdown:+ "\(label) filler paragraph number \(index) with a good amount of "+ + "running text so the document grows well past the highlight "+ + "window margin in the harness viewport, whatever its height "+ + "happens to be."+ )+ }+ }+ var blocks: [MarkdownBlock] = [.paragraph(markdown: "First needle match here.")]+ blocks.append(contentsOf: filler("Leading", 200))+ blocks.append(.paragraph(markdown: "Far needle match, past the window."))+ blocks.append(contentsOf: filler("Trailing", 60))+ return blocks+ }++ private static let plainContext = SearchContext(showHTMLComments: false, footnoteData: .empty)++ /// The search-state JSON for `blocks` with the given current global match.+ private func stateJSON(blocks: [MarkdownBlock], currentIndex: Int?) -> String {+ SearchStateFeeder.searchStateJSON(+ query: "needle",+ blocks: blocks,+ matchCountsPerBlock: blocks.map {+ SearchService.countMatches(query: "needle", in: $0, context: Self.plainContext)+ },+ currentGlobalMatchIndex: currentIndex,+ context: Self.plainContext+ )+ }++ /// The current-match geometry as "scrollY|rectTop|innerHeight" read from the+ /// live page: the bounding rect of the prism-search-current range.+ private func currentMatchGeometry(_ harness: WebDocumentLiveHarness) async throws -> (+ scrollY: Double, rectTop: Double, innerHeight: Double+ )? {+ let raw = try await harness.evalString(+ "var h = (typeof CSS !== 'undefined' && CSS.highlights) ? CSS.highlights.get('prism-search-current') : null;"+ + " if (!h || h.size === 0) { return 'none'; }"+ + " var rect = null; h.forEach(function (r) { rect = r.getBoundingClientRect(); });"+ + " return String(window.scrollY) + '|' + String(rect.top) + '|'"+ + " + String(window.innerHeight || document.documentElement.clientHeight);"+ )+ guard let raw, raw != "none" else { return nil }+ let parts = raw.split(separator: "|").compactMap { Double($0) }+ guard parts.count == 3 else { return nil }+ return (parts[0], parts[1], parts[2])+ }++ // MARK: - The final position after a cross-block navigation (Req 6.3)++ @Test("Navigating to a match far outside the highlight window scrolls it to the viewport third")+ func farCrossBlockNavigationLandsOnTheMatch() async throws {+ let blocks = Self.farMatchBlocks()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ featureScripts: ["prism-scroll", "prism-search"]+ )++ // The user steps from the first match to the last: global index 1 lives in+ // the final block, far outside the ±2000px window at scrollY 0. This is a+ // user navigation, so the push reveals the current match.+ try await harness.send(.setSearchState(+ json: stateJSON(blocks: blocks, currentIndex: 1), reveal: true+ ))+ try await Task.sleep(for: .milliseconds(300))++ // Expected: the page scrolled, the current match's range is registered+ // (the current section bypasses the window gate), and its rect sits at+ // roughly one third of the viewport — the Req 6.3 approximate centring.+ // Actual (bug): the far section is outside the highlight window, so no+ // current range exists and the page never scrolls (scrollY stays 0).+ let geometry = try #require(+ try await currentMatchGeometry(harness),+ "the current match must have a registered range even outside the window"+ )+ #expect(geometry.scrollY > 0, "the navigation must scroll the document")+ #expect(+ geometry.rectTop >= 0 && geometry.rectTop <= geometry.innerHeight * 0.6,+ "the current match must rest approximately centred (viewport third), got top \(geometry.rectTop) of \(geometry.innerHeight)"+ )+ }++ @Test("A non-reveal push never scrolls, even with an off-screen current match")+ func nonRevealPushDoesNotScroll() async throws {+ let blocks = Self.farMatchBlocks()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ featureScripts: ["prism-scroll", "prism-search"]+ )++ // The same state, pushed the way a remount/recovery replay pushes it: no+ // user navigation, so no reveal — the reading position must not move,+ // whatever the current match's visibility.+ try await harness.send(.setSearchState(+ json: stateJSON(blocks: blocks, currentIndex: 1), reveal: false+ ))+ try await Task.sleep(for: .milliseconds(300))++ let scrollY = try await harness.evalString("return String(window.scrollY);")+ #expect((Double(scrollY ?? "0") ?? 0) == 0, "a state replay must not scroll the document")+ }++ @Test("Navigating back to a near match re-centres it (both directions of the cross-block case)")+ func navigatingBackRecentresTheEarlierMatch() async throws {+ let blocks = Self.farMatchBlocks()+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks,+ featureScripts: ["prism-scroll", "prism-search"]+ )++ // Forward to the far match, then back to the first: the final position+ // must track the CURRENT match each time — one owner, one geometry.+ try await harness.send(.setSearchState(+ json: stateJSON(blocks: blocks, currentIndex: 1), reveal: true+ ))+ try await Task.sleep(for: .milliseconds(300))+ try await harness.send(.setSearchState(+ json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+ ))+ try await Task.sleep(for: .milliseconds(300))++ let geometry = try #require(try await currentMatchGeometry(harness))+ #expect(+ geometry.rectTop >= 0 && geometry.rectTop <= geometry.innerHeight * 0.6,+ "the first match must be back in view after navigating back, got top \(geometry.rectTop)"+ )+ }++ // MARK: - The navigation classification (what may carry reveal: true)++ private func key(+ query: String = "needle",+ counts: [Int] = [1, 1],+ index: Int?,+ revision: UInt64 = 1,+ nonce: UInt64 = 0+ ) -> WebSearchStateKey {+ WebSearchStateKey(+ query: query, matchCounts: counts, currentIndex: index,+ parseRevision: revision, navigationNonce: nonce+ )+ }++ @Test("Stepping to another match within one parse is a navigation")+ func steppingMatchesIsNavigation() {+ #expect(WebSearchStateKey.isNavigation(+ from: key(index: 0), to: key(index: 1)+ ))+ }++ @Test("Selecting the very first match (nil -> 0) is a navigation")+ func firstMatchSelectionIsNavigation() {+ #expect(WebSearchStateKey.isNavigation(+ from: key(index: nil), to: key(index: 0)+ ))+ }++ @Test("A nonce bump at an unchanged index is a navigation, but not across a re-parse")+ func nonceBumpClassification() {+ // The same-index wrap-around: only the nonce moved.+ #expect(WebSearchStateKey.isNavigation(+ from: key(index: 0, nonce: 1), to: key(index: 0, nonce: 2)+ ))+ // The revision veto outranks the nonce, exactly as it outranks an index+ // change (T-1775): a navigation coalesced with a re-parse must not reveal.+ #expect(!WebSearchStateKey.isNavigation(+ from: key(index: 0, revision: 1, nonce: 1),+ to: key(index: 0, revision: 2, nonce: 2)+ ))+ }++ @Test("Re-navigating to the SAME match must change the key and classify as a navigation")+ func sameIndexRenavigationChangesKeyAndClassifies() async {+ // A single-match search: next/previous always wraps back to index 0. If+ // the reader scrolled away and asks for the match again, the push key+ // must still change (or `.onChange` never fires) and the diff must+ // classify as a navigation so the match is re-revealed (Req 6.3).+ let session = DocumentSession(clipboardContent: "Only one needle in this document.")+ await session.parseContent()+ session.search.setActiveSearchQueryForTesting("needle")+ session.search.navigateToMatch(at: 0)++ let oldKey = WebSearchStateKey(session: session)+ session.search.navigateToNextMatch() // wraps back to the same index 0+ let newKey = WebSearchStateKey(session: session)++ #expect(+ newKey != oldKey,+ "a same-index re-navigation must change the push key, or no push fires at all"+ )+ #expect(+ WebSearchStateKey.isNavigation(from: oldKey, to: newKey),+ "a same-index re-navigation must classify as a user navigation"+ )+ }++ @Test("A navigation attempt with no matches changes nothing")+ func navigationWithNoMatchesLeavesKeyUnchanged() async {+ // The guard in the coordinator's navigate methods must keep a fruitless+ // next/previous from bumping the key — no push, no reveal.+ let session = DocumentSession(clipboardContent: "Nothing to find here.")+ await session.parseContent()+ session.search.setActiveSearchQueryForTesting("needle")++ let oldKey = WebSearchStateKey(session: session)+ session.search.navigateToNextMatch()+ session.search.navigateToPreviousMatch()+ let newKey = WebSearchStateKey(session: session)++ #expect(newKey == oldKey, "a no-op navigation must not re-push search state")+ }++ @Test("Only the three navigate methods bump the navigation nonce")+ func onlyNavigateMethodsBumpTheNonce() async {+ // The nonce's whole value is its provenance: it must move on exactly the+ // user-navigation entry points and on nothing else, or a recompute/clamp/+ // clear could masquerade as a navigation and steal the reader's scroll+ // position. Pin every non-navigation mutator first, then each navigate+ // method's bump, then the failed-guard cases.+ let session = DocumentSession(clipboardContent: """+ First paragraph mentions a needle once.++ Second paragraph has a needle too.+ """)+ await session.parseContent()+ let search = session.search+ search.setActiveSearchQueryForTesting("needle")+ let baseline = search.navigationNonce++ // A direct recompute (the parsedBlocks.didSet re-parse path).+ search.recomputeMatchCounts()+ #expect(search.navigationNonce == baseline, "recomputeMatchCounts must not bump the nonce")++ // The visibility-change recompute, which even resets the cursor to the+ // first match — a cursor move that is still not a user navigation.+ search.recomputeAfterVisibilityChange()+ #expect(search.currentGlobalMatchIndex == 0)+ #expect(search.navigationNonce == baseline, "recomputeAfterVisibilityChange must not bump the nonce")++ // The clamp path: updateMatchCount drops the totals until the selected+ // index clamps and then resets to nil.+ search.updateMatchCount(for: 0, count: 0)+ search.updateMatchCount(for: 1, count: 0)+ #expect(search.currentGlobalMatchIndex == nil, "the clamp must have actually run")+ #expect(search.navigationNonce == baseline, "clampCurrentMatchIndex must not bump the nonce")++ // Re-setting the query (recompute through activeSearchQuery.didSet).+ search.setActiveSearchQueryForTesting("paragraph")+ search.setActiveSearchQueryForTesting("needle")+ #expect(search.navigationNonce == baseline, "a query change must not bump the nonce")++ // Clearing the search.+ search.clearSearch()+ #expect(search.navigationNonce == baseline, "clearSearch must not bump the nonce")++ // Each navigate method bumps exactly once when it selects a match.+ search.setActiveSearchQueryForTesting("needle")+ search.navigateToNextMatch()+ #expect(search.navigationNonce == baseline &+ 1, "navigateToNextMatch must bump exactly once")+ search.navigateToPreviousMatch()+ #expect(search.navigationNonce == baseline &+ 2, "navigateToPreviousMatch must bump exactly once")+ search.navigateToMatch(at: 0)+ #expect(search.navigationNonce == baseline &+ 3, "navigateToMatch(at:) must bump exactly once")++ // A failed guard bumps nothing: out of bounds with matches present…+ search.navigateToMatch(at: 99)+ #expect(search.navigationNonce == baseline &+ 3, "an out-of-bounds navigate must not bump")++ // …and any navigation attempt with no matches at all.+ search.setActiveSearchQueryForTesting("unfindable-zzz")+ search.navigateToNextMatch()+ search.navigateToPreviousMatch()+ search.navigateToMatch(at: 0)+ #expect(search.navigationNonce == baseline &+ 3, "a fruitless navigate must not bump")+ }++ @Test("Typing a different query within one parse is a navigation")+ func queryChangeIsNavigation() {+ #expect(WebSearchStateKey.isNavigation(+ from: key(query: "need", index: 0), to: key(query: "needle", index: 0)+ ))+ }++ @Test("A re-parse is not a navigation, even when the match index moved")+ func reparseIsNotNavigation() {+ // recomputeMatchCounts can keep (or clamp) the index across a re-parse+ // while the block it resolves to moves — the T-1775 false-navigation+ // class. The revision change alone must veto the reveal.+ #expect(!WebSearchStateKey.isNavigation(+ from: key(index: 0, revision: 1), to: key(index: 1, revision: 2)+ ))+ }++ @Test("A counts-only recompute is not a navigation")+ func countsOnlyChangeIsNotNavigation() {+ #expect(!WebSearchStateKey.isNavigation(+ from: key(counts: [1, 1], index: 0), to: key(counts: [2, 1], index: 0)+ ))+ }++ @Test("Clearing the search is not a navigation")+ func clearingIsNotNavigation() {+ #expect(!WebSearchStateKey.isNavigation(+ from: key(index: 0), to: key(query: "", counts: [], index: nil)+ ))+ }++ // MARK: - The reveal claim (T-1775 precedence, kept without the native owner)++ private func makeUnreadyController() -> WebDocumentController {+ WebDocumentController(+ sessionID: "t1918",+ parseRevision: 1,+ schemeHandler: PrismDocSchemeHandler()+ )+ }++ @Test("An undelivered reveal outranks the stored-position restore for its load")+ func undeliveredRevealBlocksRestore() {+ let controller = makeUnreadyController()+ // A user navigation while the page is still loading: the reveal queues.+ controller.setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: true)+ // The load task offers the saved position — it must be dropped, exactly+ // as it is for an undelivered scrollTo target (T-1775).+ controller.restoreScroll(blockID: "b-saved-0")+ #expect(+ controller.latestSnapshot.scrollTargetBlockID == nil,+ "the saved position must not replace an undelivered search reveal"+ )++ // Delivery releases the claim: once the page settles the reveal flushes,+ // and a later restore works normally.+ controller.test_markReady()+ controller.test_markLayoutSettled()+ #expect(controller.pendingCommands.isEmpty)+ controller.restoreScroll(blockID: "b-saved-0")+ #expect(controller.latestSnapshot.scrollTargetBlockID == "b-saved-0")+ }++ @Test("A reveal that dispatches straight through raises no claim")+ func deliveredRevealDoesNotBlockRestore() {+ let controller = makeUnreadyController()+ controller.test_markReady()+ controller.test_markLayoutSettled()+ controller.setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: true)+ controller.restoreScroll(blockID: "b-saved-0")+ #expect(controller.latestSnapshot.scrollTargetBlockID == "b-saved-0")+ }++ @Test("A non-reveal push never blocks the restore")+ func nonRevealPushDoesNotBlockRestore() {+ let controller = makeUnreadyController()+ controller.setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: false)+ controller.restoreScroll(blockID: "b-saved-0")+ #expect(controller.latestSnapshot.scrollTargetBlockID == "b-saved-0")+ }++ @Test("The snapshot replays search state without the reveal once delivered")+ func snapshotStripsDeliveredReveal() {+ let controller = makeUnreadyController()+ controller.test_markReady()+ controller.test_markLayoutSettled()+ controller.setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: true)++ // A recovery replay must re-render highlights WITHOUT re-scrolling to a+ // long-since-visited match: the replayed command carries reveal: false.+ controller.handleProcessTermination(+ documentURL: URL(string: "prism-doc://document/x")!+ )+ #expect(controller.pendingCommands.contains(+ .setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: false)+ ))+ }++ @Test("An undelivered reveal survives the load, re-queued after the restore target")+ func undeliveredRevealSurvivesLoadAfterRestoreTarget() {+ let controller = makeUnreadyController()+ // The restore target is already part of native truth for this load…+ controller.restoreScroll(blockID: "b-saved-0")+ // …when a user navigation arrives, still undelivered.+ controller.setSearchState(json: "{\"query\":\"n\",\"blocks\":{}}", reveal: true)++ controller.load(+ documentURL: URL(string: "prism-doc://document/x")!, parseRevision: 1+ )++ // The replay keeps the reveal (it never reached any page) and orders it+ // LAST — after the scroll restore — so its scroll still wins.+ let commands = controller.pendingCommands+ #expect(commands.last == .setSearchState(+ json: "{\"query\":\"n\",\"blocks\":{}}", reveal: true+ ))+ if let scrollIndex = commands.firstIndex(of: .scrollToBlock(domID: "b-saved-0")) {+ #expect(scrollIndex < commands.count - 1)+ }+ }++ // MARK: - Single ownership: the synchronizer no longer scrolls for search++ @Test("Search-match navigation does not enqueue a native block scroll")+ func searchNavigationEnqueuesNoNativeScroll() async throws {+ let session = DocumentSession(+ url: URL(fileURLWithPath: "/tmp/t1918-\(UUID().uuidString).md"),+ content: """+ # Alpha++ First paragraph mentions a needle once.++ Second paragraph has a needle too.+ """+ )+ await session.parseContent()+ let made = WebDocumentStateSynchronizer.makeAssembly(+ session: session,+ settings: AppSettings(),+ coordinator: DocumentLayoutCoordinator(),+ notesManager: NotesManager()+ )+ made.synchronizer.start(dynamicTypeSize: .large)++ // The user searches and steps between matches in different blocks.+ session.search.setActiveSearchQueryForTesting("needle")+ session.search.navigateToMatch(at: 0)+ session.search.navigateToMatch(at: 1)++ // Let the synchronizer's scheduled observation passes settle.+ for _ in 0..<12 {+ await Task.yield()+ try? await Task.sleep(for: .milliseconds(10))+ }++ // Expected: search scrolling is owned by prism-search.js's reveal path+ // alone — the synchronizer must not issue controller.scrollTo, so the+ // snapshot's scroll target stays untouched. Actual (bug): the native+ // owner routes the match block and the snapshot records it.+ #expect(+ made.controller.latestSnapshot.scrollTargetBlockID == nil,+ "search navigation must not produce a native scrollToBlock (single owner, T-1918)"+ )+ }+}
diff --git a/prismTests/WebRendering/WebSearchWiringTests.swift b/prismTests/WebRendering/WebSearchWiringTests.swiftindex db6ee6c..027107c 100644--- a/prismTests/WebRendering/WebSearchWiringTests.swift+++ b/prismTests/WebRendering/WebSearchWiringTests.swift@@ -72,10 +72,13 @@ struct WebSearchWiringTests { themeKey: settings.theme(for: .light).rawValue, contrast: .standard )+ // The mount-time push is never a navigation (T-1918): reveal is false,+ // mirroring DocumentScrollContent exactly. WebDocumentControllerFactory.pushSearchState( to: controller, session: session,- settings: settings+ settings: settings,+ reveal: false ) let documentURL = WebDocumentControllerFactory.documentURL(@@ -187,10 +190,12 @@ struct WebSearchWiringTests { // The user dismisses search: the coordinator clears, and the same feed // pushes the clearing payload (the view's onChange re-push, replicated). session.search.clearSearch()+ // Clearing is not a navigation (WebSearchStateKey.isNavigation): no reveal. WebDocumentControllerFactory.pushSearchState( to: controller, session: session,- settings: settings+ settings: settings,+ reveal: false ) var cleared: String?
diff --git a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swiftindex cad6aed..6b9017e 100644--- a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift+++ b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift@@ -267,24 +267,11 @@ struct WebStateSynchronizerAssemblyTests { #expect(routed, "row sub-block id must resolve to its parent block's DOM id") } - @Test("Navigating search matches scrolls the rendered document to the current match")- func searchMatchNavigationScrollsToMatchBlock() async throws {- let assembly = await makeAssembly()- let paragraph = try #require(firstBlock(in: assembly.session) { block in- if case .paragraph = block { return true }- return false- })- let expectedDOMID = try #require(domID(at: paragraph.index, in: assembly.session))-- assembly.session.search.updateMatchCount(for: paragraph.index, count: 2)- assembly.session.search.navigateToMatch(at: 0)- #expect(assembly.session.currentMatch?.blockIndex == paragraph.index)-- let routed = await waitUntil {- assembly.controller.latestSnapshot.scrollTargetBlockID == expectedDOMID- }- #expect(routed, "search-match navigation must drive controller.scrollTo (Req 6.3)")- }+ // Search-match navigation deliberately has NO test here any more (T-1918):+ // the synchronizer is not an owner of search scrolling. The single owner is+ // the view-fed reveal-carrying setSearchState push; the assembly-level+ // guarantee that the synchronizer stays out of it is pinned by+ // WebSearchScrollOwnershipTests.searchNavigationEnqueuesNoNativeScroll. // MARK: - Details open-state (native authoritative, exact replay)
diff --git a/specs/search/decision_log.md b/specs/search/decision_log.mdindex 3d79f24..a234cde 100644--- a/specs/search/decision_log.md+++ b/specs/search/decision_log.md@@ -258,3 +258,108 @@ The Textual fork was specifically enhanced to support search highlighting as par - Context excerpts computed on-demand from rendered text when needed for UI ---++## Decision 8: prism-search.js Is the Single Owner of Search-Match Scrolling++**Date**: 2026-07-29+**Status**: accepted++### Context++Since the WebKit cutover, two independent components scrolled the rendered document+on the same search-navigation trigger (T-1918). The native+`WebDocumentStateSynchronizer` issued `controller.scrollTo(blockID:)` whenever the+current match's block changed (T-1719/PR #315), which `prism-scroll.js` renders as+block-top. Independently, `prism-search.js` ran `scrollCurrentIntoView` on every+`setSearchState` push (T-1680), positioning the match range at a third of the+viewport when off-screen. The final resting position depended on bridge-command+ordering, a visible double-jump was possible, and block-top could leave a match in a+tall block below the fold, against Req 6.3 (approximate centring). The owners were+layered rather than redundant: prism-search.js only registered ranges for sections+inside its ±2000px highlight window, so a far match had no scroll target at all+(T-1839) and only the accidental native block scroll reached it. T-1944 (zero-rect+hidden sections treated as in-window) was read as a design input.++### Decision++`prism-search.js` is the single owner of search-match scrolling. Native declares+intent instead of scrolling: `setSearchState` carries a `reveal: Bool`, true exactly+when the push is a user search navigation (classified by+`WebSearchStateKey.isNavigation(from:to:)` — a changed match index or query, or a+bumped `SearchCoordinator.navigationNonce`, within one parse revision; the nonce is+a monotonic count bumped only by the coordinator's three navigate methods, so a+navigation that resolves to the *same* index — a single-match "next" after the+reader scrolled away — still changes the push key and re-reveals the match), and+only a reveal push scrolls, resting the current match at+the viewport third. The synchronizer's search-scroll branch and its T-1775 seeding+machinery are deleted. The current match's section always bypasses the highlight+window so far matches are reachable. A reveal-carrying push `requiresLayoutSettled`+and raises `hasUndeliveredSearchReveal` on the controller — the search analogue of+`undeliveredNavigationTarget` — so the T-1775 precedence (a user navigation outranks+the stored-position restore for its load) is preserved; snapshot replays strip the+reveal unless it is still undelivered, in which case it is re-queued last.++### Rationale++Req 6.3 asks for the *match* to be approximately centred, and only the JS side can+resolve the match's range geometry — native addresses blocks, so block-top cannot+centre a match inside a tall block, and the native owner's per-block dedupe made a+second match below the fold in the same block unreachable. Keeping the JS owner also+turns the remount/re-parse "not a navigation" rule structural (the mount push is+`reveal: false` by construction; a revision change vetoes the reveal), replacing the+synchronizer's per-instance diff seeding that took three T-1775 regression rounds to+get right. Relaxing the window gate for the current section falls out naturally and+subsumes T-1839's core case.++### Alternatives Considered++- **Keep native, drop JS** (delete `scrollCurrentIntoView`, give `scrollTo` an+ anchor argument for centring): rejected — native only knows the block, so centring+ the block still leaves a match in a tall block arbitrarily far from centre; doing+ better would require shipping intra-block match ordinals for JS to resolve anyway,+ re-implementing the JS owner as a command. The per-block dedupe also breaks+ intra-block navigation.+- **Keep both, layered** (native coarse scroll brings the block into the window, JS+ fine-tunes): rejected — this is the status quo's accidental behaviour made+ official; the double-jump remains, and correctness still depends on command+ ordering that nothing guarantees.++### Consequences++**Positive:**+- One scroll per navigation, one geometry (viewport third, Req 6.3), pinned by a+ live-page test rather than incidental ordering.+- Far matches are reachable (T-1839's core case) because the current section+ bypasses the highlight window.+- The T-1775 remount/re-parse false-navigation class is structurally impossible+ rather than guarded by seeded diffs.+- A navigation that lands on the already-selected index (single match, or a+ wrap-around cycling back) re-reveals the match — the navigation nonce makes the+ push key change even when index, query, and counts are all equal, a gap the old+ per-block dedupe shared.++**Negative:**+- Search scrolling is now view-fed (the reveal rides `DocumentScrollContent`'s+ push), so it does not work with no view mounted — acceptable because search+ navigation is only reachable through mounted UI, and highlight state was already+ view-fed by the same seam (T-1680).+- The reveal claim adds a second undelivered-navigation flag to the controller;+ both follow the same raise/release rules, documented side by side.+- A match inside a collapsed (zero-rect) section still silently fails to scroll —+ unchanged from before, tracked by T-1944.+- A user navigation that coalesces with a re-parse into the same SwiftUI update+ cycle loses its reveal: the push carries both the nonce bump and the revision+ change, and the revision veto outranks the nonce, so the match highlights but+ does not scroll. Inherited from the T-1775 veto rule (a re-parse must never+ masquerade as a navigation) and self-healing — the next navigation bumps the+ nonce within the new revision and reveals normally.++### Impact++`prism-search.js`, `WebBridgeContract`, `WebDocumentController` (claim + snapshot+replay), `WebDocumentControllerFactory.pushSearchState`, `DocumentScrollContent` /+`WebSearchStateKey`, `WebDocumentStateSynchronizer` (search branch removed), and the+search/precedence test suites. Regression suite:+`prismTests/WebRendering/WebSearchScrollOwnershipTests.swift`.++---
The live-page tests ran on macOS (WebDocumentLiveHarness). The viewport-third arithmetic uses window.innerHeight and unpinned scrollTo, which should behave identically in iOS WKWebView, but a quick manual next/previous sweep on an iPhone before release would confirm no safe-area/keyboard-inset interaction with the search overlay.
By construction the reveal replays last and wins. If a user ever reports “I tapped a TOC entry while searching and it jumped to the match instead”, this is the ordering to revisit — scheduleSnapshotReplay in WebDocumentController.swift:551-569.
Unchanged and tracked, but note the fix's shape interacts: onMatchSelected → expandAncestorsForCurrentMatch races the reveal in the DOM, so T-1944's eventual fix likely needs the reveal to wait for (or retry after) the sections push.