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.
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.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.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.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.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.sectionInWindow in prism-search.js has the identical unguarded zero-rect assumption.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.
c395157 Fix T-1851: Skip collapsed-heading content when detecting the visible block 1ee3c3d T-1851: Address review notes on the visible-block geometry filter working-tree Fixes applied in this review 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.
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.
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.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.
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.
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.
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.
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.
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.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.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.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()
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)
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()
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
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
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.
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.
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.
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.
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.
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.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | prism-scroll.js / CHANGELOG / test coverage | The 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. |
| minor | prism-scroll.js:100 comment accuracy | The 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. |
| minor | prism-scroll.js:87 comment rationale | The 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. |
| minor | docs/agent-notes/scroll-persistence.md | The '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. |
| minor | CHANGELOG.md accuracy | The 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. |
| major | DocumentScrollContent.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. |
| major | prism-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. |
| major | prismTests/WebRendering test-harness reuse | makeStyledHarness 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. |
| minor | WebCollapsedSectionScrollTests.swift white-box assertion | zeroRectSectionIDs 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. |
| minor | specs/bugfixes convention | Recent 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. |
| minor | docs/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. |
| nit | efficiency | topmostBlockID 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. |
| nit | verification — not attributable to this branch | WebDocumentBridgeLiveTests.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. |
Click to expand.
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; }
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)")+ }+}
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
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`).
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 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.
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.