Skip to content

v2.5.1

Choose a tag to compare

@github-actions github-actions released this 20 Aug 06:30
· 485 commits to main since this release

All fixes. The three items the 2.5.0 backlog had left open, each verified as a real defect before being touched — two of the seven candidates turned out not to be, and are recorded as such rather than fixed.

  • A resumed smooth stream revealed broken grapheme clusters. finish(), snap() and a finished flush() confirm the source's end unconditionally, which is right at the time: a finished stream owes the caller every byte it has. But a round ends between tool calls and then continues, and an offset that was a source end need not be a cluster boundary of the longer string — while hold, which exists to protect confirmed boundaries, never saw these ones. Three left-context rules broke there: a lone high surrogate completed by the next round ("👩‍👧‍👦x" resumed at 4 segmented as ["\uDC67‍", "👦", "x"] and revealed the family in visibly broken pieces), a regional-indicator run resumed at an odd position (GB12/GB13 pair flags off from the START of the run, so "🇺🇸🇬🇧🇫🇷" resumed at 2 read 🇸 and 🇬 as one flag), and a leading ZWJ or combining mark. Rather than enumerate UAX #29's left-context rules, a resume now discards the scheduled backlog and re-segments from visibleEnd — the only settled offset — through a bounded lookback window; grapheme breaking is a finite-state process, so a window that starts mid-cluster re-synchronises within a cluster or two. Regional indicators are the one rule a window cannot recover, so the anchor walks their whole run. Steady-state streaming pays nothing.
  • One well-formed HTML table disabled freezing for the rest of the document. A stray table part (<td>, <tr>, <col> …) outside a table re-routes how parse5 builds every later table, so 2.4.4 taught the scanner to poison its candidates from such a tag onwards. Neither that check nor the splice's asked whether the part was actually stray — so <table><tr><td>a</td></tr></table> took the boundary to 0 from the table onwards (43 for the same prose without it) and to zero incremental frames on every schedule, with no way back: phasePoisonedAt only ever moves down. A <details> wrapping a table did it too. Both checks are now positional — the scanner against tagBalance, the open-element model the straddle bail already trusts, read only to SUPPRESS a poison so a miscount leaves the old over-blocking behaviour in place; the splice carrying table depth across raw values, since hast-util-raw feeds them all to one parse5. Five table shapes now run 142–183 incremental frames each.
  • Resolving a cross-chunk footnote left every later footnote numbered wrong. The block cache's footnote rank models state.footnoteOrder, whose position is what a coordinated render bakes into each mark as localNumber. Phantom labels never enter footnoteOrder, but the rank counted them: with [^A] supplied by another chunk and [^B] local, B's baked number is 1 while A is phantom and 2 once A's definition arrives — and B's rank, counting A both times, stayed 1. Same raw, same offset, same key, so the cache kept serving a superscript reading 1 under a footer numbering it 2.
  • perf: the swallowed-extent taint test is a binary search instead of a scan (it was O(taint nodes × raw-HTML blocks) on the streaming hot path — 14% of buildBlocks on a document with 200 definitions and 120 containers).
  • Examined and kept, with the reasoning now in the code so reviews stop re-filing them: the mermaid popup's same-origin blob URL (script that reaches the DOM already holds the page's origin — inline on* handlers inserted via innerHTML are not inert, only <script> is, so the popup adds nothing a DOMPurify bypass would not already have; the noopener comment no longer claims to answer this); and ==highlight== keeping CommonMark's unpatched flanking, where ** and ~~ pair next to full-width punctuation and == does not — == is neither CommonMark nor GFM, and its value is rendering the same way in Obsidian and VitePress, which relaxing it here would break. docs/cjk-typography.md documents the limitation and three working shapes.
  • Also measured and NOT changed: merging the two walks over a raw-HTML container's subtree. Replacing one walk with a bare traversal costs the same as removing it outright (0.199 ms vs 0.203 ms), so the traversal is free and the cost is entirely per-node work — a merge saves nothing.

Verification: every fixed shape is a deterministic pin, red on 2.5.0; release soak (splice fuzz 50k / direction battery 20k / K=4 census) clean on the final engine; full preflight at 1414 tests.