prism branch T-1944 commits 4 files 7 touched lines +725 / -18

Pre-push review: T-1944/bugfix-hidden-section-rect-guards

PR #338 — hoists a shared bridge.isSectionRendered zero-rect predicate across the three geometric readers of section layout (scroll reporting, search windowing, restore), defers search reveals into collapsed sections until expansion, and invalidates deferred reveals on explicit navigation.

At a glance

  • prism-bridge.js gains isSectionRendered(section, rect?) — attribute check plus a deliberately conjunctive width===0 && height===0 rect test — and two listener registries: section-visibility changes and explicit navigation.
  • prism-scroll.js routes the per-tick topmost-block scan through the predicate (passing its already-computed rect), and scrollToBlock now falls back to the nearest rendered section (preceding first — the collapsed heading; else following — document top for a stale frontmatter-carrier id).
  • prism-search.js excludes non-laid-out sections from the viewport window, defers a reveal whose match section is hidden (revealPending), delivers it on the section-visibility notification, and drops it on any executed navigation command or newer state push.
  • prism-theme.js notifies sectionVisibilityChanged after every applied setSectionState; setDetailsState deliberately does not (nested <details> is tracked separately as T-1930).
  • 530-line live-WebPage regression suite covers all four deferral/invalidation interleavings, both restore fallbacks, and the windowing guard — driven through the real bundled document.css cascade.
  • Docs updated: CHANGELOG entry, T-1918 entry amended, and the specs/search Decision 8 known-open item marked closed.

Verdict

Ready to push

All four commits are coherent and mutually reinforcing: a failing-tests commit, the guard hoist, the invalidation hook, and interleaving coverage. The shared predicate genuinely replaces every per-site zero-rect test (no duplicates remain in the renderer scripts), the script load order guarantees registration-before-notification, and the native synchronizer's deliberate omission of search-match scrollToBlock (T-1918) means the new invalidation hook cannot self-invalidate the reveal it accompanies. SwiftLint is clean and the new 8-test live-WebPage suite passes on macOS (8/8 in this review's targeted run). Two minor observations were noted; neither blocks the push.

Review findings

3 raised · 1 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism can collapse a document section under its heading — the content is still in the page, just hidden. A hidden element reports its position and size as all zeros, which looks to the code exactly like "a box at the very top of the window". Three different features read those positions and were fooled: search thought a hidden match was already on screen and skipped scrolling to it; the highlight painter thought hidden matches were visible; and re-opening a document tried to scroll to a hidden block, which silently does nothing, leaving you at the top instead of where you were reading.

Why It Matters

Searching for text inside a collapsed section now actually takes you there: the section opens and the page scrolls to the match. Reopening a document after collapsing the section you were reading lands you on that section's heading instead of the top of the file.

Key Concepts

Bounding rect: the position/size box the browser reports for an element — all zeros when the element is hidden with display:none. Shared predicate: instead of each feature repeating the "is this really visible?" check (and some forgetting it), there is now one function they all call. Deferred reveal: when the scroll-to-match request arrives before the section has opened, the scroll is remembered and performed the moment the section becomes visible — unless you navigate somewhere else first, in which case it is thrown away so it cannot yank you back later.

Changes Overview

Four bundled renderer scripts change. prism-bridge.js hoists isSectionRendered(section, rect?): false when data-prism-section-hidden="true" or when the bounding rect is all-zero (conjunction — width AND height — because empty sections, hidden comments, closed details, and floated-only content can legitimately be zero in one dimension). It also adds two tiny listener registries: onSectionVisibilityChanged (notified by prism-theme.js after each applied setSectionState) and onExplicitNavigation (notified by prism-scroll.js when a scroll command actually executes).

Implementation Approach

The three readers route through the predicate: the per-scroll-tick topmost-block scan passes its precomputed rect so it stays one layout read per section; sectionInWindow rejects non-laid-out sections before the window bounds test; scrollCurrentIntoView defers instead of scrolling when the match's section is hidden, setting revealPending. Delivery happens in the section-visibility listener (which first re-runs renderHighlights so the freshly laid-out ranges exist, then consumes the flag). scrollToBlock on a hidden target falls back to the nearest rendered section, preceding-first.

Trade-offs

Deferral-plus-invalidation was chosen over ordering guarantees: bridge command arrival order between feature scripts is unspecified, so the reveal must survive landing before the ancestor-expansion push. The invalidation registry exists because that expansion push is Observation-driven and can lag arbitrarily — without it a stale reveal could fire long after the user navigated elsewhere. Invalidation is scoped to executed commands only: a no-op scrollToBlock (unresolvable id) returns before notifying, establishing no position authority.

Technical Deep Dive

The predicate's two clauses are defence in depth in opposite directions: the rect conjunction covers both hidden shapes today (collapsed-section content via data-prism-section-hidden + document.css, and the frontmatter carrier's bare hidden attribute), while the attribute check survives a future move of the collapse rule to visibility/content-visibility, which leave rects non-zero. The comment explicitly walks the display:contents hole — a contents element's own rect is all-zero while children paint — and notes no stylesheet rule currently produces it on a block <section>.

Architecture Impact

Registration order is safe by construction: user scripts inject bridge → scroll → theme → … → search, and notifications only fire from command handlers, which run after all scripts load. Critically, WebDocumentStateSynchronizer deliberately sends no scrollToBlock for search navigation (T-1918 single-scroll-owner), so the reveal push and the invalidation hook can never race each other from the same user action. revealPending re-arms if a visibility notification finds the match's section still hidden (unrelated toggle), and is reset by every applyState so only the state that requested a reveal may deliver it.

Potential Issues

A manual drag/wheel scroll between deferral and expansion does not invalidate the pending reveal — only bridge commands do. The window is normally a frame or two (native always expands ancestors on the same navigation), and delivering the reveal is that navigation's intent, so this is acceptable; it would only surface if the expansion push lagged pathologically. setDetailsState changes layout without notifying — deliberate, deferred to T-1930. If renderHighlights fails to register the current range after expansion (degenerate rangeFor failure), the consumed reveal is silently lost; no worse than the pre-fix behaviour.

Important changes — detailed

prism-bridge.js: shared isSectionRendered predicate + two listener registries

prism-bridge.js

Why it matters. The single point every geometric reader must route through; the conjunction-vs-disjunction choice and the display:contents caveat are the correctness core of the whole PR.

What to look at. prism-bridge.js:92-124 (isSectionRendered), 126-160 (listener registries)

Takeaway. When several call sites share a subtle boolean test, hoist it with the rationale attached to the predicate, not the call sites — and accept an optional precomputed input (the rect) so the hot path pays one layout read.
Rationale. A display:none section's all-zero rect passes every naive geometric test (top-of-viewport contest, window bounds, already-on-screen, scrollIntoView no-op); one shared predicate stops a future feature from quietly reintroducing the assumption.

prism-scroll.js: scrollToBlock fallback to the nearest rendered section

prism-scroll.js

Why it matters. User-visible restore behaviour: a since-collapsed reading position lands on the collapsed heading; a stale pre-T-1851 frontmatter-carrier id falls back to the document top instead of silently no-opping.

What to look at. prism-scroll.js:164-208 (nearestRenderedSection + scrollToBlock)

Takeaway. scrollIntoView on a display:none element does nothing — check layout before issuing it and pick a principled fallback (preceding-first, because the hider is the closest surviving anchor).
Rationale. Preceding-first lands on the heading that hides the target — the closest the stored position can still be; following covers the frontmatter carrier at sections[0] so the fallback is the document top.

prism-search.js: deferred reveal with revealPending

prism-search.js

Why it matters. Closes the known-open item from specs/search Decision 8: navigating to a match in a collapsed section now scrolls once the section expands, whichever bridge-command order the race produces.

What to look at. prism-search.js:342-407 (revealPending, sectionOfRange, scrollCurrentIntoView), 463-475 (visibility listener)

Takeaway. When two async pushes have unspecified arrival order, don't assume an order — hold the intent and retry on the event that makes it satisfiable, re-arming if it still isn't.
Rationale. Native always expands ancestors for the current match, but the reveal push can land first; the current range's zero rect used to read as already-on-screen and the scroll was silently dropped.

prism-search.js + prism-scroll.js: explicit-navigation invalidation of stale reveals

prism-search.js

Why it matters. The stale-reveal coordination gap found in local review round two: the expansion push is Observation-driven and can lag arbitrarily, so a deferred reveal must not clobber a position the user chose in the gap.

What to look at. prism-search.js:359 (listener); prism-scroll.js:198-204, 211, 221 (notify sites)

Takeaway. Model deferred UI intent versus position authority explicitly: intent survives no-ops (a failed scrollToBlock notifies nothing) but yields to any executed navigation.
Rationale. scrollToBlock notifies only after its resolution guards — a target that cannot be resolved establishes no new position and must not discard the reveal; edge/page scrolls have no no-op path and always notify.

WebHiddenSectionGuardTests: 7 live-WebPage tests over the real CSS cascade

WebHiddenSectionGuardTests.swift

Why it matters. Regression fence for all three readers and all four deferral/invalidation interleavings (deliver, intervening navigation, no-op navigation, paging), plus once-only delivery and both restore fallbacks — 8 tests, written failing-first (commit ec2dc73).

What to look at. prismTests/WebRendering/WebHiddenSectionGuardTests.swift (whole file)

Takeaway. Inject the real bundled document.css into the harness page so the actual cascade — not a hand-rolled inline style — governs visibility; a hand-rolled style would pass against a rule the app never ships.
Rationale. The interleavings are the spec of the invalidation contract; the no-op case pins the notify call's position after the resolution guards, which a mutation would silently break.

Key decisions

The rect test stays a conjunction (width === 0 AND height === 0).

Empty sections, hidden HTML comments, closed <details>, and floated-only content can all legitimately measure zero in one dimension; a disjunction would wrongly classify them as not rendered. The display:contents counter-case (all-zero rect, children still paint) is documented as out of scope because no stylesheet rule produces it on a block section.

Attribute check kept alongside the rect test as defence in depth.

data-prism-section-hidden keeps the predicate working if the collapse rule ever moves to visibility/content-visibility, which leave the rect non-zero.

Deferral + invalidation instead of ordering guarantees between bridge commands.

Bridge-command arrival order between feature scripts is unspecified, so the reveal retries on the section-visibility notification instead of assuming the expansion push lands first. Because that push is Observation-driven and can lag arbitrarily, any executed navigation command (scrollToBlock/scrollToEdge/scrollByPage) discards the pending reveal — a deferred reveal is position intent, not authority.

notifyExplicitNavigation fires only when the scroll actually executes.

Every no-op return in scrollToBlock (missing payload, unresolvable id, nothing rendered) sits before the notify call: a navigation that cannot execute establishes no new position and must not discard a pending reveal. Covered by the mutation-verified no-op test.

setDetailsState does not notify section-visibility listeners.

Matches inside nested collapsed <details> blocks are deliberately out of scope, tracked as T-1930 (stated in the test-file header and CHANGELOG).

Restore fallback prefers the preceding rendered section.

For content under a collapsed heading, the preceding rendered section is the heading that hides it — the closest the stored reading position can still be. The following-section fallback exists for the frontmatter carrier at sections[0], turning a stale stored id into a document-top restore.

Review findings

SeverityAreaFindingResolution
minorprism-search.js sectionInWindowsectionInWindow calls bridge.isSectionRendered(section) — which computes getBoundingClientRect — and then computes the same rect again on the next line. The predicate's optional precomputed-rect parameter exists exactly for this and is used by the topmost-block scan. Cost is negligible in practice (no layout dirtying between the two reads), but it is the one call site that ignores the predicate's own affordance.Report-only review: left as-is. One-line change (compute the rect first, pass it to the predicate) for a future pass.
minorprism-search.js reveal invalidation scopeA manual drag/wheel scroll between reveal deferral and the expansion push does not invalidate the pending reveal — only bridge commands notify onExplicitNavigation. The window is normally a frame or two (native expands ancestors on the same navigation) and delivering the reveal is that navigation's intent, so the residual is acceptable and arguably correct; noting it so the scoping is a recorded choice rather than an accident.Accepted behaviour; no change requested.
infoCross-script wiringVerified: script injection order (bridge → scroll → theme → … → search) guarantees listener registration before any notification can fire; no zero-rect or data-prism-section-hidden test remains outside the predicate in the renderer scripts; WebDocumentStateSynchronizer sends no scrollToBlock for search navigation (T-1918), so the invalidation hook cannot self-invalidate the reveal it accompanies.No action needed.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 878195c..106b238 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,7 +18,8 @@ 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.+- Search and reading-position restore no longer lose their place to content the document hides (T-1944). Three faults shared one cause: hidden content — the body of a collapsed section, or the carrier holding a document's YAML frontmatter — measures as a zero-size box sitting exactly at the top of the window, and several parts of the app read that as "visible". Stepping to a search match inside a collapsed section silently did nothing: the section expanded, but the app had already treated the invisible match as on screen and skipped the scroll; the match is now brought to its usual resting place a third of the way down the window as soon as the section opens. Reopening a document after collapsing the section you were reading left the page at the top instead of anywhere near your place; the restore now lands on the collapsed heading that hides the saved block — and a stale saved position pointing at the hidden frontmatter carrier now falls back to the top of the document instead of doing nothing. Hidden matches also no longer count as on-screen when deciding which highlights to draw, and expanding a section lights its highlights up immediately instead of waiting for the next scroll. The zero-size test now lives in one shared check used by every reader of section layout, so a future feature cannot quietly reintroduce the assumption. Matches inside nested collapsed disclosure blocks (`<details>`) are tracked separately (T-1930).+- 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 initially still did not scroll; the hidden-content fix above (T-1944) closed that gap. - 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 (`&#91;^1&#93;`) 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.
prism/Resources/WebRenderer/prism-bridge.js Modified +72 / -0
diff --git a/prism/Resources/WebRenderer/prism-bridge.js b/prism/Resources/WebRenderer/prism-bridge.jsindex e1885d0..823ce88 100644--- a/prism/Resources/WebRenderer/prism-bridge.js+++ b/prism/Resources/WebRenderer/prism-bridge.js@@ -92,6 +92,72 @@         return { x: r.left, y: r.top, width: r.width, height: r.height };     } +    // Whether a block section is actually laid out (T-1944). A display:none+    // section reports an ALL-ZERO bounding rect, which every geometric reader+    // misreads: a top of 0 wins the "closest to the viewport top" contest+    // (T-1851), passes both viewport-window bounds (search windowing), reads as+    // "already on screen" (the reveal scroll), and scrollIntoView on it no-ops+    // (restore). Two kinds of section are display:none — content under a+    // collapsed heading (data-prism-section-hidden, hidden by document.css) and+    // the inert YAML-frontmatter carrier emitted with the bare `hidden`+    // attribute. This is the SINGLE shared predicate; readers must route through+    // it rather than repeating the rect test per site.+    //+    // The rect test MUST stay a conjunction (width === 0 AND height === 0):+    // empty sections, hidden HTML comments, closed details, and floated-only+    // content can all legitimately measure zero in ONE dimension, and a+    // disjunction would wrongly skip them. (display:contents is NOT such a+    // case: a contents element generates no box at all, so its rect is+    // all-zero and this predicate would classify it not-rendered even though+    // its children paint. No stylesheet rule puts display:contents on a block+    // <section>; if one ever does, the predicate needs a different signal for+    // it.) The attribute check is defence in depth — it keeps working if the+    // collapse rule ever moves to visibility/content-visibility, which leave+    // the rect non-zero.+    //+    // `rect` (optional) is a precomputed getBoundingClientRect() result for+    // `section`, so a caller that needs the rect anyway (the per-scroll-tick+    // topmost-block scan) reads layout once instead of twice.+    function isSectionRendered(section, rect) {+        if (!section) { return false; }+        if (section.getAttribute("data-prism-section-hidden") === "true") { return false; }+        if (!rect) { rect = section.getBoundingClientRect(); }+        return rect.width !== 0 || rect.height !== 0;+    }++    // Section-visibility change hook (T-1944). prism-theme.js notifies after+    // setSectionState applies a collapse/expand; listeners (prism-search.js)+    // re-derive anything they computed from section layout. Bridge-command+    // arrival order between feature scripts is unspecified, so state that must+    // survive an ordering race (a search reveal into a section whose expansion+    // push has not landed yet) retries here instead of assuming an order.+    var sectionVisibilityListeners = [];+    function onSectionVisibilityChanged(listener) {+        sectionVisibilityListeners.push(listener);+    }+    function notifySectionVisibilityChanged() {+        for (var i = 0; i < sectionVisibilityListeners.length; i++) {+            sectionVisibilityListeners[i]();+        }+    }++    // Explicit-navigation hook (T-1944). prism-scroll.js notifies after+    // executing a native scroll command (scrollToBlock — TOC/fragment/note+    // targets AND the stored-position restore — scrollToEdge, scrollByPage).+    // Listeners drop any position intent they are still holding: an explicit+    // navigation is the newest position authority, so deferred state like a+    // search reveal waiting on a section expansion must not fire later and+    // clobber the position the user (or the restore) just chose.+    var explicitNavigationListeners = [];+    function onExplicitNavigation(listener) {+        explicitNavigationListeners.push(listener);+    }+    function notifyExplicitNavigation() {+        for (var i = 0; i < explicitNavigationListeners.length; i++) {+            explicitNavigationListeners[i]();+        }+    }+     // The source map shipped as a JSON data island (anchor-based run map). Read     // once; selection mapping needs no bridge round-trip.     var sourceMap = (function () {@@ -144,6 +210,12 @@         clientRect: clientRect,         sourceMap: sourceMap,         registerCommand: registerCommand,+        // Shared layout predicate + section-visibility hook (T-1944).+        isSectionRendered: isSectionRendered,+        onSectionVisibilityChanged: onSectionVisibilityChanged,+        notifySectionVisibilityChanged: notifySectionVisibilityChanged,+        onExplicitNavigation: onExplicitNavigation,+        notifyExplicitNavigation: notifyExplicitNavigation,         // Generation helpers exposed for feature scripts and tests.         currentGeneration: currentGeneration,         generationIsCurrent: generationIsCurrent,
prism/Resources/WebRenderer/prism-scroll.js Modified +52 / -12
diff --git a/prism/Resources/WebRenderer/prism-scroll.js b/prism/Resources/WebRenderer/prism-scroll.jsindex 4efdda2..1dba1b0 100644--- a/prism/Resources/WebRenderer/prism-scroll.js+++ b/prism/Resources/WebRenderer/prism-scroll.js@@ -67,20 +67,16 @@      // The topmost block section currently intersecting the viewport top.     //-    // A display:none element's bounding rect is ALL ZEROS. A top of 0 satisfies the+    // A display:none section's bounding rect is ALL ZEROS. A top of 0 satisfies the     // "closest to the viewport top" test and beats every genuinely visible section,-    // which starts ABOVE the fold at a negative top — so without this filter a+    // which starts ABOVE the fold at a negative top — so without a guard a     // non-laid-out section wins and Prism persists a block the reader cannot see as-    // the reading position (T-1851). Two kinds of section are display:none:-    //   - content under a collapsed heading (prism-theme.js marks it with-    //     data-prism-section-hidden; document.css hides it), and-    //   - the inert YAML-frontmatter carrier, which BlockHTMLEmitter emits with the-    //     bare `hidden` attribute — so it is sections[0] and used to win at EVERY-    //     scroll offset in any document with frontmatter.-    // Skip anything that is not laid out, in the scan AND in the fallback. The rect-    // check alone covers both cases today; the attribute check is kept as defence in-    // depth, because it keeps working if the collapse rule ever moves to-    // visibility/content-visibility, which leave the rect non-zero.+    // the reading position (T-1851): content under a collapsed heading, and the+    // inert frontmatter carrier (sections[0] in any document with frontmatter,+    // which used to win at EVERY scroll offset). Skip anything that is not laid+    // out, in the scan AND in the fallback, via the shared predicate+    // bridge.isSectionRendered (T-1944) — the layout rationale and the+    // conjunction/attribute trade-offs are documented on the predicate.     function topmostBlockID() {         var sections = document.querySelectorAll("section[data-prism-block-id]");         var best = null;@@ -88,9 +84,10 @@         var firstRendered = null;         for (var i = 0; i < sections.length; i++) {             var section = sections[i];-            if (section.getAttribute("data-prism-section-hidden") === "true") { continue; }+            // One layout read per section: the rect feeds the predicate AND the+            // topmost test below (this scan runs on every scroll-debounce tick).             var rect = section.getBoundingClientRect();-            if (rect.width === 0 && rect.height === 0) { continue; }+            if (!bridge.isSectionRendered(section, rect)) { continue; }             if (!firstRendered) { firstRendered = section; }             // The block whose top is closest to (but not far below) the viewport top.             if (rect.top <= 1 && rect.top > bestTop) {@@ -164,16 +161,54 @@      // ---- Native → JS scroll commands ------------------------------------- +    // The nearest laid-out section to a display:none target (T-1944):+    // preceding first — for content under a collapsed heading that is the+    // heading that hides it, the closest the stored reading position can still+    // be — else following (the frontmatter carrier is sections[0], so a stale+    // pre-T-1851 stored id falls back to the first rendered section, the+    // document top). Null when nothing is rendered at all.+    function nearestRenderedSection(element) {+        var sections = document.querySelectorAll("section[data-prism-block-id]");+        var index = -1;+        for (var i = 0; i < sections.length; i++) {+            if (sections[i] === element) { index = i; break; }+        }+        if (index < 0) { return null; }+        for (var before = index - 1; before >= 0; before--) {+            if (bridge.isSectionRendered(sections[before])) { return sections[before]; }+        }+        for (var after = index + 1; after < sections.length; after++) {+            if (bridge.isSectionRendered(sections[after])) { return sections[after]; }+        }+        return null;+    }+     bridge.registerCommand("scrollToBlock", function (payload) {         if (!payload || !payload.domID) { return null; }         var element = document.getElementById(payload.domID);         if (!element) { return null; }+        // scrollIntoView on a display:none element does NOTHING, so a restore+        // (or navigation) into a since-collapsed block or the hidden+        // frontmatter carrier used to silently lose the position (T-1944).+        // Land on the nearest rendered section instead.+        if (!bridge.isSectionRendered(element)) {+            element = nearestRenderedSection(element);+            if (!element) { return null; }+        }+        // This scroll is the newest position authority (TOC/fragment/note+        // target, or the stored-position restore): tell listeners so deferred+        // position intent — a search reveal waiting on a section expansion —+        // is discarded instead of firing later and clobbering it (T-1944).+        // Notified only when the scroll actually executes: a target that+        // cannot be resolved establishes no new position.+        bridge.notifyExplicitNavigation();         beginProgrammaticScroll();         element.scrollIntoView({ block: "start", behavior: "auto" });         return true;     });      bridge.registerCommand("scrollToEdge", function (payload) {+        bridge.notifyExplicitNavigation();         beginProgrammaticScroll();         var top = payload && payload.edge === "bottom"             ? document.documentElement.scrollHeight@@ -183,6 +218,7 @@     });      bridge.registerCommand("scrollByPage", function (payload) {+        bridge.notifyExplicitNavigation();         beginProgrammaticScroll();         var delta = document.documentElement.clientHeight * 0.9;         if (payload && payload.direction === "up") { delta = -delta; }
prism/Resources/WebRenderer/prism-search.js Modified +63 / -3
diff --git a/prism/Resources/WebRenderer/prism-search.js b/prism/Resources/WebRenderer/prism-search.jsindex e951b88..f418d4e 100644--- a/prism/Resources/WebRenderer/prism-search.js+++ b/prism/Resources/WebRenderer/prism-search.js@@ -208,8 +208,13 @@         }     } -    // Whether a section is within the viewport window (± margin).+    // Whether a section is within the viewport window (± margin). A section+    // that is not laid out is NOT in the window: its all-zero rect would pass+    // both bounds below, wrongly registering matches hidden inside a collapsed+    // heading as if they were on screen (T-1944; the shared predicate is the+    // T-1851 zero-rect guard, hoisted onto the bridge).     function sectionInWindow(section) {+        if (!bridge.isSectionRendered(section)) { return false; }         var rect = section.getBoundingClientRect();         var viewportHeight = window.innerHeight || document.documentElement.clientHeight;         return rect.bottom >= -WINDOW_MARGIN && rect.top <= viewportHeight + WINDOW_MARGIN;@@ -334,12 +339,45 @@         }     } +    // True while a reveal is waiting for the current match's section to be laid+    // out (T-1944). A user navigation into a collapsed section races two bridge+    // commands whose order is unspecified: native ALWAYS expands the ancestors+    // (expandAncestorsForCurrentMatch → setSectionState), but the reveal push can+    // land first — and the current range's all-zero rect used to read as+    // "already on screen", silently dropping the scroll (the known-open item in+    // specs/search Decision 8). The reveal is deferred instead and delivered by+    // the section-visibility listener below. Superseded by any newer push+    // (applyState resets it) AND by any explicit navigation command — a TOC/+    // fragment/note scrollToBlock, the stored-position restore, or edge/page+    // scrolling (the bridge onExplicitNavigation hook) — because those are+    // newer position authorities: the ancestor-expansion push that delivers a+    // deferred reveal is Observation-driven and can lag arbitrarily, so+    // without the invalidation a stale reveal could fire after the user had+    // already navigated elsewhere and clobber that position.+    var revealPending = false;++    bridge.onExplicitNavigation(function () { revealPending = false; });++    // The section owning a registered range, for the layout check.+    function sectionOfRange(range) {+        var node = range.startContainer;+        var element = node.nodeType === 1 ? node : node.parentNode;+        if (!element || !element.closest) { return null; }+        return element.closest("section[data-prism-block-id]");+    }+     // 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).+    // A match whose section is not laid out yet defers the reveal (T-1944).     function scrollCurrentIntoView(state) {         if (currentRangeForScroll) {+            var section = sectionOfRange(currentRangeForScroll);+            if (section && !bridge.isSectionRendered(section)) {+                revealPending = true;+                return;+            }             var rect = currentRangeForScroll.getBoundingClientRect();             var viewportHeight = window.innerHeight || document.documentElement.clientHeight;             if (rect.top < 0 || rect.bottom > viewportHeight) {@@ -355,7 +393,14 @@             var entry = state.blocks[domID];             if (entry.current && entry.current.kind === "badge") {                 var badge = resolveCurrentBadge(domID, entry.current);-                if (badge) { badge.scrollIntoView({ block: "center", behavior: "auto" }); }+                if (badge) {+                    var badgeSection = badge.closest("section[data-prism-block-id]");+                    if (badgeSection && !bridge.isSectionRendered(badgeSection)) {+                        revealPending = true;+                        return;+                    }+                    badge.scrollIntoView({ block: "center", behavior: "auto" });+                }                 return;             }         }@@ -363,6 +408,9 @@      function applyState(state, reveal) {         activeState = state;+        // A new push supersedes any reveal still waiting on a section expansion+        // (T-1944): only the state that requested the reveal may deliver it.+        revealPending = false;         clearBadges();         if (!state || !state.query) {             clearHighlights();@@ -412,6 +460,20 @@         }, 80);     }, { passive: true }); +    // ---- Re-window on section collapse/expand (T-1944) -------------------+    // A collapse/expand changes which sections are laid out without any scroll+    // event: re-window so matches in a freshly expanded section highlight+    // immediately (and ranges in a freshly collapsed one drop out), and deliver+    // a reveal that was deferred because its section had not expanded yet.+    bridge.onSectionVisibilityChanged(function () {+        if (!activeState || !activeState.query) { return; }+        renderHighlights(activeState);+        if (revealPending) {+            revealPending = false;+            scrollCurrentIntoView(activeState);+        }+    });+     // Expose for tests (bridge world only).     bridge.searchActiveState = function () { return activeState; }; 
prism/Resources/WebRenderer/prism-theme.js Modified +3 / -0
diff --git a/prism/Resources/WebRenderer/prism-theme.js b/prism/Resources/WebRenderer/prism-theme.jsindex d2b5e1b..ff8a06b 100644--- a/prism/Resources/WebRenderer/prism-theme.js+++ b/prism/Resources/WebRenderer/prism-theme.js@@ -94,6 +94,9 @@             }         }         applySectionVisibility(sections);+        // Section layout changed: let geometric readers re-derive (T-1944 —+        // prism-search.js re-windows and delivers a deferred reveal here).+        bridge.notifySectionVisibilityChanged();         return true;     }); 
prismTests/WebRendering/WebHiddenSectionGuardTests.swift Added +530 / -0
diff --git a/prismTests/WebRendering/WebHiddenSectionGuardTests.swift b/prismTests/WebRendering/WebHiddenSectionGuardTests.swiftnew file mode 100644index 0000000..0c3a22b--- /dev/null+++ b/prismTests/WebRendering/WebHiddenSectionGuardTests.swift@@ -0,0 +1,530 @@+//+//  WebHiddenSectionGuardTests.swift+//  prismTests+//+//  T-1944 regression tests: every reader of "is this section actually laid out"+//  must route through the shared bridge predicate, not repeat the zero-rect test+//  (or forget it) per site.+//+//  A display:none section reports an ALL-ZERO bounding rect. T-1851 guarded the+//  visibleBlock reporter in prism-scroll.js, but the same unguarded assumption+//  remained in two other readers:+//+//    1. prism-search.js `sectionInWindow`: an all-zero rect passes both+//       viewport-window bounds, so a section hidden inside a collapsed heading is+//       treated as in-window — and `scrollCurrentIntoView` reads the current+//       range's zero rect as "already on screen" and silently never scrolls, the+//       known-open item recorded in specs/search decision 8 (T-1918).+//    2. prism-scroll.js `scrollToBlock` (the restore/navigation command):+//       `scrollIntoView` on a display:none element does nothing, so a restore+//       targeting a block the user has since collapsed (or the hidden+//       frontmatter carrier persisted by the pre-T-1851 bug) silently no-ops.+//+//  The fix hoists one predicate — `bridge.isSectionRendered(section)` — and+//  routes scroll reporting, search windowing, the reveal scroll, and the restore+//  fallback through it. The rect test stays a conjunction (width === 0 AND+//  height === 0): empty sections, hidden HTML comments, closed details, and+//  floated-only content can all legitimately measure zero in ONE dimension.+//+//  A deferred reveal is position INTENT, not authority: any explicit navigation+//  (scrollToBlock/scrollToEdge/scrollByPage) arriving before the expansion push+//  delivers it invalidates it — but only when the navigation actually executes+//  (a no-op scrollToBlock establishes no position and keeps the reveal) — and a+//  delivered reveal never fires again on later visibility notifications. All+//  four interleavings are covered below.+//+//  All tests drive a real WebPage with the bundled document.css injected, so the+//  real cascade — not a hand-rolled inline style — governs visibility.+//++import Foundation+import Testing+import WebKit+@testable import prism++@MainActor+struct WebHiddenSectionGuardTests {++    // MARK: - Harness++    /// The harness scheme handler cannot serve the linked stylesheet, so the+    /// bundled document.css is injected directly (mirrors+    /// `WebCollapsedSectionScrollTests.makeStyledHarness`).+    private static func makeStyledHarness(+        blocks: [MarkdownBlock],+        featureScripts: [String]+    ) async throws -> WebDocumentLiveHarness {+        let harness = try await WebDocumentLiveHarness.make(+            blocks: blocks, featureScripts: featureScripts+        )+        let cssURL = try #require(+            Bundle.main.url(forResource: "document", withExtension: "css"),+            "bundled document.css must be present in the test host"+        )+        let css = try String(contentsOf: cssURL, encoding: .utf8)+        _ = try await harness.page.callJavaScript(+            "var s = document.createElement('style'); s.textContent = css;"+                + " document.head.appendChild(s); return null;",+            arguments: ["css": css],+            contentWorld: harness.bridgeWorld+        )+        return harness+    }++    private static let searchScripts = ["prism-scroll", "prism-theme", "prism-search"]+    private static let scrollScripts = ["prism-scroll", "prism-theme"]++    private static let plainContext = SearchContext(showHTMLComments: false, footnoteData: .empty)++    /// A document whose ONLY "needle" match sits inside a collapsible H2 span far+    /// below the fold. `headingIndex` is the H2; the match is the block after it.+    private static func collapsedNeedleBlocks() -> (blocks: [MarkdownBlock], headingIndex: Int) {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Document")]+        for index in 0..<60 {+            blocks.append(.paragraph(markdown:+                "Leading filler paragraph \(index) with a good amount of running "+                + "text so the collapsible span sits far below the fold."+            ))+        }+        let headingIndex = blocks.count+        blocks.append(.heading(level: 2, text: "Collapsible section"))+        blocks.append(.paragraph(markdown: "The needle match lives inside the collapsed span."))+        for index in 0..<20 {+            blocks.append(.paragraph(markdown:+                "Trailing filler paragraph \(index) so the match is not pinned at "+                + "the document end and the viewport-third geometry is achievable."+            ))+        }+        return (blocks, headingIndex)+    }++    private func domIDs(_ blocks: [MarkdownBlock]) -> [String] {+        BlockDOMID.map(blocks: blocks).map(\.domID)+    }++    /// The composite section id `{hash}-{sourceIndex}` setSectionState matches on.+    private func sectionID(_ blocks: [MarkdownBlock], _ index: Int) -> String {+        "\(blocks[index].id)-\(index)"+    }++    /// 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 read from the live page: the bounding rect of+    /// the prism-search-current range (mirrors WebSearchScrollOwnershipTests).+    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])+    }++    private func scrollY(_ harness: WebDocumentLiveHarness) async throws -> Double {+        Double(try await harness.evalString("return String(window.scrollY);") ?? "0") ?? 0+    }++    private func hiddenSectionCount(_ harness: WebDocumentLiveHarness) async throws -> Int {+        let result = try await harness.evalString(+            "return String(document.querySelectorAll("+                + "'section[data-prism-section-hidden=\"true\"]').length);"+        )+        return Int(result ?? "0") ?? 0+    }++    // MARK: - Search windowing (prism-search.js sectionInWindow)++    // A section hidden by a collapsed heading reports an all-zero rect, which+    // passes BOTH viewport-window bounds (bottom >= -margin, top <= height ++    // margin) — so before the fix its matches were registered as if the section+    // were on screen. A hidden section is not in the window: no range of a+    // display:none subtree can ever paint.+    @Test("Matches inside a collapsed section are not treated as in-window")+    func hiddenSectionMatchesAreNotRegisteredAsInWindow() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // No current match: the only match sits in the hidden section, and the+        // current-match window bypass (T-1918) must not mask the window test.+        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: nil), reveal: false+        ))+        try await Task.sleep(for: .milliseconds(200))++        // Expected: no range registered — the hidden section is not in-window.+        // Actual (bug): the zero rect passes both bounds and the match registers.+        let count = try await harness.evalString(+            "return String(window.__prismBridge.searchHighlightCount());"+        )+        #expect(count == "0", "a hidden section's matches must not register as in-window, got \(count ?? "nil")")+    }++    // MARK: - Reveal scroll into a collapsed section (specs/search decision 8 known-open item)++    // Navigating to a match inside a collapsed section races the native+    // ancestor-expansion push (setSectionState) against the reveal push+    // (setSearchState reveal:true) — command order is unspecified. When the+    // reveal lands first, the current range's rect is all zeros, which the+    // pre-fix geometry read as "already on screen": the reveal silently no-ops+    // and the later expansion never retries. The fix defers the reveal until the+    // section is rendered, delivered on the section-visibility change.+    @Test("A reveal into a still-collapsed section scrolls once the section expands")+    func revealIntoCollapsedSectionScrollsAfterExpansion() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // The user navigation reveal arrives while the section is still hidden.+        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+        ))+        try await Task.sleep(for: .milliseconds(200))++        // The native expansion push (expandAncestorsForCurrentMatch → the+        // synchronizer's setSectionState) lands after the reveal.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the deferred reveal fires and the match rests at the viewport+        // third. Actual (bug): the zero-rect read as visible, nothing scrolls.+        let geometry = try #require(+            try await currentMatchGeometry(harness),+            "the current match must have a registered range after expansion"+        )+        #expect(geometry.scrollY > 0, "the deferred reveal must scroll the document")+        #expect(+            geometry.rectTop >= 0 && geometry.rectTop <= geometry.innerHeight * 0.6,+            "the match must rest approximately centred (viewport third), got top \(geometry.rectTop) of \(geometry.innerHeight)"+        )+    }++    // A deferred reveal must not outlive a NEWER explicit navigation: the+    // ancestor-expansion push that delivers it is Observation-driven and can lag+    // arbitrarily, so the user can navigate elsewhere (TOC entry, footnote/note+    // target — all scrollToBlock) in the gap. The navigation invalidates the+    // pending reveal (bridge onExplicitNavigation); when the expansion push+    // finally lands, the position must stay at the navigation target instead of+    // jumping back to the stale search match.+    @Test("An explicit navigation between deferral and expansion discards the reveal")+    func interveningNavigationDiscardsDeferredReveal() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )+        let ids = domIDs(blocks)++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // The reveal arrives while the section is still hidden: deferred.+        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+        ))+        try await Task.sleep(for: .milliseconds(200))++        // The user navigates elsewhere before the expansion push lands. The+        // target is an early filler block, far above the collapsed match.+        let navigationTarget = ids[10]+        try await harness.send(.scrollToBlock(domID: navigationTarget))+        try await Task.sleep(for: .milliseconds(300))++        // The lagging ancestor-expansion push finally arrives.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the position stays at the navigation target; the stale+        // reveal was discarded. Actual (bug): the reveal fires on the+        // visibility notification and jumps back to the search match.+        let targetTop = try await harness.page.callJavaScript(+            "var el = document.getElementById(id);"+                + " return el ? String(el.getBoundingClientRect().top) : 'missing';",+            arguments: ["id": navigationTarget],+            contentWorld: harness.bridgeWorld+        ) as? String+        let targetTopValue = try #require(targetTop, "target rect must be readable")+        let top = try #require(Double(targetTopValue), "target rect must be numeric")+        #expect(abs(top) <= 40, "the explicit navigation target must keep the viewport, got top \(top)")++        // The match's range exists after expansion (current-match bypass), but+        // it must still be far below the viewport — un-revealed.+        let geometry = try #require(+            try await currentMatchGeometry(harness),+            "the current match must have a registered range after expansion"+        )+        #expect(+            geometry.rectTop > geometry.innerHeight,+            "the discarded reveal must not bring the match on screen, got top \(geometry.rectTop) of \(geometry.innerHeight)"+        )+    }++    // A deferred reveal delivers exactly once. Section-visibility notifications+    // fire on EVERY setSectionState (prism-theme notifies unconditionally), so a+    // reveal that stayed armed after delivery would re-scroll the document back+    // to the match on any later collapse/expand — long after the user moved on.+    @Test("A deferred reveal is not delivered twice by a later visibility change")+    func deferredRevealDeliversOnlyOnce() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+        ))+        try await Task.sleep(for: .milliseconds(200))++        // First expansion: the deferred reveal delivers.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))+        #expect(try await scrollY(harness) > 0, "the deferred reveal must deliver on the first expansion")++        // The user scrolls back to the top; the reveal has been consumed.+        _ = try await harness.page.callJavaScript(+            "window.scrollTo({ top: 0, behavior: 'auto' }); return null;",+            contentWorld: harness.bridgeWorld+        )+        try await Task.sleep(for: .milliseconds(300))+        #expect(try await scrollY(harness) == 0, "the setup scroll must return to the top")++        // A later section-state push fires the visibility notification again.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the position is untouched — the reveal delivered once and+        // only once. Actual (bug): a still-armed reveal re-scrolls to the match.+        let after = try await scrollY(harness)+        #expect(after < 100, "a delivered reveal must not fire again, got scrollY \(after)")+    }++    // The inverse of the invalidation contract: a scrollToBlock that CANNOT+    // execute (unresolvable domID — every no-op return in prism-scroll.js+    // scrollToBlock sits before the notifyExplicitNavigation call) establishes+    // no new position authority, so it must NOT discard a pending reveal. The+    // reveal still delivers when the expansion notification arrives.+    @Test("A no-op scrollToBlock does not discard a pending deferred reveal")+    func noOpScrollToBlockKeepsDeferredReveal() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // The reveal arrives while the section is still hidden: deferred.+        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+        ))+        try await Task.sleep(for: .milliseconds(200))++        // A scrollToBlock whose target does not exist in the document: the+        // command returns before notifying — no scroll executes, so no new+        // position authority is established.+        try await harness.send(.scrollToBlock(domID: "b-0000000000000000-9999"))+        try await Task.sleep(for: .milliseconds(300))+        #expect(try await scrollY(harness) == 0, "the no-op scroll must not move the document")++        // The lagging ancestor-expansion push arrives.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the reveal survived the no-op and delivers — the match+        // rests at the viewport third. Actual (bug: notify above the guards):+        // the failed scroll cleared revealPending and nothing scrolls.+        let geometry = try #require(+            try await currentMatchGeometry(harness),+            "the current match must have a registered range after expansion"+        )+        #expect(geometry.scrollY > 0, "the surviving reveal must scroll the document")+        #expect(+            geometry.rectTop >= 0 && geometry.rectTop <= geometry.innerHeight * 0.6,+            "the match must rest approximately centred (viewport third), got top \(geometry.rectTop) of \(geometry.innerHeight)"+        )+    }++    // Keyboard paging/edge navigation is explicit navigation too: unlike+    // scrollToBlock it has no no-op path, so it ALWAYS establishes a new+    // position authority and must discard a pending reveal — otherwise the+    // expansion push would later yank a reader who paged away back to the+    // stale match.+    @Test("Paging between deferral and expansion discards the reveal")+    func pagingDiscardsDeferredReveal() async throws {+        let (blocks, headingIndex) = Self.collapsedNeedleBlocks()+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.searchScripts+        )++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // The reveal arrives while the section is still hidden: deferred.+        try await harness.send(.setSearchState(+            json: stateJSON(blocks: blocks, currentIndex: 0), reveal: true+        ))+        try await Task.sleep(for: .milliseconds(200))++        // The user pages down before the expansion push lands.+        try await harness.send(.scrollByPage(direction: .down))+        try await Task.sleep(for: .milliseconds(300))+        let pagedY = try await scrollY(harness)+        #expect(pagedY > 0, "the page-down must move the document")++        // The lagging ancestor-expansion push finally arrives. The collapsed+        // section sits far below the paged viewport, so expanding it cannot+        // shift the content above it.+        try await harness.send(.setSectionState(collapsedIDs: []))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the position stays where paging put it; the stale reveal+        // was discarded. Actual (bug): the reveal fires on the visibility+        // notification and jumps to the search match.+        let after = try await scrollY(harness)+        #expect(+            abs(after - pagedY) <= 50,+            "the paged position must keep the viewport, got \(after) after paging to \(pagedY)"+        )++        // The match's range exists after expansion (current-match bypass), but+        // it must still be far below the viewport — un-revealed.+        let geometry = try #require(+            try await currentMatchGeometry(harness),+            "the current match must have a registered range after expansion"+        )+        #expect(+            geometry.rectTop > geometry.innerHeight,+            "the discarded reveal must not bring the match on screen, got top \(geometry.rectTop) of \(geometry.innerHeight)"+        )+    }++    // MARK: - Restore into a collapsed block (prism-scroll.js scrollToBlock)++    // The reader collapses the section holding the stored top block, then+    // reloads. `scrollIntoView` on the display:none target does nothing, so the+    // pre-fix restore silently left the document at the top. The fallback lands+    // on the nearest PRECEDING rendered section — the collapsed heading that+    // hides the target — keeping the reading position as close as it can be.+    @Test("A restore targeting a collapsed block lands on its collapsed heading")+    func restoreToCollapsedBlockFallsBackToTheHeading() async throws {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Document")]+        for index in 0..<40 {+            blocks.append(.paragraph(markdown:+                "Leading filler paragraph \(index) with running text for height."+            ))+        }+        let headingIndex = blocks.count+        blocks.append(.heading(level: 2, text: "Collapsible section"))+        let hiddenIndex = blocks.count + 4+        for index in 0..<10 {+            blocks.append(.paragraph(markdown:+                "Collapsed content paragraph \(index) with running text for height."+            ))+        }+        // A same-level heading ENDS the collapsed span: without it the collapse+        // would swallow the whole document tail and the collapsed heading could+        // only clamp near the bottom of a much shorter document.+        blocks.append(.heading(level: 2, text: "Trailing section"))+        for index in 0..<40 {+            blocks.append(.paragraph(markdown:+                "Trailing filler paragraph \(index) with running text for height."+            ))+        }+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.scrollScripts+        )+        let ids = domIDs(blocks)++        try await harness.send(.setSectionState(collapsedIDs: [sectionID(blocks, headingIndex)]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // The restore command targets a block hidden inside the collapsed span.+        try await harness.send(.scrollToBlock(domID: ids[hiddenIndex]))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the scroll lands on the collapsed heading (nearest preceding+        // rendered section). Actual (bug): scrollIntoView no-ops and the+        // document stays at the top.+        #expect(try await scrollY(harness) > 0, "the restore fallback must scroll the document")+        let headingTop = try await harness.page.callJavaScript(+            "var el = document.getElementById(id);"+                + " return el ? String(el.getBoundingClientRect().top) : 'missing';",+            arguments: ["id": ids[headingIndex]],+            contentWorld: harness.bridgeWorld+        ) as? String+        let headingTopValue = try #require(headingTop, "heading rect must be readable")+        let top = try #require(Double(headingTopValue), "heading rect must be numeric")+        // Small tolerance for any scroll-margin the stylesheet applies.+        #expect(abs(top) <= 40, "the fallback must land on the collapsed heading, got top \(top)")+    }++    // The stale-stored-id shape of the same defect: the pre-T-1851 bug persisted+    // the hidden frontmatter carrier (sections[0], `hidden` attribute, no+    // data-prism-section-hidden) as the reading position at every scroll offset.+    // Restoring such an id must fall back to the first rendered section (the+    // document top) instead of silently keeping whatever position the page is at.+    @Test("A restore targeting the hidden frontmatter carrier falls back to the document top")+    func restoreToFrontmatterCarrierFallsBackToTheTop() async throws {+        var blocks: [MarkdownBlock] = [+            .metadata(content: "title: Document"),+            .heading(level: 1, text: "Document"),+        ]+        for index in 0..<60 {+            blocks.append(.paragraph(markdown:+                "Filler paragraph \(index) with running text for height."+            ))+        }+        let harness = try await Self.makeStyledHarness(+            blocks: blocks, featureScripts: Self.scrollScripts+        )+        let ids = domIDs(blocks)++        // Scroll away from the top first, so the silent no-op is observable.+        _ = try await harness.page.callJavaScript(+            "var el = document.getElementById(id); el.scrollIntoView({ block: 'start' }); return null;",+            arguments: ["id": ids[40]],+            contentWorld: harness.bridgeWorld+        )+        try await Task.sleep(for: .milliseconds(200))+        let scrolledAway = try await scrollY(harness)+        #expect(scrolledAway > 500, "the setup scroll must move the document, got \(scrolledAway)")++        try await harness.send(.scrollToBlock(domID: ids[0]))+        try await Task.sleep(for: .milliseconds(300))++        // Expected: the fallback lands on the first rendered section — the+        // document top, modulo the document's own top padding. Actual (bug):+        // scrollIntoView on the [hidden] carrier does nothing and the page+        // stays where the setup scroll left it.+        let restored = try await scrollY(harness)+        #expect(restored < 100, "the carrier restore must fall back to the document top, got scrollY \(restored)")+    }+}
specs/search/decision_log.md Modified +4 / -1
diff --git a/specs/search/decision_log.md b/specs/search/decision_log.mdindex a234cde..ea23acc 100644--- a/specs/search/decision_log.md+++ b/specs/search/decision_log.md@@ -346,7 +346,10 @@ subsumes T-1839's core case. - 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.+  unchanged from before, tracked by T-1944. *Closed by T-1944*: the reveal is+  deferred when the current match's section is not laid out+  (`bridge.isSectionRendered`) and delivered by prism-search.js's+  section-visibility listener once the native ancestor-expansion push lands. - 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

Things to double-check

Expansion push that never arrives.

If native ever failed to expand the current match's ancestors, revealPending would stay armed until the next state push or navigation. The next visibility notification from an unrelated toggle re-defers (section still hidden), and a user manually expanding the match's own section delivers the reveal — which is reasonable. Nothing to fix, but worth remembering if expansion behaviour changes.

Live-WebPage tests use fixed sleeps (200–300 ms).

Consistent with the existing WebRendering suites (e.g. WebCollapsedSectionScrollTests); the full class passed in this review's targeted macOS run. If CI machines are slower, these are the usual suspects for flakes.