prism branch T-1851/bugfix-collapsed-heading-scroll-position PR #318 commits 2 + working tree files 4 touched lines +334 / -4 production code 1 JS function

Pre-push review: T-1851 collapsed-heading scroll position

The WebKit visible-block detector let display:none sections win the “topmost block” contest, so Prism persisted a block the reader could not see as the reading position. The fix filters non-laid-out sections; this review widened the fix’s documentation and coverage to the collapse-free case it also repairs.

At a glance

  • Root cause: a display:none element's bounding rect is all zeros. topmostBlockID() picked the greatest rect.top <= 1, and a top of 0 beats every genuinely visible section — those sit at a negative top once scrolled past.
  • Wider than the ticket: the YAML-frontmatter carrier is emitted with the bare hidden attribute and is sections[0], so every document with frontmatter reported it as the reading position at every scroll offset, collapsed or not. Verified empirically.
  • Regression tests are real: reverting prism-scroll.js to origin/main turns 3 of 4 tests red with the exact expected messages; they pass with the fix. Each runs in ~0.5s.
  • Efficiency: neutral to marginally positive. The attribute early-out saves a getBoundingClientRect per hidden section; the loop shape (O(n) rect reads behind a 120 ms debounce) is pre-existing and costs one layout flush, not n.
  • Not attributable to this branch: WebDocumentBridgeLiveTests.bridgePostsReady() fails identically with main's prism-scroll.js in place, and the 44 build warnings are all the pre-existing ImageDimension / Equatable isolation warning — no Swift production file is touched here.
  • Two follow-ups recommended (deliberately not fixed here): the restore side has no visibility check, and sectionInWindow in prism-search.js has the identical unguarded zero-rect assumption.

Verdict

Ready to push

The fix is correct, minimal, and genuinely regression-tested — I reverted prism-scroll.js to origin/main and confirmed three of the four tests turn red, then green again with the fix. No blockers, no correctness holes: I checked every way a section could measure zero (empty sections, hidden HTML comments, closed <details>, floated-only content) and the width === 0 && height === 0 conjunction correctly lets all of them through.

The one substantive finding was scope, not correctness: the same defect fires with no collapse at all on any document with YAML frontmatter, and that was neither documented nor covered. Fixed in this review — a fourth regression test (verified red on main), a corrected code comment, and a rewritten CHANGELOG entry. Two pre-existing gaps in neighbouring code are left as follow-ups rather than scope creep.

Review findings

13 raised · 5 fixed · 8 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism remembers where you were reading in a document so it can put you back there next time. To do that, the page constantly asks “which paragraph is currently sitting at the top of the window?” and saves that paragraph's identity.

The way it asked that question was broken. Every paragraph is measured by its distance from the top of the window — a paragraph you have scrolled past has a negative distance, because it is above the window. The code looked for the paragraph with the largest distance that was still at or above the top edge, which is the right idea.

The trouble is that hidden content measures as being at distance zero. Not “far away”, not “unknown” — exactly zero, which is bigger than every negative number. So a hidden paragraph always beat every real one, and Prism saved a paragraph you could not see as your reading position. Reopening the document then tried to scroll to something invisible, which does nothing at all.

The fix is to skip content the page is not actually laying out before comparing distances.

Why It Matters

Two everyday situations were affected. First, collapsing any section heading — from that moment your saved place was wrong. Second, and more common, any document whose file begins with a YAML frontmatter block: Prism keeps an invisible placeholder for that block, and it was always winning, in every such document, whether or not anything was collapsed. Reopening the file, coming back from raw-source view, or recovering after the renderer restarted would land you in the wrong place or simply do nothing.

Key Concepts

  • Bounding rect — the browser's measurement of where an element sits on screen. For hidden elements it is all zeros, which is what caused this.
  • display:none — the CSS instruction that removes an element from the page layout entirely, as opposed to merely making it transparent.
  • YAML frontmatter — the metadata block (title, tags, date) some markdown files start with. Prism does not display it, but keeps a hidden placeholder so the block still has a stable identity.

Changes Overview

One function changed: topmostBlockID() in prism/Resources/WebRenderer/prism-scroll.js, the bridge-world script that posts visibleBlock(domID:fraction:) to native on every debounced scroll. It now skips two classes of section before the comparison, in both the scan and the “nothing above the fold yet” fallback:

  • data-prism-section-hidden="true" — the marker applySectionVisibility in prism-theme.js writes for content under a collapsed heading, which document.css:544 turns into display:none.
  • any section whose rect is width === 0 && height === 0 — the general not-laid-out signature.

The fallback also changed from sections[0] to the first rendered section, tracked as firstRendered during the same pass.

Supporting changes: prismTests/WebRendering/WebCollapsedSectionScrollTests.swift (four live-WebPage tests over the real bundled document.css), a CHANGELOG entry, and a gotcha bullet in docs/agent-notes/scroll-persistence.md.

Implementation Approach

The two checks are not redundant by accident. Under today's CSS the rect check subsumes the attribute check — but the attribute check survives a change of collapse mechanism to visibility: hidden or content-visibility, which leave the rect non-zero. It also costs less than the rect read it replaces on hidden sections. That reasoning is now stated in the comment; the original “cheap early-out” framing undersold it.

The tests deliberately drive a real WebPage with the bundled stylesheet injected rather than asserting on emitted HTML. The bug lives in the cascade — a hand-rolled inline style would not reproduce the UA [hidden] rule that hides the frontmatter carrier, and that is precisely the case a synthetic test would have missed.

Trade-offs

The obvious alternative — break out of the loop once rect.top > 1, since sections are in document order — is valid today (sections are in-flow block boxes; document.css:253 adds only position: relative, and main is not a flex or grid container) and would turn the common case into O(1). It was correctly not taken. This bug is an implicit geometric assumption failing; adding a second load-bearing one in the same change is the wrong trade, and there is no measured problem to justify it behind a 120 ms debounce.

The other trade-off is what happens when nothing is rendered: the function returns null and posts nothing, keeping the last known position rather than persisting an unreachable id. That is a behaviour change from the old unconditional sections[0], and it is the right one — silence beats a wrong answer here.

Technical Deep Dive

The comparison was rect.top <= 1 && rect.top > bestTop with bestTop seeded at -Infinity. A zero rect satisfies both on the first hidden section encountered, and the strict > means a genuinely visible section sitting exactly at top === 0 cannot displace it either. So the winner was deterministic, not racy: the first non-laid-out section in document order won at every scroll offset.

That makes the frontmatter case the more severe instance. BlockHTMLEmitter.swift:194-198 emits .metadata as <section … data-prism-metadata hidden>; nothing in document.css overrides [hidden], so the UA rule applies. It carries no data-prism-section-hidden, it is sections[0], and there is no collapse involved — so every document with frontmatter has always reported the carrier, and every restore silently no-opped. Only the zero-rect half of the filter catches it, and only the firstRendered fallback change fixes the scroll-0 case. Empirically confirmed: the new test fails on origin/main at the scroll-0 assertion.

On the conjunction: && rather than || is load-bearing. A rendered section is an in-flow block box, so its width is the content width whenever the viewport is non-zero, but its height can legitimately be zero — an .htmlComment block with comments off hides the .prism-comment child, not the section. An || would drop those and lose a meaningful top. A closed <details> is also safe: the emitter puts <details> inside the section and nested children go through renderInnerBlock with no section of their own, so the section stays laid out around the summary. There is no display: contents, content-visibility, or contain anywhere in the stylesheet. The only false positive is a zero-width viewport, where the function reports nothing — the correct outcome.

Architecture Impact

The filter makes prism-scroll.js a third participant in a contract previously shared by prism-theme.js (writes data-prism-section-hidden) and document.css (acts on it), with no single owner for the attribute name. One reader is not enough to justify hoisting a bridge.isSectionRendered() predicate into prism-bridge.js; a second one would be.

That second reader is already latent. sectionInWindow at prism-search.js:143-148 computes rect.bottom >= -MARGIN && rect.top <= viewportHeight + MARGIN, which an all-zero rect satisfies on both bounds — so every collapsed section counts as in-window and gets its text nodes walked for Custom Highlight ranges that can never paint. Wasted work only, but scrollCurrentIntoView at :233-242 has the reachable consequence: a current match inside a collapsed section produces a zero rect, both out-of-view tests read false, and the scroll silently no-ops — the same failure class as T-1851.

Potential Issues

  • Restore side is still unguarded. DocumentScrollContent.swift:265-271 resolves the stored id via BlockDOMID.restoreDOMID(forStored:blocks:) with no visibility filter, and scrollToBlock returns true even when scrollIntoView was a no-op on a display:none element. Reachable two ways: a stale on-disk position written by a pre-fix build, or the user collapsing the heading containing the current top block with no subsequent scroll before a raw toggle or WebContent recovery replays it. BlockDOMID.navigationDOMID(forTarget:blocks:visibleSourceIndices:) already prefers a visible occurrence and WebDocumentStateSynchronizer.swift:311 already computes the visible source indices — the pieces exist.
  • Test-harness duplication is now at four copies. makeStyledHarness appears in this file plus HTMLCommentVisibilityLiveTests, DocumentCSSCodeBlockRulesTests, and DocumentCSSTableRulesTests; the new file's own doc comment admits the copy. It belongs on WebDocumentLiveHarness as an injectDocumentCSS option, together with a waitForMessage(type:after:) overload that would absorb the local waitForVisibleBlock.
  • One white-box assertion. zeroRectSectionIDs re-implements the production predicate in JS, so it cannot catch a change to that predicate — the behavioural assertions carry the real weight. It earns its place in the frontmatter test, where it pins which section is the zero-rect one.

Important changes — detailed

prism-scroll.js: skip non-laid-out sections in the topmost-block scan

prism-scroll.js

Why it matters. This is the entire production fix. A display:none rect is all zeros, and zero beats every negative top, so a section the reader cannot see won the contest at every scroll offset and got persisted as the reading position.

What to look at. prism-scroll.js:78-107, topmostBlockID()

Takeaway. getBoundingClientRect() on a display:none element returns all zeros, not null and not something obviously invalid. Any geometry comparison that treats 0 as a meaningful coordinate — and a viewport-top comparison does, because scrolled-past elements are negative — silently prefers hidden elements. Filter for laid-out-ness before you compare, never after.
Rationale. Two checks rather than one: the attribute check is the semantic signal and survives a change of collapse mechanism to visibility/content-visibility (which leave the rect non-zero); the rect check is the general not-laid-out signature and is the only one that catches the frontmatter carrier, which carries no attribute. The comment originally justified the attribute check on performance grounds; this review re-justified it as defence in depth, which is the real reason.

The fallback moved from sections[0] to the first rendered section

prism-scroll.js

Why it matters. sections[0] is the hidden frontmatter carrier in any document that has frontmatter, so the old fallback returned an unreachable id at scroll 0 — the single most common moment a position gets reported.

What to look at. prism-scroll.js:82, :90, :105 (firstRendered)

Takeaway. When a lookup can find nothing valid, returning null and posting nothing is often better than returning a plausible-looking default. Here silence preserves the last known good position; a default would clobber it with block zero.
Rationale. Reported nothing, rather than falling back to a hidden id, when the document has no laid-out section at all. The original comment claimed that branch was unreachable because applySectionVisibility always leaves the outermost collapsed heading visible — the invariant is real (verified in prism-theme.js:86-104) but the conclusion was wrong: a frontmatter-only document, or a zero-size viewport, reaches it. Corrected in this review.

New: the frontmatter carrier regression test (added in this review)

WebCollapsedSectionScrollTests.swift

Why it matters. It covers the collapse-free instance of the same defect — which affects far more documents than the ticket's reproduction — and it is the only test that exercises the fallback change. Confirmed red on origin/main at the scroll-0 assertion.

What to look at. WebCollapsedSectionScrollTests.swift:236-288, frontmatterCarrierIsNotReportedAsVisibleBlock()

Takeaway. When a bug is caused by the cascade, test through the cascade. This test only reproduces because the harness injects the real bundled document.css and the UA [hidden] rule applies — a synthetic inline style would have hidden nothing and the test would have passed against the broken code.
Rationale. Written after tracing BlockHTMLEmitter.swift:194-198 (.metadata emits `hidden`) and confirming document.css has no [hidden] override. Test height was tuned to 60 paragraphs after a first attempt at 40 hit scrollIntoView clamping and failed on a geometry artefact rather than the behaviour under test.

Three live-WebPage regression tests over the real stylesheet

WebCollapsedSectionScrollTests.swift

Why it matters. These are the branch's original coverage and they are genuine: reverting prism-scroll.js to origin/main turns collapsedSectionIsNotReportedAsVisibleBlock and fallbackSkipsHiddenSections red with the expected messages.

What to look at. WebCollapsedSectionScrollTests.swift:126-234

Takeaway. The third test is the one most reviewers would skip writing — a negative case pinning that the new heuristic does NOT skip a genuinely visible section, built from the sparsest blocks that still render. It passes on main by design, which is the point: it guards the fix, not the bug.
Rationale. Live WebPage over hand-rolled HTML because the display:none comes from the real cascade. Polling at 40 × 50ms mirrors WebDocumentLiveHarness.waitForMessage and early-exits on the first new report, so actual runtime is ~0.5s per test, not the 2s ceiling.

CHANGELOG and agent-note updates (revised in this review)

CHANGELOG.md

Why it matters. The original entry described only the collapse case, understating a fix that also repairs every frontmatter document. It also credited 'toggling raw source' with a symptom that only reaches the user through persistence.

What to look at. CHANGELOG.md:21, docs/agent-notes/scroll-persistence.md:38-53

Takeaway. The agent note records the gotcha as a class rather than a site, and names the one place it is still unguarded (prism-search.js sectionInWindow) — that is what makes it worth writing down instead of leaving to the inline comment.
Rationale. Following the T-1639 precedent: the previous fix to this same file also shipped no specs/bugfixes report and updated scroll-persistence.md instead.

Key decisions

Filter on both the attribute and the rect, not just one.

Under today's CSS the zero-rect check strictly subsumes the data-prism-section-hidden check, so the attribute test is redundant. It is kept anyway: it is the semantic signal, it costs less than the getBoundingClientRect it replaces on hidden sections, and it keeps working if the collapse rule ever moves to visibility: hidden or content-visibility, both of which leave the rect non-zero. The two cannot drift, since both derive from the same attribute.

<code>width === 0 &amp;&amp; height === 0</code>, not <code>||</code>.

A rendered section is an in-flow block box, so its width is the content width whenever the viewport is non-zero — but its height can legitimately be zero. An .htmlComment block with comments off hides the .prism-comment child, not the section; floated or absolutely-positioned-only content does the same. Those sections still carry a meaningful top and must be kept. minimalVisibleSectionIsNotSkipped() pins this.

Return nothing when nothing is rendered.

When no section is laid out at all — a frontmatter-only document, or a zero-size viewport — topmostBlockID() returns null and reportVisibleBlock posts nothing, so the last known position survives. The alternative (the old unconditional sections[0]) would overwrite a good restored position with block zero, which is the failure this function exists to prevent.

No early <code>break</code> on the first section below the fold.

Sections are in document order and in normal flow (document.css:253 adds only position: relative; main is a plain block container), so once hidden sections are filtered out the visible tops are monotonically non-decreasing and a break at rect.top > 1 would be correct today — turning the common case from O(n) into O(k). Not taken: there is no measured problem behind a 120 ms debounce, and this bug is an implicit geometric assumption failing. Adding a second load-bearing implicit assumption in the same change is the wrong trade. If wanted, it belongs in its own change with an explicit invariant test.

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

The convention exists but is decaying: only 3 of the last ~13 bugfix merges on main shipped one. The closest precedent is cbe23dd (T-1639), the previous fix to this exact file and subsystem, which shipped no report and updated docs/agent-notes/scroll-persistence.md instead. This branch now does the same, and the root cause, mechanism, and test rationale are all captured in the code comment, the test header, and the agent note.

(inferred — not stated by the author.)
Test-harness duplication left in place.

makeStyledHarness is now the fourth copy of the document.css injection helper (alongside HTMLCommentVisibilityLiveTests, DocumentCSSCodeBlockRulesTests, DocumentCSSTableRulesTests), and waitForVisibleBlock duplicates WebDocumentLiveHarness.waitForMessage with a count offset. Hoisting both onto the harness would touch four test files on a bugfix branch. Recommended as a standalone tidy-up instead.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorprism-scroll.js / CHANGELOG / test coverageThe defect is wider than the ticket describes and the branch documented. BlockHTMLEmitter emits a .metadata (YAML frontmatter) block as an inert <section ... hidden> carrier; document.css has no [hidden] override, so the UA rule makes it display:none. It carries no data-prism-section-hidden and it is sections[0], so its top of 0 won the contest at EVERY scroll offset — meaning every document with frontmatter always reported the frontmatter carrier as the reading position, with no collapse involved, and the restore silently no-opped. Neither the code comment, the CHANGELOG, nor any test mentioned this, and it is the only case that exercises the fallback change from sections[0] to firstRendered.Verified empirically (BlockHTMLEmitter.swift:194-198; no [hidden] rule in document.css). Added frontmatterCarrierIsNotReportedAsVisibleBlock() to WebCollapsedSectionScrollTests.swift, confirmed it fails on origin/main at the scroll-0 assertion and passes with the fix. Broadened the prism-scroll.js comment and rewrote the CHANGELOG entry to cover both causes.
minorprism-scroll.js:100 comment accuracyThe new comment claimed the firstRendered === null branch is 'today unreachable' because applySectionVisibility always leaves the outermost collapsed heading visible. The invariant is real (confirmed in prism-theme.js:86-104 — openLevels is empty at that setSectionHidden call) but the conclusion is false: a frontmatter-only document has exactly one section and it is hidden, and a zero-size viewport zeroes every rect. A comment that asserts an incorrect reachability claim is worse than no comment.Rewrote the comment to name the two reachable cases and keep the (correct) statement that silence is the intended behaviour there.
minorprism-scroll.js:87 comment rationaleThe attribute check was justified as a 'cheap early-out'. That is the weakest available justification — getBoundingClientRect is cheap once layout is clean, and the loop reads it n times against a single flush anyway. The real justification is defence in depth: if the collapse rule ever moved to visibility:hidden or content-visibility, the rect stays non-zero and only the attribute check would still work.Removed the misleading inline comment and folded the correct rationale into the function header comment.
minordocs/agent-notes/scroll-persistence.mdThe 'Web-Path Scroll Contract' section documents the reporting and restore halves and the clobber guards, but says nothing about how the reported block is chosen — so it was incomplete on exactly the point this fix turns on. The gotcha spans three files (prism-theme.js writes the attribute, document.css acts on it, prism-scroll.js must filter) and recurs elsewhere, which clears the CLAUDE.md bar for a note.Added a bullet to the web-path contract covering both display:none sources, why zero beats negative, and naming prism-search.js sectionInWindow as the place the same assumption is still unguarded.
minorCHANGELOG.md accuracyThe entry credited 'toggling raw source' as a symptom. The raw toggle restores from pendingRestorePercentage, which comes from documentScrollFraction() (window.scrollY-based) and was never touched by this bug. The symptom reaches the user only through persistence — rendered→raw stores the corrupt id as-is, and raw→rendered only rewrites it when the snapshot is non-zero.Reworded to 'returning from raw source', which is accurate, alongside the frontmatter broadening.
majorDocumentScrollContent.swift:265-271 / prism-scroll.js scrollToBlock (pre-existing)The restore side has no visibility check. The stored id is resolved via BlockDOMID.restoreDOMID(forStored:blocks:) with no filter, and scrollToBlock calls scrollIntoView on whatever getElementById returns and reports true even when the element is display:none — a silent no-op. Reachable via a stale on-disk position written by a pre-fix build, or when the user collapses the heading containing the current top block and no scroll event re-reports before a raw toggle or WebContent recovery replays the collapse state.Not fixed — out of scope for this bugfix, and the reporting-side fix removes the mechanism that creates new bad ids. Recommended follow-up ticket: route the restore through the visibility-aware BlockDOMID.navigationDOMID(forTarget:blocks:visibleSourceIndices:) (WebDocumentStateSynchronizer.swift:311 already computes the indices), or have scrollToBlock return false on a zero-rect target so native can fall back.
majorprism-search.js:143-148 sectionInWindow (pre-existing)The identical unguarded zero-rect assumption. sectionInWindow returns rect.bottom >= -MARGIN && rect.top <= viewportHeight + MARGIN, both of which an all-zero rect satisfies, so every collapsed section counts as in-window and renderHighlights walks its text nodes registering Custom Highlight ranges that can never paint. Wasted work only — but scrollCurrentIntoView (:233-242) is the reachable consequence: a current match inside a collapsed section produces a zero rect, both out-of-view tests read false, and 'scroll to current match' silently no-ops.Not fixed — widening this PR into the search path would obscure the bugfix. Recommended follow-up ticket. Recorded in docs/agent-notes/scroll-persistence.md so the next session touching either file finds it. This is also the second reader that would justify hoisting a shared bridge.isSectionRendered() predicate into prism-bridge.js.
majorprismTests/WebRendering test-harness reusemakeStyledHarness is now the fourth copy of the document.css injection helper (HTMLCommentVisibilityLiveTests.swift:25-54, DocumentCSSCodeBlockRulesTests.swift:36, DocumentCSSTableRulesTests.swift:40); the new file's doc comment explicitly says it mirrors the first. waitForVisibleBlock duplicates WebDocumentLiveHarness.waitForMessage(type:) with a count offset, an idiom also hand-rolled in WebScrollNavigationTests.swift:112-135.Not fixed — hoisting injectDocumentCSS() and a waitForMessage(type:after:) overload onto WebDocumentLiveHarness would touch four test files on a bugfix branch. Recommended as a standalone tidy-up.
minorWebCollapsedSectionScrollTests.swift white-box assertionzeroRectSectionIDs re-implements the production predicate (width === 0 && height === 0) in the test, so it cannot catch a change to that predicate — flipping && to || would keep it green. It also marshals results by joining on ',' and splitting in Swift rather than using the SpikeWebPageHarness.json decoder the suite already has.Kept. The behavioural assertions carry the weight in both tests that use it, and in the new frontmatter test it earns its place by pinning WHICH section is the zero-rect one. DOM ids are hex-and-dash, so the comma join is safe in practice.
minorspecs/bugfixes conventionRecent bugfix merges on main (6788b9c, 22fbdd9, e9d2fed) shipped a specs/bugfixes/<name>/report.md; this branch ships none.Skipped deliberately. The convention is decaying (3 of the last ~13 bugfix merges), and the closest precedent — cbe23dd/T-1639, the previous fix to this exact file — shipped no report and updated docs/agent-notes/scroll-persistence.md instead. This branch now matches that precedent.
minordocs/agent-notes/collapsible-sections.md (pre-existing)The note's Phase 4/5 sections still describe the retired SwiftUI collapse path — layouts rendering session.visibleBlocks, chevron.right heading rows, '··· N blocks hidden' indicators, scrollToHeadingIfNeeded. Collapse now happens in the page via prism-theme.js applySectionVisibility + setSectionState. It is the note a future session would read before touching collapse behaviour, i.e. exactly this ticket's area.Not fixed — pre-existing and unrelated to this diff; rewriting it is a separate piece of work. Recommended follow-up.
nitefficiencytopmostBlockID runs O(n) getBoundingClientRect calls over every section on a 120ms-debounced scroll handler. Pre-existing loop shape, unchanged here; and the cost is one layout flush plus n cheap rect reads, not n forced layouts, since nothing in the loop mutates the DOM.No action. The change is net positive: the attribute early-out SAVES a rect read per hidden section and costs one null-returning getAttribute per visible one.
nitverification — not attributable to this branchWebDocumentBridgeLiveTests.bridgePostsReady() fails in the WebRendering suite, and a clean iOS build emits 44 warnings (all 'main actor-isolated conformance of ImageDimension to Equatable').Both confirmed pre-existing: bridgePostsReady() fails identically with origin/main's prism-scroll.js swapped in, and no Swift production file is touched by this branch. Not attributable.

Per-file diffs

Click to expand.

prism/Resources/WebRenderer/prism-scroll.js Modified +34 / -4
diff --git a/prism/Resources/WebRenderer/prism-scroll.js b/prism/Resources/WebRenderer/prism-scroll.jsindex 76f0483..4efdda2 100644--- a/prism/Resources/WebRenderer/prism-scroll.js+++ b/prism/Resources/WebRenderer/prism-scroll.js@@ -66,20 +66,46 @@     }      // 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+    // "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+    // 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.     function topmostBlockID() {         var sections = document.querySelectorAll("section[data-prism-block-id]");         var best = null;         var bestTop = -Infinity;+        var firstRendered = null;         for (var i = 0; i < sections.length; i++) {-            var rect = sections[i].getBoundingClientRect();+            var section = sections[i];+            if (section.getAttribute("data-prism-section-hidden") === "true") { continue; }+            var rect = section.getBoundingClientRect();+            if (rect.width === 0 && rect.height === 0) { 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) {                 bestTop = rect.top;-                best = sections[i];+                best = section;             }         }-        // If nothing is above the fold yet, use the first section.-        if (!best && sections.length > 0) { best = sections[0]; }+        // If nothing is above the fold yet, use the first RENDERED section (before+        // T-1851 this was sections[0], which is the hidden frontmatter carrier in+        // any document with frontmatter). When NOTHING is rendered — a+        // frontmatter-only document, or a zero-size viewport — this deliberately+        // returns null and reportVisibleBlock posts nothing, keeping the last known+        // position rather than persisting an unreachable id. Silence is the intended+        // behaviour there, not a missing fallback.+        if (!best) { best = firstRendered; }         return best ? best.id : null;     } 
prismTests/WebRendering/WebCollapsedSectionScrollTests.swift Added +287
diff --git a/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swiftnew file mode 100644index 0000000..0ebb780--- /dev/null+++ b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift@@ -0,0 +1,287 @@+//+//  WebCollapsedSectionScrollTests.swift+//  prismTests+//+//  T-1851 regression tests: a section the reader cannot see must never be+//  reported as the reading position.+//+//  A display:none element's bounding rect is ALL ZEROS, so its `top` of 0+//  satisfies prism-scroll.js's "closest to the viewport top" test and beats every+//  genuinely visible section scrolled above the fold (negative top). Prism then+//  persists that block as the reading position and the restore either lands on the+//  wrong block or silently no-ops (scrollIntoView on a display:none element does+//  nothing). Two kinds of section are display:none: content under a collapsed+//  heading (`prism-theme.js` marks it `data-prism-section-hidden="true"`,+//  document.css hides it) and the inert YAML-frontmatter carrier that+//  `BlockHTMLEmitter` emits with the bare `hidden` attribute.+//+//  All four 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 WebCollapsedSectionScrollTests {++    // MARK: - Harness++    /// The harness scheme handler cannot serve the linked stylesheet, so the+    /// bundled document.css is injected directly (mirrors+    /// `HTMLCommentVisibilityLiveTests.makeStyledHarness`).+    private static func makeStyledHarness(blocks: [MarkdownBlock]) async throws -> WebDocumentLiveHarness {+        let harness = try await WebDocumentLiveHarness.make(blocks: blocks)+        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+    }++    /// A document with an early collapsible H2 span followed by a tall visible one.+    /// Index 1 is the H2 whose span (indices 2...21) collapses.+    private static func collapsibleBlocks() -> [MarkdownBlock] {+        var blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Document"),+            .heading(level: 2, text: "Early section"),+        ]+        for index in 0..<20 {+            blocks.append(.paragraph(markdown: "Early paragraph \(index) with running text for height."))+        }+        blocks.append(.heading(level: 2, text: "Later section"))+        for index in 0..<40 {+            blocks.append(.paragraph(markdown: "Later paragraph \(index) with running text for height."))+        }+        return blocks+    }++    private func domIDs(_ blocks: [MarkdownBlock]) -> [String] {+        BlockDOMID.map(blocks: blocks).map(\.domID)+    }++    /// Waits for a visibleBlock report beyond the ones already recorded and+    /// returns its DOM id.+    private func waitForVisibleBlock(+        _ harness: WebDocumentLiveHarness, after count: Int+    ) async throws -> String? {+        for _ in 0..<40 {+            let reports = harness.messages(type: "visibleBlock")+            if reports.count > count { return reports.last?["domID"] as? String }+            try await Task.sleep(for: .milliseconds(50))+        }+        return nil+    }++    /// `"hidden"` / `"visible"` / `"missing"` for the section with `domID`, read+    /// from the live DOM under the real stylesheet.+    private func visibility(+        _ harness: WebDocumentLiveHarness, of domID: String+    ) async throws -> String? {+        let result = try await harness.page.callJavaScript(+            "var el = document.getElementById(id); if (!el) { return 'missing'; }"+                + " var hidden = el.getAttribute('data-prism-section-hidden') === 'true'"+                + " || getComputedStyle(el).display === 'none';"+                + " return hidden ? 'hidden' : 'visible';",+            arguments: ["id": domID],+            contentWorld: harness.bridgeWorld+        )+        return result as? String+    }++    /// The DOM ids of every section the `rect.width === 0 && rect.height === 0`+    /// filter in `topmostBlockID()` would skip, read from the live DOM.+    private func zeroRectSectionIDs(_ harness: WebDocumentLiveHarness) async throws -> [String] {+        let result = try await harness.evalString(+            "var out = [];"+                + " var sections = document.querySelectorAll('section[data-prism-block-id]');"+                + " for (var i = 0; i < sections.length; i++) {"+                + "   var r = sections[i].getBoundingClientRect();"+                + "   if (r.width === 0 && r.height === 0) { out.push(sections[i].id); }"+                + " }"+                + " return out.join(',');"+        )+        guard let result, !result.isEmpty else { return [] }+        return result.components(separatedBy: ",")+    }++    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: - Tests++    // The reproduction from the ticket: an early collapsed heading, the reader+    // scrolled into a later visible section. The hidden sections' zero rects+    // must not win the topmost-block contest.+    @Test("visibleBlock never reports a section hidden by a collapsed heading")+    func collapsedSectionIsNotReportedAsVisibleBlock() async throws {+        let blocks = Self.collapsibleBlocks()+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        let ids = domIDs(blocks)++        // Collapse the early H2 (block index 1); its section id is "{hash}-{sourceIndex}".+        try await harness.send(.setSectionState(collapsedIDs: ["\(blocks[1].id)-1"]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        // Scroll a later, visible paragraph to the viewport top — a user scroll,+        // so no programmatic-scroll suppression is in play.+        let targetID = ids[27]+        let seen = harness.messages(type: "visibleBlock").count+        _ = try await harness.page.callJavaScript(+            "var el = document.getElementById(id); el.scrollIntoView({ block: 'start' }); return null;",+            arguments: ["id": targetID],+            contentWorld: harness.bridgeWorld+        )++        let reported = try #require(+            try await waitForVisibleBlock(harness, after: seen), "no visibleBlock was reported"+        )+        #expect(+            try await visibility(harness, of: reported) == "visible",+            "a hidden section was persisted as the reading position: \(reported)"+        )+        #expect(reported == targetID)+    }++    // The fallback ("nothing above the fold yet, use the first section") must+    // skip hidden sections too: with the only heading collapsed, every following+    // section is display:none and the heading itself sits below the 1px probe+    // line because of the document's top padding.+    @Test("the topmost-block fallback skips sections hidden by a collapsed heading")+    func fallbackSkipsHiddenSections() async throws {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Document")]+        for index in 0..<20 {+            blocks.append(.paragraph(markdown: "Paragraph \(index) with running text for height."))+        }+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        let ids = domIDs(blocks)++        try await harness.send(.setSectionState(collapsedIDs: ["\(blocks[0].id)-0"]))+        #expect(try await hiddenSectionCount(harness) > 0, "the collapse did not hide any section")++        let seen = harness.messages(type: "visibleBlock").count+        _ = try await harness.page.callJavaScript(+            "window.dispatchEvent(new Event('scroll')); return null;",+            contentWorld: harness.bridgeWorld+        )++        let reported = try #require(+            try await waitForVisibleBlock(harness, after: seen), "no visibleBlock was reported"+        )+        #expect(+            try await visibility(harness, of: reported) == "visible",+            "a hidden section was persisted as the reading position: \(reported)"+        )+        #expect(reported == ids[0])+    }++    // The negative case for the zero-rect heuristic: nothing is collapsed, and the+    // document is built from the sparsest blocks that still render (a thematic+    // break, a one-character paragraph). None of them may be mistaken for+    // display:none, and the minimal section at the viewport top must still be the+    // one reported. Sections are in-flow block boxes, so a visible one always has+    // a non-zero width even when its content is nearly empty — this pins that+    // assumption down rather than leaving it implicit.+    @Test("the zero-rect filter never skips a genuinely visible in-flow section")+    func minimalVisibleSectionIsNotSkipped() async throws {+        var blocks: [MarkdownBlock] = [.heading(level: 1, text: "Document")]+        for index in 0..<20 {+            blocks.append(.paragraph(markdown: "Filler paragraph \(index) with running text for height."))+        }+        // The sparsest renderable blocks, placed where a scroll can put them at the+        // viewport top.+        blocks.append(.thematicBreak)+        blocks.append(.paragraph(markdown: "x"))+        for index in 0..<20 {+            blocks.append(.paragraph(markdown: "Trailing paragraph \(index) with running text for height."))+        }+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        let ids = domIDs(blocks)++        // Nothing is collapsed, so no section may be filtered out as non-rendered.+        #expect(try await hiddenSectionCount(harness) == 0, "no section should be hidden here")+        let skipped = try await zeroRectSectionIDs(harness)+        #expect(skipped.isEmpty, "the zero-rect filter would skip visible sections: \(skipped)")++        // The minimal one-character paragraph, scrolled to the viewport top, must+        // still win the topmost-block contest.+        let targetID = ids[22]+        let seen = harness.messages(type: "visibleBlock").count+        _ = try await harness.page.callJavaScript(+            "var el = document.getElementById(id); el.scrollIntoView({ block: 'start' }); return null;",+            arguments: ["id": targetID],+            contentWorld: harness.bridgeWorld+        )++        let reported = try #require(+            try await waitForVisibleBlock(harness, after: seen), "no visibleBlock was reported"+        )+        #expect(reported == targetID, "a minimal visible section was skipped; reported \(reported)")+    }++    // The collapse-free instance of the same defect, and the only case that+    // exercises the fallback change (`sections[0]` → the first *rendered*+    // section). `BlockHTMLEmitter` emits a `.metadata` (YAML frontmatter) block as+    // an inert `<section … data-prism-metadata hidden>` carrier so the block keeps+    // a stable DOM node. Nothing in document.css overrides `[hidden]`, so the UA+    // rule makes it display:none — an all-zero rect on `sections[0]`, carrying no+    // `data-prism-section-hidden`. Before the fix its top of 0 won the contest at+    // EVERY scroll offset, so any document with frontmatter always reported the+    // frontmatter carrier as the reading position and the restore silently+    // no-opped. Only the zero-rect half of the filter catches this one.+    @Test("visibleBlock never reports the hidden frontmatter carrier")+    func frontmatterCarrierIsNotReportedAsVisibleBlock() async throws {+        var blocks: [MarkdownBlock] = [+            .metadata(content: "title: Document"),+            .heading(level: 1, text: "Document"),+        ]+        for index in 0..<60 {+            blocks.append(.paragraph(markdown: "Paragraph \(index) with running text for height."))+        }+        let harness = try await Self.makeStyledHarness(blocks: blocks)+        let ids = domIDs(blocks)++        // No collapse anywhere — the carrier is hidden by the UA `[hidden]` rule,+        // not by `data-prism-section-hidden`.+        #expect(try await hiddenSectionCount(harness) == 0, "no section should be marked collapsed here")+        #expect(+            try await zeroRectSectionIDs(harness) == [ids[0]],+            "the frontmatter carrier should be the only zero-rect section"+        )++        // At the top of the document the fallback must pick the first RENDERED+        // section (the H1), not the hidden carrier.+        let atTop = try #require(+            try await waitForVisibleBlock(harness, after: 0), "no visibleBlock was reported at the top"+        )+        #expect(atTop == ids[1], "the hidden frontmatter carrier was reported at scroll 0: \(atTop)")++        // And after scrolling, the genuinely visible section must win the scan.+        let targetID = ids[20]+        let seen = harness.messages(type: "visibleBlock").count+        _ = try await harness.page.callJavaScript(+            "var el = document.getElementById(id); el.scrollIntoView({ block: 'start' }); return null;",+            arguments: ["id": targetID],+            contentWorld: harness.bridgeWorld+        )++        let reported = try #require(+            try await waitForVisibleBlock(harness, after: seen), "no visibleBlock was reported after scrolling"+        )+        #expect(reported == targetID, "the hidden frontmatter carrier beat a visible section: \(reported)")+    }+}
docs/agent-notes/scroll-persistence.md Modified +16
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex f8dcba0..90a2e8e 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -35,6 +35,22 @@ The document body renders in a WebView, so the scroll contract has two halves:   `layoutSettled`. The stored id goes through   `BlockDOMID.restoreDOMID(forStored:blocks:)`, which passes DOM-format ids   through and migrates legacy pre-cutover composite ids (`{hash}-{sourceIndex}`).+- **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+  visible section (which sits at a *negative* top once scrolled past). Two kinds+  of section are `display:none`: content under a collapsed heading+  (`data-prism-section-hidden`, set by `applySectionVisibility` in+  `prism-theme.js`, hidden by `document.css`) and the inert YAML-frontmatter+  carrier (`BlockHTMLEmitter` emits it with the bare `hidden` attribute, so the+  UA rule applies and it is `sections[0]`). Before the filter, any collapsed+  heading — or merely having frontmatter — pinned the reported id to an+  unreachable block, and the restore silently no-opped (`scrollIntoView` does+  nothing on `display:none`). **The same zero-rect trap is still unguarded in+  `sectionInWindow` in `prism-search.js`** — an all-zero rect passes both bounds,+  so collapsed sections count as in-window; benign today, but the same reasoning+  error. Any new geometry check over `section[data-prism-block-id]` needs the+  same filter. - **Clobber guards in `prism-scroll.js`:** reports are suppressed for 600ms   around a programmatic scroll; `beginProgrammaticScroll` disarms any pending   debounced report AND the debounce callback re-checks the flag (a report
CHANGELOG.md Modified +1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c52d7e6..c78d7e9 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 +- The saved reading position is no longer corrupted by content the document does not show (T-1851). Hidden content measures as sitting exactly at the top of the window, which beat every genuinely visible block, so the document reported a block the reader could not see as the block being read. That happened with any heading collapsed, and — because the carrier holding a document's YAML frontmatter is hidden the same way — on every document that starts with frontmatter, collapsed or not. Reopening the document, returning from raw source, or recovering from a rendering-process restart then landed on the wrong block or did nothing at all. Content the document does not lay out is now skipped when working out the reading position. - Opening or reloading a large or HTML-heavy document no longer freezes the UI (T-1681). Since the WebKit rendering cutover, the full document HTML — including a SwiftSoup sanitisation pass per raw-HTML block and per inline HTML run — was built synchronously on the main thread on every serve, so a big or markup-dense document blocked the app while it rendered, and re-built the HTML on every reload. The build now runs off the main thread and its result is cached per parse: the UI stays responsive, and reloads (external file change, URL refresh, WebContent-process recovery, the iOS folder-access retry) reuse the cached HTML instead of re-emitting. Very HTML-dense documents can still take a noticeable moment to appear; making that incremental is tracked separately. - Search matches are highlighted in the rendered document again (T-1680). Since the WebKit rendering cutover, searching counted matches and navigated natively but the page never showed a highlight — the native→web search-state feed was never connected. Matches now light up as you type, the current match gets its distinct emphasis and scrolls into view when navigating (including when a result is picked from the iPhone search overlay), footnote badges whose content matches are marked, and dismissing search clears the highlights. - Navigation and display state reach the rendered document again (T-1719). The rendering-engine cutover left core behaviours attached to a retired scroll surface, so they silently stopped running: tapping a table-of-contents entry, a note, or a search result now scrolls the document again; the iPhone bottom toolbar hides when scrolling down and returns when scrolling up; table display-mode choices and expanded/collapsed `<details>` sections now survive a WebKit process recovery, the raw/rendered toggle, and the image-access re-fetch exactly as left (including collapsing a section that was open by default; reloading changed file content still re-seeds them from the document, as designed); and the macOS View-menu scroll commands (Page Up/Down, Top, Bottom) work on the rendered document. A new `WebDocumentStateSynchronizer` owns keeping the page in sync with native state independent of any view being mounted, backed by production-assembly regression suites (`specs/bugfixes/webkit-state-integration/report.md`).

Things to double-check

Restore to a block that is hidden right now.

This branch fixes the reporting side, so no new bad ids get written. It does not fix the restore side: a position saved by a pre-fix build is still on disk in ScrollPositionStore, and scrollToBlock will no-op on it exactly as before. Users upgrading with a stale stored id will see the old behaviour once, until the first scroll re-reports. Worth deciding whether that deserves a migration or just the follow-up ticket.

The frontmatter carrier itself.

The deeper question this review surfaced is whether .metadata should be emitted as a hidden <section data-prism-block-id> at all. It exists only to give the block a stable DOM node for identity, but it participates in every querySelectorAll("section[data-prism-block-id]") in the codebase while being invisible to layout — a trap for the next geometry check, not just this one. An identity carrier that is excluded from the block-section selector would remove the class of bug rather than filtering it at each reader.

Full test suite not run.

Verified: make lint (0 violations, 495 files), make build-ios (exit 0), and 98 passing tests across WebCollapsedSectionScrollTests, WebScrollNavigationTests, WebScrollPositionRetentionTests, WebScrollIntegrationContractTests, WebThemeStateSyncTests, and HTMLCommentVisibilityLiveTests on macOS. Not run: the full make test / make test-ui simulator suites, because of the documented pre-existing crash cascade whose exit code is masked by the xcbeautify pipe. Note that this repo's CI does not build or run tests — green CI is not validation here.