prism branch T-1669/bugfix-…-block-nodes commits 3 + review fixes files 4 touched lines +253 / -43

Pre-push review: T-1669 — inline re-parse drops thematic breaks

A block whose inline text is exactly ---/***/___ re-parsed as a block-level ThematicBreak that InlineHTMLRenderer.Walker had no visitor for, so the content rendered empty. Third instance of the same defect class after T-1640 and T-1641.

At a glance

  • The fix itself is right. visitThematicBreak renders the literal source line as a mapped run instead of reconstructing it from a node that carries no text and no children — the same shape as the T-1640 list-marker fix, and the only shape available for a childless leaf.
  • The real-world trigger is ***/___, not ---. Discovered by the end-to-end test this review added: swift-markdown parses with smart punctuation on, so a --- table cell reaches the block model already folded to an em dash and never re-parses as a break. The ticket's headline spelling is largely unreachable from real markdown; the other two are not.
  • Round 2's defensive branch had the hole it was added to close. It stepped only over line terminators, so a blank line spelled "\n \n" or "\n\t\n" left the capture on the space, rendered that space as the "break line", and dropped the content — the T-1669 symptom one blank-line spelling further out. Fixed by reusing the existing isWhitespaceUnit helper, which also deleted 11 lines and the whole steppedOverGap/progress-guard apparatus.
  • The defensive branch is unreachable and is deliberately not hardened further. Every call site that can produce this node passes a single-block string. A cursor stranded mid-line still renders the line it lands on rather than the break — documented in the code comment as a known soft failure, not closed, because closing it means ~30 lines of thematic-break grammar validation on dead code.
  • Defect class is now recorded. Three instances and no agent note existed; one was added to webview-rendering-status.md, including the residual: the Walker still has no visitHeading, visitBlockQuote, visitCodeBlock or visitHTMLBlock, so instances four and five already live in the code.
  • Verification: full make test-quick on the branch as pushed — 4269/4310, with 2 live-WebKit timing failures that pass 25/25 in isolation. After the review fixes: 46/46 across the emitter and source-map classes, then 34/34 covering ThematicBreakInlineTests, BlockHTMLEmitterTests, WebParityFixtureTests (the <hr> negative control) and InlineRenderCorpusEquivalenceTests. SwiftLint 0 violations across 529 files. Every result read from the xcresult bundle, never from console output.

Verdict

Ready to push

The fix is correct, minimal in the right place, and now genuinely proven end-to-end. All four review agents agreed the production behaviour is right; the one substantive defect they converged on — a hole in the defensive branch added by review round 2 — is fixed here, along with the coverage gap that let the branch ship without a single parse-driven test.

Two follow-ups the author must decide on before merge, neither a code blocker: (1) specs/bugfixes/inline-reparse-drops-thematic-break/ exists on disk and is empty — the fix-bug workflow ran and stopped before writing report.md. The last six bugfix merges all landed one, including the direct sibling T-1641; T-1640 did not, so precedent is mixed. (2) The branch is behind origin/main and CHANGELOG.md conflicts on rebase — a trivial adjacent-line resolution, but it must be done.

Review findings

10 raised · 4 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism renders markdown by first breaking a document into blocks (paragraphs, headings, tables, lists), and then, for each block, re-reading that block's own text as if it were a tiny markdown document of its own. That second pass is where formatting like italics and bold gets recognised.

The problem: markdown says a line containing only three dashes — --- — means "draw a horizontal rule here". So when a block's text happened to be exactly that, the second pass didn't see three dash characters; it saw a horizontal rule. And the code doing the reading only knew how to handle inline things like bold and links. Faced with a horizontal rule, it did nothing at all — and the text disappeared. A table cell containing *** rendered as an empty cell.

Why it matters

Content silently vanishing is the worst kind of rendering bug: nothing errors, nothing looks broken, the characters are just gone. A reader has no way to know they are missing something.

Key concepts

  • Parsing — turning text into a structure a program can walk. --- is just three characters until a parser decides it means "horizontal rule".
  • Walker — code that visits every node of that structure in order. If it has no instruction for a node type, it visits the node's children instead; a horizontal rule has no children, so there was nothing to visit and nothing came out.
  • Source map — Prism records which rendered characters came from which position in the original file, so that notes attached to a selection keep pointing at the right text. Anything rendered has to be recorded there too.

Changes overview

prism/Services/WebRendering/InlineHTMLRenderer.swift gains visitThematicBreak plus a literalThematicBreak(from:) helper on the Walker struct. prismTests/WebRendering/ThematicBreakInlineTests.swift is new (14 tests). CHANGELOG.md gains a user-facing entry; this review added a gotcha bullet to docs/agent-notes/webview-rendering-status.md.

Implementation approach

InlineHTMLRenderer.render calls Document(parsing: source) on each block's inline text and walks the result. A source string satisfying a block grammar therefore comes back as block structure. MarkupWalker's default behaviour is to descend into children; a ThematicBreak is a childless leaf, so the default emitted nothing.

The fix follows the T-1640 precedent exactly: recover the literal source text and emit it as a normal mapped run, rather than trying to reconstruct a rendering from the node. That matters because a ThematicBreak node carries no marker character, no repeat count and no spacing — - - - and --- are indistinguishable once parsed. Capturing the raw line is the only way to render it verbatim, and it is exact, because CommonMark guarantees a thematic break occupies a whole line of rule and spacing characters only.

The cursor is not advanced explicitly. appendVisiblelocate already searches forward from the cursor and sets it past the match, and the skipped gap is whitespace that the literal cannot begin with, so the first match is always the break's own line. That is what let the review delete round 2's manual stepping loop entirely.

Trade-offs

The alternative — validating that the captured line matches the thematic-break grammar and scanning forward to the next line that does — closes a residual where a cursor stranded mid-line renders the wrong line. It was rejected: that branch is unreachable from every current call site, and the validation costs ~30 lines on dead code while introducing a new silent-drop path of its own (a line that fails validation returns nothing). The residual is documented in the code instead.

Technical deep dive

Ordering is the subtle part. closeRun() flushes the open run's <span data-prism-run> into html, so the gap space must be appended after that call and before appendVisible opens the next run — otherwise the space would land inside the previous run's span. The space is deliberately outside every run: it is layout, exactly as visitSoftBreak treats the newline it consumes, and putting it in a run would map a rendered character to source it did not come from.

Cursor monotonicity is a hard precondition of this file, not a convention: locate's provedAbsent memo is only sound because the cursor never moves backwards, which is why locate takes no from: parameter and reads the cursor itself. literalThematicBreak(from:) does take a from:, but it is a pure read that performs no location and mutates nothing, so the memo is unaffected. Both cursor mutations in the new path (via appendVisible) are forward.

Efficiency is pay-per-use and cannot feed the quadratic-blowup machinery this file defends against (T-1966/T-2034): the literal is read from the source at the cursor, so locate's at-cursor fast path matches on the first probe — the full forward scan, the provedAbsent insert and the lazy UnitSet build are all unreachable from here. Repeated breaks in one block sum to O(source), since each capture advances the cursor past the line it read.

Architecture impact

This is the third instance of one defect class and the class is not closed. The Walker has no visitHeading, visitBlockQuote, visitCodeBlock, visitHTMLBlock or visitTable, so a table cell spelled # Title drops its # today (T-1640 shape), and a re-parsed code or HTML block would drop entirely (T-1669 shape). T-1641 is the one instance closed structurally rather than by adding a visitor — by not passing .parseBlockDirectives, matching the main document parser. That asymmetry is the real lesson: the durable fix is making the two parse passes agree on grammar, not enumerating block visitors on the inline walker.

Edge cases and things to watch

  • Smart punctuation shadows the headline case. swift-markdown parses with smart options enabled, so --- in inline position folds to an em dash before the block model sees it. The end-to-end test proves the fix on *** and ___; --- is exercised only at the emitter level, on hand-built blocks. This was invisible until a parse-driven test existed.
  • The defensive branch remains unreachable and imperfect. A cursor stranded on trailing markup (`x`\n\n--- would leave it on the closing backtick) still renders that character as the "break line" and maps a markup character into a run, violating the file's own run invariant. Unreachable from every current call site; deliberately left documented rather than closed.
  • Asymmetric gap handling. A gap before the break becomes a space; a gap after it becomes nothing, because the walker has no paragraph separator. Pre-existing for every multi-block inline source, not introduced here.

Important changes — detailed

InlineHTMLRenderer: visitThematicBreak renders the literal source line

InlineHTMLRenderer.swift

Why it matters. This is the fix. A ThematicBreak reaching the inline walker is a childless leaf, so MarkupWalker's default descend emitted nothing and the block's entire content vanished with no error and no fallback.

What to look at. InlineHTMLRenderer.swift:750-770 (visitThematicBreak), :780-795 (literalThematicBreak)

Takeaway. When a walker recovers content from a node the parser produced by mistake, take the LITERAL SOURCE, never the node's properties. A ThematicBreak carries no marker character, no repeat count and no interior spacing — `- - -` and `---` are indistinguishable after parsing, so any reconstruction canonicalises the author's text. The same reasoning produced literalListMarker in T-1640.
Rationale. Mirrors the T-1640 list-marker fix deliberately, so the two recovery paths in this walker read the same way. The doc comment states why the shapes still differ: a list has children to descend into after its marker, a thematic break has neither text nor children.

Review fix: the defensive gap skip now covers whitespace, not just terminators

InlineHTMLRenderer.swift

Why it matters. Round 2 added a loop to stop a stranded cursor from silently dropping the break. It stepped only over 0x0A/0x0D, so a blank line spelled "\n \n" or "\n\t\n" — blank by CommonMark, which treats a whitespace-only line as blank — left the capture on the space and dropped the content anyway. Three of four review agents found this independently.

What to look at. InlineHTMLRenderer.swift:760-766, :783-786; test at ThematicBreakInlineTests.swift:236-245

Takeaway. A guard that reimplements a predicate the file already has will drift from it. `isWhitespaceUnit` existed 30 lines above and `literalListMarker` already used it for exactly this purpose; reusing it closed the hole AND deleted the loop, the `steppedOverGap` flag and the progress guard — net -11 lines. Reaching for the existing helper first would have skipped two review rounds.
Rationale. Chosen over the alternative of validating the captured line against the thematic-break grammar and scanning forward: that closes one more shape but costs ~30 lines on a branch no call site can reach, and introduces a fresh silent-drop path when validation fails.

Review fix: an end-to-end parse test, which changed what the bug is understood to be

ThematicBreakInlineTests.swift

Why it matters. The file's first section was headed "End-to-end (parse + emit) — the exact shape from the ticket" but nothing in it called MarkdownBlockParser.parse; both sibling fixes (T-1640, T-1641) open with a real parse. Adding one immediately failed, and the failure was informative rather than cosmetic.

What to look at. ThematicBreakInlineTests.swift:35-62

Takeaway. swift-markdown parses with SMART PUNCTUATION ON by default, so `---` in inline position reaches the block model already folded to an em dash (one UTF-16 unit) and never re-parses as a thematic break. The ticket's headline spelling is therefore largely unreachable from real markdown; `***` and `___` are not smart-punctuated and are the spellings that actually reach the defect. Emitter-level tests over hand-built blocks could never have revealed this — they bypass the parser that does the folding.
Rationale. The test now asserts the two reachable spellings from markdown source, and additionally asserts no `<hr>` is emitted, so a rule can never leak back into a cell as block structure. (inferred — not stated by the author)

Review fix: the defect class is recorded in the agent notes

webview-rendering-status.md

Why it matters. Three instances of one shape (T-1640, T-1641, T-1669) and no note existed. The sibling T-1641 bugfix report literally predicted this bug — "a silently-unhandled block node drops content rather than erroring" — and nothing acted on it.

What to look at. docs/agent-notes/webview-rendering-status.md, Gotchas section

Takeaway. The note records the residual, which is the part a future session would otherwise re-derive: the Walker still has no visitHeading / visitBlockQuote / visitCodeBlock / visitHTMLBlock, so `# Title` in a table cell drops its `#` today and a re-parsed code block would drop entirely. Instances four and five are already in the code.
Rationale. CLAUDE.md sets the bar at "a future session would otherwise re-investigate". A defect class on its third instance, whose next two instances are already latent, clears it.

Key decisions

Render the literal source line rather than reconstructing the break.

A ThematicBreak node exposes no marker character, no repeat count and no interior spacing, so - - - and --- are the same node. Only the raw line preserves what the author wrote. Capturing the whole line is exact rather than approximate because CommonMark guarantees a break occupies a full line of rule and spacing characters only.

Do not harden the defensive branch further.

Two agents proposed validating the captured line against the thematic-break grammar and scanning forward to the next line that matches, which would close the remaining "stranded mid-line renders the wrong line" residual. Rejected in this review: every call site that can produce this node passes a single-block string, so the branch is unreachable; the validation costs roughly thirty lines on dead code and adds a new silent-drop path when a line fails validation. The residual is stated in the code comment instead, so the next reader inherits the reasoning rather than the surprise.

No cursor step of its own in visitThematicBreak.

appendVisiblelocate already searches forward from the cursor and sets it past the match. The skipped gap is whitespace, and the literal cannot begin with whitespace, so the first match is always the break's own line. This is what allowed round 2's manual stepping loop to be deleted rather than fixed in place.

Keep the CHANGELOG entry's list of spellings unchanged.

Given the smart-punctuation finding, "---, ***, or ___" overstates how reachable the first spelling is from real markdown. The entry is still accurate about the renderer's behaviour, and the class is what a user-facing note should describe, so it was left alone — flagged here rather than reworded.

Review findings

SeverityAreaFindingResolution
majorInlineHTMLRenderer.swift:760-766 — defensive gap skipThe round-2 loop stepped only over line terminators (0x0A/0x0D). A blank line containing a space or tab — blank by CommonMark — left the capture positioned on that whitespace, which was then captured and rendered as the "break line", dropping the content. The exact T-1669 symptom the branch was added to prevent, one blank-line spelling further out. Found independently by three of the four review agents.Moved the skip into literalThematicBreak using the existing isWhitespaceUnit helper (the same helper literalListMarker already uses for this purpose). The loop, the steppedOverGap flag and the progress guard were deleted; net -11 lines. Regression test added for both the space and tab spellings.
majorThematicBreakInlineTests.swift — missing end-to-end testThe section headed "End-to-end (parse + emit) — the exact shape from the ticket" contained no parse: nothing in the file called MarkdownBlockParser.parse, unlike both sibling fixes. The ticket's own reproduction was therefore never proven from markdown source.Added a parse-driven table test. It failed on first run and exposed that swift-markdown's smart punctuation folds a `---` cell to an em dash before the block model sees it, so `***`/`___` are the spellings that reach the defect from real markdown. Test now asserts those two, plus that no `<hr>` is emitted.
majordocs/agent-notes — no note for the defect classThird instance of one shape (T-1640, T-1641, T-1669) with nothing recorded anywhere in docs/. The T-1641 bugfix report predicted this bug in its Prevention section and nothing acted on it.Added a Gotchas bullet to webview-rendering-status.md covering the mechanism, the three instances, how each was closed (T-1641 structurally, the other two by visitor), and the open residual — no visitHeading/visitBlockQuote/visitCodeBlock/visitHTMLBlock, so instances four and five are already latent.
majorspecs/bugfixes/inline-reparse-drops-thematic-break/ — emptyThe directory exists on disk and is empty; git tracks no empty directories, so the working tree looks clean and the diff shows nothing. The fix-bug workflow creates report.md at its investigation checkpoint, so this is evidence the workflow ran and stopped short. The last six bugfix merges all landed a report, including the direct sibling T-1641.Not written — a bugfix report is the author's artifact from the investigation, and precedent is mixed (T-1640 shipped without one). Raised for the author to decide before merge.
minorInlineHTMLRenderer.swift — stranded mid-line cursorliteralThematicBreak does not verify that the line it captured IS a thematic break, so a cursor stranded on trailing markup (a closing backtick, an emphasis delimiter) renders that character as the break's text and maps a markup character into a run, violating the file's own invariant that a run's source span never includes markup characters.Left open deliberately. Unreachable from every current call site (all pass single-block strings), and closing it costs ~30 lines of grammar validation on dead code plus a new silent-drop path. Now stated explicitly in the visitor's comment so the next reader inherits the reasoning.
minorCHANGELOG.md:22 — spelling list"in a paragraph, a heading, a list item, or a table cell alike" — a top-level paragraph whose entire text is `---` cannot occur in a real document (MarkdownBlockParser produces .thematicBreak, pinned by MarkdownBlockParserTests). With the smart-punctuation finding, the `---` spelling is the least reachable of the three. Blockquote and <details> children, which the fix does cover, go unmentioned.Left unchanged — the entry is accurate about the renderer's behaviour and describes the class correctly for a user-facing note. Flagged rather than reworded to avoid churn.
minorThematicBreakInlineTests.swift — assertion strengthContent assertions use text.contains("---"). The emitter is total and falls back to an escaped-source <pre> on failure, which would also contain the string, so contains() cannot distinguish correct rendering from the fallback.Partly addressed: the new end-to-end test asserts no <hr> is emitted, pinning that a rule never leaks into a cell as block structure. The emitter-level tests were left as-is (the <pre> fallback is itself covered elsewhere, and rewriting passing tests is outside a pre-push review's remit).
minorWalker — defect class not closedNo visitHeading, visitBlockQuote, visitCodeBlock, visitHTMLBlock or visitTable. A table cell or list item spelled `# Title` drops its `#` today (T-1640 shape); a re-parsed code block or HTML block would drop entirely (T-1669 shape).Out of scope for this bugfix, but recorded in the new agent note with the guidance that a durable fix means making the two parse passes agree on grammar (as T-1641 did by not passing .parseBlockDirectives) rather than enumerating block visitors on the inline walker.
minorTest suite — three flakes across two full sweepsmake test-quick reported 4269/4310 passed, 2 failed: WebScrollabilityReportingTests.reportArrivesWithinMaxWaitDuringTriggerBurst (18.4s) and WebScrollNavigationTests.visibleBlockSuppressedDuringProgrammaticScroll (19.1s), both live-WebKit timing tests hitting their max wait. A second sweep failed a different, unrelated test instead — StatePersistenceTests.loadRetrievesState — and then wedged in the live-WebKit harness before finishing.All three confirmed flakes, not regressions: the two scroll classes pass 25/25 in isolation and StatePersistenceTests.loadRetrievesState passes in isolation; none touches the inline walker. The cause is machine contention — another session was running concurrent xcodebuild builds and tests throughout, which is the documented trigger for this project's harness wedge. The wedged sweep was replaced with build-for-testing + test-without-building per the project's own agent notes, which ran clean.
nitEfficiency and reuse — checked, cleanReviewed for hot-path cost, quadratic risk against this file's documented T-1966/T-2034 history, and duplicated helpers.Efficiency-neutral and strictly pay-per-use: static dispatch on a struct witness, no new stored property, and locate's at-cursor fast path matches on the first probe so the provedAbsent memo and the lazy UnitSet build are unreachable from this code. One nit left: the literal round-trips String → Array(text.utf16) when the source span is already known, matching literalListMarker's existing idiom — not worth diverging for one call site.

Per-file diffs

Click to expand.

prism/Services/WebRendering/InlineHTMLRenderer.swift Modified +58 / -0 (net after review fixes)
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 9896536..9df0aa4 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -735,6 +735,64 @@ nonisolated struct InlineHTMLRenderer {             unit == 0x20 || unit == 0x09 || unit == 0x0A || unit == 0x0D         } +        // MARK: Block-level thematic breaks produced by the inline re-parse (T-1669)++        /// A block-level thematic break reaching this walker means the block's inline+        /// source text itself re-parses as one: a table cell, heading, list item, or+        /// paragraph whose text is exactly `---`, `***`, or `___` yields a+        /// `ThematicBreak` — block structure, not a `Text` node — so the default descend+        /// (a leaf with no children) used to drop the content entirely. Same class as+        /// T-1640 (list markers reaching this walker) and T-1641 (`@`-directives): any+        /// renderInline call site can hit it. A `ThematicBreak` carries no text of its+        /// own (unlike a list, it has no children to descend into either), so render the+        /// literal source line verbatim as a mapped run rather than reconstructing one+        /// from the node's (nonexistent) properties.+        mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) {+            // The cursor normally arrives at the start of the break's own line: every+            // call site that can produce this node passes a single-block string, so no+            // sibling left the cursor on a preceding line. Where a future text+            // extraction breaks that, `literalThematicBreak` skips the whole gap —+            // whitespace of any kind, since a blank line is two terminators and may+            // hold spaces or tabs besides — so the break degrades to a stray space+            // rather than the silent drop that IS the T-1669 defect. It cannot verify+            // that the line it lands on is the break, so a cursor stranded mid-line+            // renders that line instead; that is the same class of soft failure.+            if cursor < sourceUTF16.count, isWhitespaceUnit(sourceUTF16[cursor]) {+                // One space for the whole gap, matching soft-break handling: the space+                // is layout, appended outside any run so it never enters the source map.+                closeRun()+                html += " "+            }+            // No cursor step of its own: `appendVisible` locates the literal forward+            // from the cursor, and the gap it skipped is whitespace the literal cannot+            // start with, so the first match is the break's own line.+            if let literal = literalThematicBreak(from: cursor) {+                appendVisible(literal)+            }+        }++        /// The literal thematic-break line appearing next in the source at or after+        /// `from`: leading whitespace skipped (as `literalListMarker` does), then+        /// everything up to the next newline, or the end of source. A thematic break+        /// always occupies a whole line by CommonMark's grammar (rule/spacing+        /// characters only), so capturing the raw line is exact — no need to re-derive+        /// the marker character, its repeat count, or interior spacing, unlike+        /// `literalListMarker`, which only needs a list item's leading marker before+        /// descending into the rest of the item.+        private func literalThematicBreak(from: Int) -> String? {+            var index = from+            while index < sourceUTF16.count, isWhitespaceUnit(sourceUTF16[index]) {+                index += 1+            }+            let lineStart = index+            while index < sourceUTF16.count,+                  sourceUTF16[index] != 0x0A, sourceUTF16[index] != 0x0D {+                index += 1+            }+            guard index > lineStart else { return nil }+            return String(decoding: sourceUTF16[lineStart..<index], as: UTF16.self)+        }+         // MARK: Inline wrappers — emphasis, strong, links          mutating func visitEmphasis(_ emphasis: Emphasis) {
prismTests/WebRendering/ThematicBreakInlineTests.swift Added +180
diff --git a/prismTests/WebRendering/ThematicBreakInlineTests.swift b/prismTests/WebRendering/ThematicBreakInlineTests.swiftnew file mode 100644index 0000000..4fb8d7a--- /dev/null+++ b/prismTests/WebRendering/ThematicBreakInlineTests.swift@@ -0,0 +1,199 @@+//+//  ThematicBreakInlineTests.swift+//  prismTests+//+//  Regression tests for T-1669: inline content that re-parses as a thematic+//  break renders empty.+//+//  `InlineHTMLRenderer.render` re-parses each block's inline source string+//  with `Document(parsing:)`. A source string that is exactly `---`, `***`,+//  or `___` (optionally with interior spacing) re-parses as a `ThematicBreak`+//  — block structure, not a `Text` node — and the Walker had no visitor for+//  it, so the default descend (a leaf with no children) dropped the content+//  entirely. Same defect class as T-1640 (list markers) and T-1641+//  (`@`-prefixed directives): any renderInline call site can hit it — list+//  items, headings, paragraphs, and table cells.+//+//  Expected: any inline content that re-parses as a thematic break renders+//  the literal source line as visible text, with identity source-run+//  mapping, exactly as authored (`---` stays `---`, not canonicalised).+//++import Foundation+import Testing+@testable import prism++struct ThematicBreakInlineTests {++    private func normalisedText(_ blocks: [MarkdownBlock]) -> String {+        let doc = BlockHTMLEmitterTestSupport.emit(blocks)+        return BlockHTMLEmitterTestSupport.normalisedText(doc.html)+    }++    // MARK: End-to-end (parse + emit) — the exact shape from the ticket++    @Test("Markdown table with a break-shaped body cell renders its text (T-1669)")+    func parsedTableCellExactlyAsterisksRendersText() {+        // Driven from markdown source rather than a hand-built block, so a+        // parser-side surprise in the cell text surfaces here rather than passing+        // unnoticed — and one does: swift-markdown parses with smart punctuation+        // on, so a `---` cell reaches the block model already folded to an em dash+        // and never re-parses as a break. `***` and `___` are not smart-punctuated,+        // so they arrive verbatim and are the spellings that reach the defect from+        // real markdown.+        let source = """+        | Value | Meaning |+        | --- | --- |+        | *** | asterisk spelling |+        | ___ | underscore spelling |+        """+        let blocks = MarkdownBlockParser.parse(source)+        let doc = BlockHTMLEmitterTestSupport.emit(blocks)+        let text = BlockHTMLEmitterTestSupport.normalisedText(doc.html)+        // Expected: the literal `***` renders as text.+        // Actual (bug): an empty cell — the whole node is dropped.+        #expect(text.contains("***"), "rendered text: \(text)")+        #expect(text.contains("___"), "rendered text: \(text)")+        #expect(text.contains("asterisk spelling"), "rendered text: \(text)")+        #expect(text.contains("underscore spelling"), "rendered text: \(text)")+        // A rule found this way is TEXT, never block structure: the cell must not+        // emit an `<hr>`. (A real document-level `---` still does — pinned by+        // WebParityFixtureTests and BlockHTMLEmitterTests.thematicBreak.)+        #expect(!doc.html.contains("<hr"), "emitted html: \(doc.html)")+    }++    @Test("Table row whose only cell is `---` keeps the literal text (T-1669)")+    func tableCellExactlyDashesRendersText() {+        let blocks: [MarkdownBlock] = [+            .table(headers: ["Value"], rows: [["---"]], alignments: [.leading])+        ]+        let text = normalisedText(blocks)+        // Expected: the literal `---` renders as text.+        // Actual (bug): an empty cell — the whole node is dropped.+        #expect(text.contains("---"), "rendered text: \(text)")+    }++    // MARK: Emitter-level content that is exactly a thematic break++    @Test("Paragraph that is exactly `---` keeps its text")+    func paragraphExactlyDashesRendersText() {+        let text = normalisedText([.paragraph(markdown: "---")])+        #expect(text.contains("---"), "rendered text: \(text)")+    }++    @Test("Paragraph that is exactly `***` keeps its text")+    func paragraphExactlyAsterisksRendersText() {+        let text = normalisedText([.paragraph(markdown: "***")])+        #expect(text.contains("***"), "rendered text: \(text)")+    }++    @Test("Paragraph that is exactly `___` keeps its text")+    func paragraphExactlyUnderscoresRendersText() {+        let text = normalisedText([.paragraph(markdown: "___")])+        #expect(text.contains("___"), "rendered text: \(text)")+    }++    @Test("Spaced form `- - -` keeps its text exactly as authored")+    func paragraphSpacedDashesRendersText() {+        // CommonMark allows interior spacing in a thematic break line;+        // the whole-line capture must preserve it verbatim, not canonicalise.+        let text = normalisedText([.paragraph(markdown: "- - -")])+        #expect(text.contains("- - -"), "rendered text: \(text)")+    }++    @Test("List item content that is exactly `---` keeps the text")+    func listItemExactlyDashesRendersText() {+        let items = [ListItem(content: "---", checkbox: nil)]+        let text = normalisedText([.list(ordered: false, start: 1, items: items)])+        #expect(text.contains("---"), "rendered text: \(text)")+    }++    @Test("Heading text that is exactly `---` keeps the text")+    func headingExactlyDashesRendersText() {+        let text = normalisedText([.heading(level: 2, text: "---")])+        #expect(text.contains("---"), "rendered text: \(text)")+    }++    @Test("Table cell text that is exactly `***` keeps the text")+    func tableCellExactlyAsterisksRendersText() {+        let text = normalisedText([.table(headers: ["Value"],+                                          rows: [["***"]],+                                          alignments: [.leading])])+        #expect(text.contains("***"), "rendered text: \(text)")+    }++    // MARK: The defended broken-invariant path — a gap before the break line++    @Test("A blank-line gap before the break still renders the literal line (PR #365)")+    func brokenInvariantBlankLineGapKeepsBreakText() {+        // The visitor's invariant is that the cursor arrives at the start of the+        // break's line. Its defensive branch must skip the WHOLE gap: a blank line is+        // two newlines, and skipping only one leaves the capture starting on a+        // terminator, where it scans nothing and returns nil — dropping the content+        // exactly as the T-1669 defect did, just with a stray space. One space stands+        // in for the gap; the `---` survives.+        let text = normalisedText([.paragraph(markdown: "x\n\n---")])+        #expect(text.contains("---"), "rendered text: \(text)")+        #expect(text.contains("x"), "rendered text: \(text)")+    }++    @Test("A CRLF blank-line gap before the break still renders the literal line")+    func brokenInvariantCRLFGapKeepsBreakText() {+        // Same shape with CRLF terminators: all four units are gap, so the capture+        // must land on the break line rather than mid-pair.+        let text = normalisedText([.paragraph(markdown: "x\r\n\r\n---")])+        #expect(text.contains("---"), "rendered text: \(text)")+    }++    @Test("A whitespace-bearing gap before the break still renders the literal line")+    func brokenInvariantWhitespaceGapKeepsBreakText() {+        // A blank line is blank when it holds only spaces or tabs, so the gap is a run+        // of WHITESPACE, not of terminators. Skipping terminators alone stops on the+        // space, captures it as the "break line", and drops the `---` — the T-1669+        // symptom one blank-line spelling further out (pre-push review).+        let spaced = normalisedText([.paragraph(markdown: "x\n \n---")])+        #expect(spaced.contains("---"), "rendered text: \(spaced)")+        let tabbed = normalisedText([.paragraph(markdown: "x\n\t\n---")])+        #expect(tabbed.contains("---"), "rendered text: \(tabbed)")+    }++    @Test("A gap before the break leaves the source map's runs ordered and in bounds")+    func brokenInvariantGapKeepsSourceMapConsistent() {+        // The space emitted for the gap is layout, not mapped source: it must not+        // become a run, and the runs that do exist must stay ordered, non-overlapping+        // and inside the source, or selection-anchored notes would resolve wrongly.+        let source = "x\n\n---"+        let doc = BlockHTMLEmitter.emit(blocks: [.paragraph(markdown: source)],+                                        footnotes: .empty, settings: RenderSettings())+        let runs = (doc.sourceMap.runs.values.first ?? []).sorted { $0.sourceStart < $1.sourceStart }+        #expect(!runs.isEmpty, "runs: \(runs)")+        var previousEnd = 0+        for run in runs {+            #expect(run.sourceStart >= previousEnd, "runs must be ordered and non-overlapping")+            #expect(run.sourceStart + run.length <= source.utf16.count,+                    "runs must stay inside the source: \(runs)")+            previousEnd = run.sourceStart + run.length+        }+    }++    // MARK: Source-map identity for the thematic-break run++    @Test("Thematic-break run maps identity: total run length equals the content length")+    func thematicBreakRunMapsIdentity() {+        let source = "---"+        let doc = BlockHTMLEmitter.emit(blocks: [.paragraph(markdown: source)],+                                        footnotes: .empty, settings: RenderSettings())+        let runs = (doc.sourceMap.runs.values.first ?? []).sorted { $0.sourceStart < $1.sourceStart }+        // A dropped node yields total 0 ≠ the content length: the runs must+        // tile the whole source string (ordered, non-overlapping, starting+        // at 0) so selection-anchored notes still resolve.+        let total = runs.reduce(0) { $0 + $1.length }+        #expect(total == source.utf16.count, "runs: \(runs)")+        #expect(runs.first?.sourceStart == 0, "runs: \(runs)")+        var previousEnd = 0+        for run in runs {+            #expect(run.sourceStart >= previousEnd, "runs must be ordered and non-overlapping")+            previousEnd = run.sourceStart + run.length+        }+    }+}
docs/agent-notes/webview-rendering-status.md Modified +1
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex fd8b2a8..a60518b 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -240,6 +240,7 @@ take down unrelated applications' web content. Always PID-verify, never pattern- - **CSS `:has()` does not reliably re-evaluate on a `td` when a child is inserted live** (e.g. a note dot added during the session) — for "hide X when sibling Y appears" driven by live DOM mutation, suppress explicitly in JS, not via `:has()`. - **`InlineHTMLRenderer` run offsets are always relative to the string it is handed** (its source cursor restarts at 0 on every call), and the runs are consumed against `MarkdownBlock.textContent`, which JOINS the sub-spans (cells with `" | "`, rows/items with `"\n"`). A sub-span caller that appended its runs unrebased mis-anchored every selection notes made past the first cell/item — invisible in the emitted HTML, visible only when run offsets are compared against `textContent`. Closed structurally in **T-1941**: `BlockHTMLEmitter.renderInline` takes a **required** `InlineSpan` (`.wholeBlock` / `.subspan(offset:)` / `.unmapped`), is the only place that appends to `context.blockRuns`, and does the rebase itself — so a new caller cannot omit it, only state it wrongly. Offsets come from `MarkdownBlock.tableCellTextOffsets` / `listItemTextOffsets`, which live beside `textContent` and use the same separator constants, so the two sides cannot drift. Anything absent from `textContent` (nested list items, continuation paragraphs, nested blocks, a `<details>` child list, a nested `<details>` summary, the child-less blockquote fallback) is `.unmapped` → selection declined (Decision 8), never mis-anchored; mapping the nested cases would need `textContent` widened to contain them, tracked as **T-2032** — it is a scoped-out limitation, not a defect. `EmittedDocument.badgeSourceStarts` is deliberately NOT rebased: it is keyed by the inline source string and matched against that string's own occurrence scan in `SearchStateFeeder` (T-1853). Regression guard: `WebStructuredSourceMapInvariantTests.parityCorpusRunsMonotonic` sweeps the whole parity fixture corpus asserting runs are monotonic, non-overlapping AND within their block's `textContent` UTF-16 length — the upper bound is what catches a Character-count offset, which stays monotonic and would otherwise pass. - **`InlineHTMLRenderer.Walker.locate` is a naive forward scan, and it is only affordable because failed scans are recorded** (T-1966). The search cannot be BOUNDED in the general case — text legitimately sits far ahead of the cursor whenever the walk skipped source it does not account for (a long image `src`, a long raw-HTML span), so only the pre-badge segment (PR #326) and `claimOccurrences` (T-1992) get bounds. Without a record, a text node whose rendered text does not occur verbatim in the source — the entity/escape family, `A` spelled `&#65;` — scanned to the end of the block, failed, left the cursor where it was, and the next such node re-derived the same scan: `*&#65;* ` x 3200 took 12s. Two exact rejections fix it: `provedAbsent` (a text an unbounded scan proved absent stays absent, capped at 256 entries so the T-2034 class cannot grow it with the block) and a lazily-built `sourceUnits` bitset (a text holding a unit the source does not hold cannot occur in it anywhere; a bitset over the 16-bit domain rather than a `Set`, because the probe runs once per unit of every later text and hashing dominated it). A match at the cursor is tested BEFORE either memo, so the common case — text sitting exactly where the walk expects it — pays for neither. **`provedAbsent` rests on `cursor` never moving backwards**, which is why `locate` takes NO `from:` parameter and reads `cursor` itself: the precondition is structural, not documented. Every mutation of `cursor` is forward, but one of them is only forward *because of the pre-badge bound* — `appendFootnoteBadge` steps to the occurrence's end, and the `appendVisible` before it must stay bounded by `occurrence.sourceStart` or the cursor could overshoot; weakening that bound breaks the memo, not just performance. No rejection changes any output: digests over a 4000-sample generated corpus (html + every run + `badgeSourceStarts`) are byte-identical to `origin/main` at 09cd828, and that corpus is now COMMITTED (`InlineRenderCorpusEquivalenceTests`, seeded SplitMix64, chunk digests pinned, re-blessing protocol in the file header; `PRISM_INLINE_CORPUS_FULL=1` for all 4000). Residual, deliberately open: a distinct-per-node text absent from the source whose every unit is present still costs a scan each — **T-2034**, needs a source index to close. Guards: `InlineSourceMapScanGrowthTests` (G1-G8 growth over the shared `GrowthRatioGuard`, O1-O6 rendered-text-in-order + run invariants, O7 exact pre-fix run coordinates on memo-HIT fixtures, O8 the recording gate — a bounded miss must record nothing or a later unbounded match is silently suppressed).+- **The inline re-parse can hand `InlineHTMLRenderer.Walker` BLOCK nodes, and an unhandled one drops content silently** — three instances so far, all the same shape. `render` re-parses each block's inline source with `Document(parsing:)`, so a source string that satisfies a *block* grammar comes back as block structure rather than a `Text` node, and `MarkupWalker`'s default descend emits nothing for it: content vanishes with no error, no fallback, and no visible trace except an empty cell/item/heading. **T-1640** `1.`/`3)` at the start of an item (→ `OrderedList`), **T-1641** `@Observable` (→ `BlockDirective`; closed by NOT passing `.parseBlockDirectives`, matching `MarkdownBlockParser`), **T-1669** text that is exactly `---`/`***`/`___` (→ `ThematicBreak`). The two structural fixes render the LITERAL source line/marker as a mapped run (`literalListMarker`, `literalThematicBreak`) rather than reconstructing it from the node — a `ThematicBreak` has no text and no children to descend into, so there is nothing to reconstruct from. **The class is not closed**: the Walker still has no `visitHeading`, `visitBlockQuote`, `visitCodeBlock`, `visitHTMLBlock` or `visitTable`, so `# Title` in a table cell drops its `#` (T-1640 shape) and a re-parsed code block or HTML block would drop entirely (T-1669 shape). Before adding a `renderInline` call site, check what its strings can re-parse into; when one of these turns up, add the visitor rather than pre-sanitising the string. The defensive gap-skipping in `visitThematicBreak` is for a broken cursor invariant only — every call site today passes a SINGLE-BLOCK string, so a stranded cursor is unreachable and the branch is deliberately not hardened further (it degrades to a stray space, or to rendering the line it lands on). - **`FootnotePopoverWebPage.reset()` must reload** to actually clear the live page (updating the served-HTML box alone leaves the prior content in the WebContent process). - **Live-WebPage test harness wedges intermittently** (launchservicesd / XPC / "Sandbox restriction"). Stale `prism.app`/`xctest`/`xcodebuild`/`testmanagerd` processes are a cause — `pkill -9` before a run. `livePresentAndReplace` passing while another live test fails means the harness is fine and it's a real assertion. - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang.
CHANGELOG.md Modified +1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9938073..69428fc 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- A block whose text is exactly a thematic break — `---`, `***`, or `___` — no longer renders empty (T-1669). Rendering re-parses a block's text as its own small markdown document, and that string satisfying markdown's rule for a horizontal rule made it parse as one rather than as plain text; nothing then knew how to turn a rule back into visible text, so it vanished. This is the third defect of this shape (after numbered list markers and `@`-prefixed text, T-1640/T-1641): rendering now knows a rule found this way as text too, and shows the characters as written — in a paragraph, a heading, a list item, or a table cell alike. - Arrow keys, Page Up/Down, Space, and the View menu's **Page Down**/**Page Up**/**Scroll to Top**/**Scroll to Bottom** no longer scroll the document behind an open note, footnote, add-note, reply, or document-note modal (T-1099, reopened). This was fixed once before; the WebKit rendering cutover restructured the views that carried the fix and the binding was never rebuilt, so scroll commands kept reaching the document underneath a modal that should have blocked them. The gate is restored on both the iPhone and iPad/Mac layouts, taking effect immediately when a modal is already open and staying live across every presentation and dismissal. - Replying to a document-level note is no longer silently discarded on a document that has only imported notes (T-1865). NotesPanel and SidebarNotesView create replies through a convenience method that used the document's saved user notes as its source of context; on a document where no user note had ever been created — only imported ones — that context was `nil`, so the guard returned early before the reply was ever built, leaving the tap with no visible effect and nothing written to disk. The method now falls back to the document's cached identifier, the same fallback its sibling document-note-creation method already used, so a reply always creates the note container it needs. - The safeguard that stops a broken document from reloading forever now holds when the crashes keep landing mid-load (T-2107). When a document's rendering process stops, the app reloads it, and if the reloads repeatedly fail to bring the document back it gives up after a few attempts and shows a banner offering a manual reload rather than retrying endlessly (T-1943 below). But a reload was counted as having succeeded the moment the page reported in — before it had finished laying out — so a renderer that reliably crashed in that window looked like a fresh failure each time instead of the same one continuing: the count started over on every attempt, and the document reloaded forever, which is exactly the loop the safeguard exists to prevent. A recovery now only counts as successful once the reloaded document has actually settled on screen, so crashes landing in that window accumulate toward the limit and reach the banner. Recoveries that do bring the document back still reset the count, and the banner's reload still restores everything as before.

Things to double-check

Rebase before merge — CHANGELOG conflicts.

The branch is based on 449621b; origin/main has since moved to 7a2a441. git merge-tree reports one conflict, in CHANGELOG.md only — both sides insert an entry at the top of ### Fixed. Trivial to resolve, but it must be done, and the T-1822 entry that landed on main in the meantime must survive it.

The bugfix report.

specs/bugfixes/inline-reparse-drops-thematic-break/ is an empty untracked directory, so it will not be pushed and nothing will flag its absence later. If a report is wanted, the smart-punctuation finding belongs in it — it changes which spellings the bug actually reaches — as does the residual list of missing block visitors.

Smart punctuation is worth a second look on its own terms.

swift-markdown is parsing with smart options on, which is why a --- cell becomes an em dash. That is a rendering decision nobody appears to have made deliberately, and it is invisible in every emitter-level test because those bypass the parser. Out of scope here, but worth confirming it is intended.

CI cannot confirm any of this.

GitHub Actions is billing-blocked at the account level, so the full suite, the targeted re-runs and SwiftLint were all run locally on macOS. iOS was not built or tested in this review; the change is platform-agnostic Swift in a service type, so the risk is low, but make build-ios has not been run on this branch.

The machine was contended throughout — read the flakes accordingly.

Another session ran concurrent xcodebuild build and xcodebuild test against this project for most of the review, which starved runs at the codesign step and wedged one sweep at RegisterWithLaunchServices — precisely the launchservicesd/XPC wedge this project's own agent notes describe. Every flake seen (two scroll classes, then StatePersistenceTests) went green when re-run in isolation. If the branch's own CI ever comes back, none of these should be treated as known-bad tests.