flux-md 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,979 @@
1
+ # Changelog
2
+
3
+ Notable changes to flux-md. Format based on
4
+ [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## 0.18.1 — 2026-06-29
8
+
9
+ Performance + size pass. No API or output changes — CommonMark 652/652 and
10
+ GFM 23/24 are byte-for-byte unchanged.
11
+
12
+ ### Changed
13
+
14
+ - **WASM binary −9.6 KB (175.1 KB → 165.5 KB, −5.4%).** Three levers, measured:
15
+ a compact stable merge sort replaces the standard library's general-purpose
16
+ stable sort (driftsort) at the two sort sites (−7.3 KB incl. simpler escape
17
+ codegen); `wasm-opt` switches from `-O3` to `-Oz` (−2.3 KB) — and since the
18
+ Rust codegen is already `opt-level=z`, `-Oz` is a Pareto win (equal-or-slightly
19
+ faster parse, never slower, in a Node WASM A/B).
20
+ - **Faster HTML escaping.** `escape_html` / `escape_attr` now scan bytes and copy
21
+ plain runs with one `push_str` (a memcpy) instead of decoding + re-encoding
22
+ every character. Output is byte-identical (only ASCII `< > & " '` are
23
+ rewritten). Measured **+9–23%** parse throughput on escape-heavy documents —
24
+ large fenced code, display math, and HTML/list-heavy content (the common
25
+ LLM-output shape); prose is unchanged.
26
+ - **Fewer allocations on the render path.** Paragraphs, headings, and list items
27
+ render their inline content directly into the output buffer and trim in place,
28
+ dropping one temporary `String` + copy per block (helps the SSR / one-shot
29
+ `renderToString` / `parseToBlocks` path).
30
+ - **One fewer React render per patch (default path).** `<FluxMarkdown>` fed a
31
+ changing value to `useDeferredValue` even when tail deferral was off (the
32
+ default), so React scheduled a throwaway low-priority catch-up render every
33
+ patch. It now feeds a stable value unless `deferTail` is set, so the default
34
+ path renders exactly once per patch.
35
+
36
+ ## 0.18.0 — 2026-06-29
37
+
38
+ ### Added
39
+
40
+ - **`flux-md/server/react` subpath.** Exports `FluxMarkdownStatic` (the hookless
41
+ RSC / SSR React component), moved here from `flux-md/server` so that the core
42
+ server entry stays React-free (see Changed).
43
+
44
+ ### Changed
45
+
46
+ - **`FluxMarkdownStatic` moved from `flux-md/server` to `flux-md/server/react`.**
47
+ `flux-md/server` (`initFlux` / `initFluxSync` / `isFluxReady` / `parseToBlocks`
48
+ / `renderToString`) is now genuinely **React-free**: it imports no framework, so
49
+ a non-React build step or a Vue/Svelte SSR app can
50
+ `import { renderToString } from "flux-md/server"` even when `react` is not
51
+ installed. (Previously the entry failed to load without `react`, because the
52
+ component pulled it in eagerly — contradicting the "zero React dependency"
53
+ promise.) Update RSC/SSR imports to
54
+ `import { FluxMarkdownStatic } from "flux-md/server/react"`.
55
+
56
+ ### Fixed
57
+
58
+ - **Streaming finalize divergence (correctness).** A document streamed
59
+ char-by-char could finalize to different HTML than the same bytes parsed in one
60
+ shot, when the still-growing final line transiently looked like a block start
61
+ (`#…`, `</p…`, a lone `*` / `-`) and then completed into a lazy continuation of
62
+ the previous block (`#hashtag`, `</pre>`, `*emph*`). The penultimate block was
63
+ committed too early and frozen, permanently splitting a block the one-shot parse
64
+ keeps whole. The streaming commit boundary now keeps the penultimate block
65
+ speculative across such a provisional final line.
66
+ - **Coalesced completion deferred a frame.** Under the React hooks' rAF
67
+ coalescing (default since 0.17.0), the terminal `finalize()` patch could be
68
+ delivered one frame late — its synchronous-flush signal was consumed by an
69
+ earlier in-flight append patch — briefly showing a finished code block without
70
+ its highlight / copy button. The terminal patch is now tagged `final` at the
71
+ worker, so the sync flush binds to it regardless of how many append patches
72
+ precede it.
73
+ - **`reset()` ghost blocks.** Swapping a streaming source mid-flight (e.g. a React
74
+ "regenerate") could leave stale blocks from the previous content in the store,
75
+ because an in-flight patch raced the `reset()`. A per-stream generation counter
76
+ now drops pre-reset patches before they reach the cleared store.
77
+ - **Worker-pool robustness.** A fatally-failed worker (WASM-init failure, or a
78
+ trap that poisoned the shared instance) is now terminated and removed from the
79
+ pool — previously it lingered and could bypass the pool cap, leaking a worker
80
+ per stream. A WASM trap is escalated to a fatal worker error (the stream then
81
+ recovers onto a fresh worker) instead of being mishandled as a recoverable
82
+ per-stream error, and `free()` on a poisoned instance is guarded so teardown
83
+ can't throw out of the message loop.
84
+
85
+ ### Security
86
+
87
+ - **O(n²) entity-decode DoS.** The numeric character-reference scan (`&#…`) was
88
+ unbounded; input like `&#&#&#…` (no terminator) re-scanned to end-of-input on
89
+ every `&`, freezing the single-threaded parser for seconds on a few hundred KB.
90
+ The scan is now bounded to the longest valid reference (7 decimal / 6 hex
91
+ digits), matching the already-bounded named-entity branch.
92
+ - **Incomplete `data:` link blocklist.** Script-capable `data:` media types
93
+ (`image/svg+xml`, `application/xhtml+xml`, `text/xml`, `application/xml`,
94
+ `application/javascript`, …) could render as a live link / autolink /
95
+ component-attribute `href` — a browser navigating to one runs its script. They
96
+ are now blocked on the href path. Inert `data:image/…` raster images via
97
+ `![]()` are unaffected (an `<img>`-loaded SVG cannot run script).
98
+
99
+ ## 0.17.0 — 2026-06-27
100
+
101
+ ### Added
102
+
103
+ - **Compiled `dist/`.** The package now ships compiled, non-minified ESM
104
+ (`dist/*.js` + `.d.ts`) instead of raw TypeScript source — fixing consumers that
105
+ don't transpile `node_modules` (e.g. Next.js no longer needs
106
+ `transpilePackages`) and the Socket "unusual packaging" signal. The worker and
107
+ WASM remain separate assets so a consumer bundler still re-emits the worker
108
+ chunk and fetches the `.wasm`.
109
+
110
+ ### Changed
111
+
112
+ - WASM shadow stack reduced from 1 MB to 256 KB, cutting the WASM initial-memory
113
+ floor from ~1088 KB to ~320 KB (memory stays growable for large documents).
114
+ - Worker→main wire format is now a JSON string (a string structured-clones far
115
+ cheaper than an object graph); dropped `serde-wasm-bindgen` (smaller binary).
116
+ - React `useFluxStream` / `useFluxMarkdownString` default to rAF coalescing (one
117
+ render per frame), matching the framework-neutral DOM adapter.
118
+
119
+ ### Fixed
120
+
121
+ - Bounded three recursive descents in the parser (block render, link-reference
122
+ sweep, inline-component tags) at depth 100. With the smaller shadow stack an
123
+ unbounded descent on deeply nested input could trap and poison the worker;
124
+ beyond the cap, content is preserved as escaped text.
125
+
126
+ ## 0.16.2 — 2026-06-26
127
+
128
+ ### Fixed
129
+
130
+ - **Retryable WASM init.** A transient failure fetching the `.wasm` asset (web
131
+ path) no longer poisons every subsequent `initFlux()` / `renderToString()` —
132
+ the cached rejected promise is dropped so the next call retries.
133
+ - **Defensive `blockData` guards.** A malformed/drifted keyed-list `items` field
134
+ or table `rows`/`aligns`/`headers` now falls back to the full-HTML render path
135
+ instead of crashing the streaming render. The start-only ordered-list
136
+ renumber path is unaffected.
137
+
138
+ ### Changed
139
+
140
+ - `<flux-markdown>` stream-failure logging now logs only the error *message*,
141
+ not the raw `src` URL or the full error object (avoids a console forwarder
142
+ shipping a tokenized URL / bulky error body to monitoring).
143
+ - Micro-perf: memoized the components normalization and hoisted `parseOpenTag`'s
144
+ single-char regexes to module scope on the React render path.
145
+
146
+ ## 0.16.1 — 2026-06-25
147
+
148
+ ### Fixed
149
+
150
+ - **Streaming flash for incomplete inline links, code, and math.** While an
151
+ inline construct is still streaming in (no closing delimiter yet), it no
152
+ longer flashes its raw markdown source. A half-typed link renders just its
153
+ label as an inert (non-navigable) `<a>` with the destination hidden until the
154
+ closing `)` lands (then only `href` is added — the element is reused, not
155
+ remounted); inline code shows `<code>…</code>` with the backtick hidden;
156
+ inline math (`$…$`, `\(…\)`, `\[…\]`) shows the rendered `<span class="math
157
+ …">` with the `$`/`\(` hidden. Previously these showed `[label](https://… `,
158
+ `` `code… ``, and `$x^2 +…` as raw text until the closer arrived.
159
+ Final output is unchanged, and an inline construct that never closes still
160
+ finalizes to literal text, byte-identical to a one-shot parse (pinned by
161
+ truncate-at-every-offset streaming-parity fuzz). Images, emphasis/strong, and
162
+ reference links intentionally still render literally while open.
163
+
164
+ ## 0.16.0 — 2026-06-25
165
+
166
+ ### Added
167
+
168
+ - **Keyed streaming renderers (opt-in via `blockData`).** Tables, lists, and
169
+ blockquote/alert containers now render keyed sub-blocks (`<tr>` / `<li>` /
170
+ inner blocks), so while a block streams only the growing tail row/item
171
+ re-renders instead of the whole block — committed rows keep their DOM
172
+ identity (scroll/selection survive). React + vanilla DOM. Backed by new
173
+ `ListData.items` and `ContainerData` block-data channels.
174
+ - **`onRenderMetrics` hook + render counters.** Opt-in per-block render-churn
175
+ probe; `getMetrics()` gains `renderCount` / `rebuildCount`. Zero cost when
176
+ unused.
177
+ - **Opt-in render/scheduling knobs (all default off):** `coalesce` (rAF patch
178
+ coalescing for the React/store path), `deferTail` (`useDeferredValue`),
179
+ `childMemo` (fine-grained `htmlToReact` reuse), `morphOpenBlocks` (in-place
180
+ DOM morph of open blocks), a DOM prefix-extension tail-append fast path, and
181
+ fine-grained tail-block signals for Solid/Vue/Svelte.
182
+
183
+ ### Performance
184
+
185
+ - **Footnotes no longer disable the streaming caches.** The paragraph, list,
186
+ table, and blockquote/alert caches now stay armed when `gfm_footnotes` is on,
187
+ via placeholder occurrence-id tokens resolved on commit — closing the O(n²)
188
+ tail re-scan for footnote-bearing streamed blocks. Output is byte-identical
189
+ to a one-shot render.
190
+ - **Huge unclosed blocks stream in O(new bytes).** New incremental caches for
191
+ open indented-code and raw-HTML blocks remove their O(n²) tail re-scan.
192
+ - Single-pass URL scheme probe and memoized keyed-table header sniffs trim two
193
+ hot paths.
194
+
195
+ ### Build & size
196
+
197
+ - The published tarball is ~32 KB gzip smaller. The WASM core is rebuilt with
198
+ `-Z build-std` + `panic=immediate-abort` (~219 → ~178 KB), and `CHANGELOG.md`
199
+ + a stray wasm-pack `package.json` no longer ship. **Note:** building the
200
+ WASM now requires the nightly Rust toolchain + `rust-src`; consumers are
201
+ unaffected (the prebuilt binary ships), and `build:wasm:stable` remains for
202
+ stable toolchains.
203
+
204
+ ### Security
205
+
206
+ - Footnote occurrence-id placeholder tokens can never leak into rendered HTML
207
+ (defensive guard + a debug assertion exercised by the streaming fuzz corpus).
208
+
209
+ ## 0.15.1 — 2026-06-22
210
+
211
+ ### Security
212
+
213
+ - **XSS — dangerous-scheme autolinks are neutralized.** A CommonMark URI autolink
214
+ (`<javascript:alert(1)>`, `<vbscript:…>`, `<file:…>`) previously emitted a live
215
+ `href`, because autolinks bypassed the scheme allowlist that regular links go
216
+ through. They now route through the same decode-stable dangerous-scheme filter:
217
+ the `href` becomes `#` while the visible link text is unchanged. `file:` is now
218
+ blocked everywhere (links, autolinks, URL attributes) — it has no legitimate use
219
+ in rendered untrusted markdown and is a local-resource / phishing vector in
220
+ privileged contexts (Electron, extensions, `file://` origins).
221
+ - **Component-tag / `htmlToReact` attribute hardening.** Sanitized attributes now
222
+ also drop React-meaningful names (`dangerouslySetInnerHTML`, `ref`, `key`,
223
+ `defaultValue`, `defaultChecked`, `suppressHydrationWarning`, …) so a hostile
224
+ attribute can't crash the render tree or smuggle in a prop. Attribute→prop
225
+ lookup maps are prototype-free (`Object.create(null)`), and only HTML / `data-`
226
+ / `aria-` attribute names are forwarded to React.
227
+
228
+ ### Fixed
229
+
230
+ - **ReDoS / quadratic blow-ups on untrusted input.**
231
+ - Highlighter (`hi.ts`): the JS/TS regex-literal and bash double-quoted-string
232
+ patterns could backtrack quadratically on crafted code blocks; both rewritten
233
+ to linear forms, plus a 50 KB per-block size guard.
234
+ - URL scheme check: the decode-to-fixpoint loop (Rust `is_dangerous_scheme` and
235
+ JS `safeUrl`) is capped at 8 passes — still catches multi-encoded
236
+ `javascript&amp;amp;#58;` payloads, no longer O(n²) on `&amp;`-spam.
237
+ - Inline parser: nested / unbalanced link-bracket scanning is bounded
238
+ (depth + length caps); GFM extended-autolink trailing-paren trimming is now
239
+ linear instead of recounting the span each iteration.
240
+
241
+ ### Changed
242
+
243
+ - **`flux-md/server` uses a literal `import("node:fs/promises")`** instead of a
244
+ variable specifier, resolving the `dynamicRequire` supply-chain signal. Behavior
245
+ is unchanged — still a Node-only, `file:`-guarded branch.
246
+ - Added a **`## Security`** / supply-chain-transparency section to the README and a
247
+ documented **`socket.yml`** covering the inherent `nativeCode` / `networkAccess`
248
+ / `filesystemAccess` signals (the WebAssembly core and the opt-in
249
+ `<flux-markdown src>` fetch).
250
+
251
+ ### Performance
252
+
253
+ - **No redundant re-renders / rebuilds on no-op updates.**
254
+ - `<flux-markdown>` ignores a `setAttribute` whose value didn't change (a host
255
+ framework re-applying identical attributes no longer tears down the self-owned
256
+ client and reparses the whole document), and the `components` / `sanitize`
257
+ property setters skip the remount when assigned the same identity.
258
+ - `FluxClient.reset()` no longer notifies subscribers when the store was already
259
+ empty — skips a wasted, output-identical render pass.
260
+ - Documented that `sanitize` (like `components`) should be memoized/hoisted in
261
+ React, so a fresh closure each render doesn't bust the per-block memo.
262
+ - Added render-count / node-reuse / no-remount regression tests across the React,
263
+ DOM, store, custom-element, and Vue bindings, locking in that committed blocks
264
+ never re-render or rebuild as the stream grows (only the streaming tail does).
265
+
266
+ ### Known limitations
267
+
268
+ - Streaming a single very large **unclosed** block (a multi-megabyte indented code
269
+ block, open HTML block, or footnote-disarmed list delivered across many chunks)
270
+ is still O(n²) in the uncommitted-tail length. A bounded incremental cache for
271
+ these resumable containers is tracked as follow-up; finalized / closed blocks and
272
+ all other inputs are unaffected.
273
+
274
+ ## 0.15.0 — 2026-06-17
275
+
276
+ ### Added
277
+
278
+ - **Safe raw-HTML sanitizer (`htmlAllowlist` / `dropHtmlTags`)** — render a safe
279
+ subset of *inline* raw HTML (`<br>`, `<sub>`, `<sup>`, `<mark>`, …) **without**
280
+ `unsafeHtml`. Setting either list (even to `[]`) engages it: `htmlAllowlist`
281
+ non-empty renders only those tags (others escaped); **empty allows all tags
282
+ except a built-in, non-overridable dangerous set** (`script`, `style`,
283
+ `iframe`, `object`, `embed`, `form`, `svg`, `xmp`, `plaintext`, …);
284
+ `dropHtmlTags` removes tags entirely. Every rendered tag's attributes are
285
+ sanitized — `on*` handlers and `style` (a CSS beacon / clickjacking vector)
286
+ dropped, dangerous URL schemes (incl. multi-encoded) → `#`. Inline-scoped;
287
+ block-level raw HTML stays escaped. Matching is case-insensitive.
288
+
289
+ ### Fixed
290
+
291
+ - **HTML comments are dropped instead of escaped to visible text.** `<!--mk:id-->`
292
+ (a common LLM marker) previously rendered as a literal `&lt;!--…--&gt;` run or a
293
+ `<pre><code>` block; it now has no visible representation, in every mode except
294
+ bare `unsafeHtml` pass-through (which keeps it verbatim for CommonMark fidelity —
295
+ the browser ignores it either way). A comment-led block with trailing content
296
+ keeps that content (only comment-*only* blocks are dropped).
297
+
298
+ ### Security
299
+
300
+ - The dangerous-tag set is **non-overridable** (allowlisting `script`/`iframe`/`svg`
301
+ still drops them), `style` is stripped from every sanitized/component tag, and
302
+ raw-text elements (`xmp`/`plaintext`/`noembed`/`noframes`/`listing`) are blocked
303
+ in allow-all mode — closing CSS-exfiltration / clickjacking / DOM-corruption
304
+ vectors found in adversarial review. The React `htmlToReact` path mirrors the
305
+ `style` value-filter as defense-in-depth (safe declarations like `text-align`
306
+ still pass).
307
+
308
+ Feature-off output is byte-identical except HTML comments now drop (the
309
+ CommonMark/GFM suites run with `unsafeHtml` on, so the 652/GFM floors are
310
+ unaffected).
311
+
312
+ ## 0.14.0 — 2026-06-17
313
+
314
+ ### Added
315
+
316
+ - **Inline custom component tags (`inlineComponentTags`)** — the headline gap for
317
+ rich apps. An allowlisted inline tag like `<tik symbol="AAPL">AAPL</tik>` (or
318
+ self-closing `<tik/>`) **anywhere inline** — paragraphs, headings, list items,
319
+ and **table cells** — renders as a real custom element with its inner parsed as
320
+ **inline markdown** and its attributes sanitized (event handlers dropped,
321
+ dangerous URL schemes → `#`). The React renderer dispatches it to
322
+ `components[tag]` with the inner markdown as `children` and the attributes as
323
+ props — **XSS-safe without `unsafeHtml`**. Independent of `componentTags`
324
+ (block containers): list a tag under either or both. Use lowercase tag names.
325
+ - **`children` on `Component` block overrides** — a `Component` override now also
326
+ receives the inner content pre-parsed to a React tree (`children`), so you can
327
+ `return <Chip {...attrs}>{children}</Chip>` instead of
328
+ `dangerouslySetInnerHTML`-ing `html`. The html-vs-children contract is now loud
329
+ in the types and docs (an override that renders neither shows empty).
330
+ - **`flux-md/server` — worker-free synchronous SSR / RSC rendering.** The Rust→
331
+ WASM core is a plain synchronous parser, so finished markdown renders on the
332
+ server with no worker: `initFlux()` (async, idempotent — reads the co-located
333
+ `.wasm` in Node, or `initFluxSync(bytes)` on edge), `renderToString(md, {
334
+ config })` (sync HTML string, zero React dep), `parseToBlocks(md, { config })`,
335
+ and `<FluxMarkdownStatic content config components />` — a hookless, RSC-safe
336
+ React component that emits the same `flux-md` tree a client `<FluxMarkdown>`
337
+ hydrates, with the same overrides (inline/block component tags dispatch on the
338
+ server too).
339
+ - **`FluxParser.allBlocks()` (WASM)** — returns the whole parsed document as a
340
+ block array, the one-shot render primitive used by `flux-md/server`.
341
+
342
+ ### Fixed
343
+
344
+ - **Data-loss: a block component tag used inline swallowed sibling blocks.** With
345
+ e.g. `componentTags: ["tik"]`, an inline occurrence such as
346
+ `<tik>AAPL</tik> is up.` on a line with following content opened a block
347
+ container that consumed the rest of the document (the paragraph and a following
348
+ table vanished). A block component open tag must now be the **whole line** (only
349
+ trailing whitespace after `>`); otherwise it's treated as inline and degrades
350
+ inertly — it never eats surrounding content.
351
+
352
+ ### Changed
353
+
354
+ - The React HTML→tree converter (`htmlToReact` / `parseTrustedHtml`) now preserves
355
+ a tag's original **case** for component dispatch (so a capitalized inline tag
356
+ like `<Cite>` maps to `components.Cite`); HTML semantics (void elements, `input`,
357
+ close-tag matching) still compare case-insensitively, so standard output is
358
+ unchanged.
359
+
360
+ Feature-off output is byte-identical (CommonMark 652 + GFM floors hold); both
361
+ allowlists are empty by default.
362
+
363
+ ## 0.13.0 — 2026-06-04
364
+
365
+ ### Added
366
+
367
+ - **`FluxClient.setContent(content, { done })` + controlled-string helpers for
368
+ every binding** — a first-class bridge for UIs that hold a streaming message as
369
+ a single growing/controlled string prop (rather than a stream). setContent diffs
370
+ against the last value: a **prefix-extension** appends only the delta (committed
371
+ blocks stay put); any **divergence** (e.g. a finished message swapped for a
372
+ re-processed final string) resets and reparses. No hand-rolled diff, no
373
+ readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
374
+ framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
375
+ per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
376
+ (composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
377
+ (action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
378
+ / `<flux-markdown>` use a caller-owned client + `setContent` directly.
379
+ - **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
380
+ on app load) so the one-time WASM init is off the first-token critical path; the
381
+ warm worker is the one the first stream attaches to, so the work isn't wasted.
382
+ - **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
383
+ block**, not just settled ones — a design-system renderer (Tailwind classes on
384
+ `p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
385
+ of only after a block commits. This also closes a gap where a supplied
386
+ `sanitize` previously bypassed component-rendered blocks; it now runs on every
387
+ block. The no-`components` path is unchanged (byte-identical `innerHTML`).
388
+
389
+ ### Fixed
390
+
391
+ - **Worker no longer drops the first chunk(s) under a slow WASM load.** The
392
+ worker buffered appends but did not gate parser creation on WASM readiness, so
393
+ an append that arrived before `init()` resolved would call `new FluxParser()`
394
+ against an uninitialized module — throwing `fluxparser_new of undefined` and
395
+ silently losing that chunk. Appends now accumulate (and `finalize` defers)
396
+ until init completes, then drain in order. Surfaced on a fresh Next.js /
397
+ Turbopack production load, where the worker+WASM fetch is slow enough to lose
398
+ the race; the fix is bundler-agnostic. The worker's message/readiness state
399
+ machine was extracted to `worker-core.ts` (dependency-injected, like
400
+ `FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
401
+ covering the gate — buffer-until-ready, drain order, finalize/reset before
402
+ ready — so the regression can't silently return.
403
+ - **React 19 / Next.js type compatibility.** The shipped source used the global
404
+ `JSX.Element`, which React 19's `@types/react` removed — a consumer's
405
+ `next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
406
+ *"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
407
+ under `@types/react` 18 **and** 19.
408
+
409
+ ### Docs
410
+
411
+ - **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
412
+ Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
413
+ and use it from a `"use client"` component. See the README's Next.js callout.
414
+
415
+ ## 0.12.0 — 2026-05-30
416
+
417
+ ### Added
418
+
419
+ - **Optional default theme — `import "flux-md/styles.css"`.** A drop-in stylesheet
420
+ for good-looking output out of the box, **including the built-in syntax
421
+ highlighter's colors** (without any CSS, `highlight()` output is uncolored).
422
+ Scoped to `.flux-md`, driven by `--flux-*` CSS variables (re-theme by overriding
423
+ a few), light by default with automatic dark via `prefers-color-scheme` (force
424
+ with `class="flux-md flux-dark"` / `flux-light`). Opt-in and zero-runtime — the
425
+ rendered HTML is unchanged; skip the import to bring your own CSS.
426
+
427
+ ## 0.11.0 — 2026-05-30
428
+
429
+ ### Added
430
+
431
+ - **Opt-in live region + root attributes** on `<FluxMarkdown>` and
432
+ `mountFluxMarkdown`. The root accepts `className` (appended to `flux-md`),
433
+ `id`, `role`, and `aria-live` / `aria-atomic`. Set `aria-live="polite"` to
434
+ announce streamed content to screen readers — `polite` coalesces rapid updates
435
+ and does **not** read every token. Off by default; covers React and the DOM
436
+ mount (so the Web Component and the Vue/Svelte/Solid adapters too).
437
+
438
+ ### Docs
439
+
440
+ - A repository root README, a "Structured block data" guide in the package
441
+ README, and a runnable **Data Studio** demo in the playground — a
442
+ sort/filter/CSV table and a live table of contents built entirely from
443
+ `block.data`, mid-stream.
444
+
445
+ ## 0.10.0 — 2026-05-30
446
+
447
+ Server-side rendering safety, plus an opt-in structured-data channel so consumers
448
+ build toolbars / tables of contents / charts from **data** instead of re-parsing
449
+ rendered HTML (no hast tree, no rehype).
450
+
451
+ ### Added
452
+
453
+ - **SSR-safe.** `new FluxClient()` and `renderToString(<FluxMarkdown …/>)` no
454
+ longer touch a Web Worker during construction or server render — worker
455
+ creation is deferred to the first `append`/`pipeFrom` (client-side) — so the
456
+ library imports and server-renders cleanly across React / Vue / Solid / Svelte.
457
+ A fresh-process SSR cold-import check guards it in CI.
458
+ - **Structured block data — `blockData: true`** (per-stream config; opt-in,
459
+ default off — output and CommonMark/GFM conformance are **byte-identical** when
460
+ off). When on, `block.kind.data` carries typed structured data per kind, also
461
+ surfaced as typed `BlockComponentProps` fields, and it **streams** in lock-step
462
+ with the HTML:
463
+ - **Table** → `{ headers, rows, aligns }`, cells `{ text, html }` (`props.table`)
464
+ — sort / filter / transpose / CSV / chart.
465
+ - **Heading** → `{ level, text, id }` (`props.heading`) — TOC with anchors.
466
+ - **CodeBlock** → `{ lang, code }` (`props.code`) — decoded source.
467
+ - **MathBlock** → `{ latex }` (`props.math`) — LaTeX source.
468
+ - **List** → `{ ordered, start }` (`props.list`).
469
+
470
+ ### Fixed
471
+
472
+ - Packaging: the published tarball ships the WASM deterministically on every npm
473
+ version (build removes wasm-pack's nested `.gitignore`), with a tarball tripwire
474
+ in CI and the publish workflow.
475
+
476
+ ## 0.9.0 — 2026-05-29
477
+
478
+ Kills the React streaming boilerplate. The common case — render an LLM stream —
479
+ goes from ~17 lines of hand-rolled lifecycle to one:
480
+
481
+ ```tsx
482
+ <FluxMarkdown stream={stream} />
483
+ ```
484
+
485
+ ### Added
486
+
487
+ - **`stream` prop on React `<FluxMarkdown>`** — pass an `AsyncIterable<string>`
488
+ (SSE deltas), a `Response`, or a `ReadableStream<Uint8Array>` and the
489
+ component owns an internal client, pipes the stream, supersedes it on change,
490
+ and destroys it on unmount. The `client` prop is unchanged (now optional);
491
+ passing a `client` keeps the existing caller-owned behavior.
492
+ - **`useFluxStream(stream, options?)` hook (React)** — same lifecycle, returns
493
+ the owned `FluxClient` (so you can read `outline()` / `getMetrics()` or pass it
494
+ to `<FluxMarkdown client={…} />`).
495
+ - **`pipeFrom` now also accepts an `AsyncIterable<string>`** and an optional
496
+ `{ signal }` — the signal is checked every iteration, so an aborted stream
497
+ appends no further chunks and is **not** finalized (and a byte reader is
498
+ `cancel()`'d). Existing `pipeFrom(Response | ReadableStream)` calls are
499
+ unchanged.
500
+
501
+ ### Notes
502
+
503
+ - A stream is single-use, so React StrictMode's dev-only double-mount may
504
+ truncate it in development; production mounts once and is unaffected (the
505
+ prior manual `useEffect` form had the same caveat).
506
+ - Rules of Hooks are respected — `<FluxMarkdown>` dispatches to one of two
507
+ sibling components, never a conditional hook.
508
+
509
+ ## 0.8.0 — 2026-05-29
510
+
511
+ A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
512
+ in the worker pool and added two small, streaming-native conveniences.
513
+
514
+ ### Added
515
+
516
+ - **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
517
+ `ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
518
+ chunk, and `finalize()`s. The LLM-native one-liner:
519
+ `await client.pipeFrom(await fetch("/api/chat"))`.
520
+ - **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
521
+ commits (document order), for side effects like lazily highlighting a finished
522
+ code block or analytics. Committed blocks never re-fire.
523
+
524
+ ### Fixed
525
+
526
+ - **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
527
+ user `onError` (or any handler) that threw could abort the fatal-error fan-out
528
+ mid-loop and escape the worker message listener; dispatch is now isolated.
529
+ - **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
530
+ skipped the `failed` flag, so after a WASM-init failure a new stream could be
531
+ routed onto the dead worker and hang (a client that didn't `await whenReady()`
532
+ had no safety net). Failed workers are now excluded from selection.
533
+ - **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
534
+ `src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
535
+ - Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
536
+ no tarball) and documented the `htmlToText` core-HTML-only invariant.
537
+
538
+ ## 0.7.0 — 2026-05-29
539
+
540
+ DX, robustness, and accessibility round — the streaming core (perf, CommonMark
541
+ 652/652, GFM) was already comprehensive, so this release sharpens the surface
542
+ around it.
543
+
544
+ ### Added
545
+
546
+ - **`onError` on `FluxClient`** — `new FluxClient({ onError })` receives worker
547
+ and parse errors (previously only `console.error`'d). A **WASM-init failure**
548
+ now also surfaces: `whenReady()` **rejects** instead of hanging forever, and
549
+ `onError` fires with `{ fatal: true }`.
550
+ - **`a11y` parser option** (`ParserConfig.a11y` / `setA11y` / `<flux-markdown
551
+ a11y>`) — opt-in accessibility markup that intentionally deviates from strict
552
+ GFM byte-output: wraps a task-list checkbox + its text in a `<label>` (so the
553
+ box is programmatically associated for screen readers), and adds
554
+ `scope="col"` to table header cells. **Off by default** (conformance output
555
+ unchanged). Streaming output stays byte-identical to one-shot.
556
+ - **`FluxClient.outline()`** — a heading table-of-contents (level / text /
557
+ stable id) from the current snapshot, in document order; works mid-stream.
558
+ - **`FluxClient.toPlaintext()`** — the rendered document as plain text (tags
559
+ stripped, entities decoded, blocks blank-line separated) for search indexing
560
+ / summaries.
561
+
562
+ ### Fixed
563
+
564
+ - **`<flux-markdown>` `src` race** — rapidly changing `src` (or switching
565
+ between a `src` URL and inline `markdown`/`textContent`) could interleave two
566
+ fetch streams into one parser, corrupting the parse tree. The element now
567
+ supersedes any in-flight fetch (monotonic token + `AbortController`) at a
568
+ single chokepoint.
569
+
570
+ ### Docs / packaging
571
+
572
+ - README documents the one-line Vite `optimizeDeps.exclude` requirement.
573
+ - `"sideEffects": ["./src/worker.ts"]` so bundlers can drop unused framework
574
+ adapters from the export surface.
575
+ - CI now publishes via a tag-triggered workflow with `npm publish --provenance`,
576
+ and asserts every published tarball ships a non-empty WASM artifact.
577
+
578
+ ## 0.6.0 — 2026-05-28
579
+
580
+ ### Added — flux-md is no longer React-only
581
+
582
+ The core (`FluxClient` + the WASM worker) was always framework-neutral; only
583
+ the renderer was React-bound. This release adds five new entry points, each
584
+ **thin lifecycle glue** over one new framework-agnostic DOM renderer — none
585
+ re-implements the subscribe/diff loop, and none destroys your client (you own
586
+ the worker/stream).
587
+
588
+ - **`flux-md/dom`** — the foundation. `mountFluxMarkdown(client, container,
589
+ options?) → { destroy(), refresh() }` incrementally patches a DOM subtree
590
+ using the parser's stable block IDs: a committed block's node is never
591
+ recreated (so one-shot work like syntax highlighting and the copy-button
592
+ listener runs exactly once), only the streaming tail re-renders. Reuses the
593
+ in-house highlighter for deferred code, applies your `sanitize` hook to the
594
+ open/speculative tail, and batches patches per `requestAnimationFrame`.
595
+ Block-kind overrides via `components` (`(props) => HTMLElement | string`);
596
+ tag-level overrides remain React-only.
597
+ - **`flux-md/element`** — `defineFluxMarkdown(tag = "flux-markdown")` defines a
598
+ `<flux-markdown>` custom element. Light DOM (your markdown CSS applies),
599
+ SSR-safe (no auto-register), and usable three ways: a caller-owned `client`
600
+ property, a self-owned client driven by `append()`/`finalize()`, or zero-JS
601
+ via a `src` URL it fetch-streams / inline text / a `markdown` attribute.
602
+ Config flags map to tri-state attributes (`gfm-math`, `dir-auto`, …). Covers
603
+ **Angular** with `CUSTOM_ELEMENTS_SCHEMA` — no separate package.
604
+ - **`flux-md/vue`** — a `<FluxMarkdown>` component + `useFluxMarkdown`
605
+ composable (Vue 3, optional peer dep).
606
+ - **`flux-md/svelte`** — a `fluxMarkdown` action, `use:fluxMarkdown={{ client }}`
607
+ (Svelte 4 and 5, optional peer dep).
608
+ - **`flux-md/solid`** — a `<FluxMarkdown>` component (Solid, optional peer dep).
609
+ Newest binding: its mount/teardown glue is tested, but the JSX component shell
610
+ has only been exercised via a real `vite-plugin-solid` build, not in CI — the
611
+ `flux-md/dom` mount inside `onMount`/`onCleanup` is the fallback if your Solid
612
+ toolchain trips on it.
613
+
614
+ Purely additive — existing `flux-md` / `flux-md/react` / `flux-md/client` users
615
+ are unaffected (the React renderer and core are byte-identical; the only change
616
+ to existing code was a type-only import repoint so the neutral entry points
617
+ typecheck without React). `vue`, `svelte`, and `solid-js` join `react` as
618
+ optional peer dependencies — import only the binding you need. See the new
619
+ "Framework bindings" section in the README. 65 → 85 tests.
620
+
621
+ ## 0.5.6 — 2026-05-28
622
+
623
+ ### Performance
624
+
625
+ - **`ContainerCache` now handles multi-paragraph inner content.** A blockquote
626
+ or GitHub alert with blank `>` lines inside (`> [!NOTE]\n> Para one.\n>\n>
627
+ Para two.\n`) used to drop the cache and fall back to the O(n²) full path
628
+ the moment the first blank arrived. The cache now closes the current
629
+ paragraph on a blank `>` and starts a new one, preserving the
630
+ streaming-O(new bytes) shape across multi-paragraph inner content. Each
631
+ completed inner paragraph is pre-rendered into a growing
632
+ `committed_paras_html` string; the single-paragraph fast path (the bench's
633
+ `big_blockquote` / `big_alert`) is unchanged within noise.
634
+
635
+ - **`ListCache` now handles loose lists.** A flat list with blank lines
636
+ between siblings (`- one\n\n- two\n\n- three\n`) is a CommonMark "loose"
637
+ list — every item body gets wrapped in `<p>…</p>` — and the cache used to
638
+ bail on the first blank. The cache now flips to loose on the first
639
+ blank-then-marker sequence, re-renders prior cached items with `<p>`
640
+ wrappers from stored source spans (one-time O(items)), and continues the
641
+ streaming-O(new bytes) shape from there. Tight→loose is sticky.
642
+
643
+ 50 KB loose-list bench, before-fix → after-fix:
644
+
645
+ | chunk | before | after | speedup |
646
+ |------:|---------:|--------:|--------:|
647
+ | 16 | 5593 ms | 21 ms | ~272× |
648
+ | 256 | 355 ms | 7 ms | ~49× |
649
+
650
+ Tight `big_list` perf is unchanged within bench noise.
651
+
652
+ ### Added
653
+
654
+ - **React `CodeBlock` default renderer ships a copy-to-clipboard button.**
655
+ Closed code blocks now show an icon + "Copy" in their header (the existing
656
+ "streaming" pill takes that slot until close, so streaming code is never
657
+ copy-clickable mid-arrival). Click → copies the decoded source via
658
+ `navigator.clipboard.writeText` → swaps to a checkmark + "Copied" for
659
+ 1.5 s → reverts. Native `<button>` (keyboard-reachable), `aria-label`
660
+ toggles between "Copy code" and "Copied" with `aria-live="polite"`,
661
+ guards against `navigator.clipboard` being absent (SSR / insecure context)
662
+ and rejected `writeText` promises (permission denied) — both leave the
663
+ button silently usable. No new dependency.
664
+
665
+ ### Documentation
666
+
667
+ - README quickstart now uses `useState(() => new FluxClient())` + an
668
+ unmount-only destroy effect instead of `useMemo(() => new FluxClient(),
669
+ [])` + cleanup-on-stream-change (which destroyed the client when the
670
+ `stream` prop changed, leaking a freed parser on the next append).
671
+ - New "when to enable each flag" guide for `ParserConfig` with concrete
672
+ LLM-output triggers (`gfmMath` when `$…$` arrives, `componentTags` for
673
+ `<Thinking>` blocks, etc.) — so a reader picks flags without reading the
674
+ full reference further down.
675
+ - `Alert` block-kind override example added to the `components` docs.
676
+ - `sanitize` example mirrors the realistic memoize-at-module-scope pattern
677
+ from the live demo (a fresh arrow each render busts the per-block memo).
678
+ - New "Performance" section pointing to CHANGELOG / `examples/bench.rs` for
679
+ numbers (no numbers baked into the README — those rot).
680
+
681
+ ## 0.5.5 — 2026-05-28
682
+
683
+ ### Performance
684
+
685
+ - 1× memcpy in the paragraph / container cache assembly (was 2×). Both caches
686
+ were building the block HTML in two stages — concatenate
687
+ `committed + active` into an intermediate `String`, then concatenate
688
+ `<p>` + that into the output — so a long open paragraph or container did two
689
+ memcpys of the committed inner per append. The fix builds directly into the
690
+ output buffer and trims trailing whitespace in-place; the container case
691
+ backs out a provisional `<p>` opener if the body content turns out to be
692
+ empty (preserving the empty-body fix from 0.5.4). Output is byte-identical.
693
+
694
+ 200 KB bench (best of 7), chunk=16:
695
+
696
+ | shape | 0.5.4 | 0.5.5 | speedup |
697
+ |-----------------|---------:|---------:|--------:|
698
+ | `long_paragraph`| 142 ms | **96 ms**| 1.48× |
699
+ | `emphasis_para` | 170 ms | **116 ms**| 1.47× |
700
+ | `big_blockquote`| 213 ms | **157 ms**| 1.36× |
701
+ | `big_alert` | 343 ms | **237 ms**| 1.45× |
702
+
703
+ Modest wins at every chunk size for the affected caches; the
704
+ table / list / fence caches are unchanged (they were already 1× memcpy).
705
+
706
+ ## 0.5.4 — 2026-05-28
707
+
708
+ ### Fixed (mid-stream rendering)
709
+
710
+ - **GFM tables now form during streaming, not just at finalize.** Streaming a
711
+ table char-by-char (or in any chunking where the delimiter row's `\n` lands
712
+ in a different chunk than the row's content) used to leave the block as a
713
+ `<p>` spanning both lines until `.finalize()` ran. The paragraph cache's
714
+ delimiter-detection walked from the line AFTER the cut and so missed a
715
+ delimiter row that completed inside the line the cut had advanced into. The
716
+ fix re-checks the line containing the cut whenever it has just completed,
717
+ guarded by a cheap `bytes[cut..].contains('\n')` so long open paragraphs
718
+ without interior `\n` still take the O(new bytes) per-call path.
719
+ - **Open alerts/blockquotes with an empty body no longer render an empty
720
+ `<p></p>`.** A `> [!NOTE]\n` shown mid-stream now matches the full renderer:
721
+ `<div class="markdown-alert ...">…<p class="...title">Note</p></div>` with
722
+ no empty body paragraph. The container cache was wrapping the body in
723
+ `<p>…</p>` unconditionally, even when the body was empty.
724
+
725
+ Both bugs only manifested *before* `finalize()`. The post-finalize output —
726
+ what every existing parity test checks — was already correct, which is why
727
+ neither was caught earlier. A new `tests/midstream_parity.rs` asserts that the
728
+ streamed view of an open block matches what one-shot parsing produces for the
729
+ same prefix (tables, alerts, blockquotes, lists, code fences, math fences).
730
+
731
+ ### Performance
732
+
733
+ - `big_table` at the artificial `chunk=16` stress case is ~280 ms (was ~145 ms
734
+ in 0.5.3). The 145 ms was the *incorrect* path: the paragraph cache treated
735
+ the whole 200 KB table as a single growing paragraph until finalize, never
736
+ engaging the table cache. The 280 ms is the cost of correctly emitting the
737
+ table mid-stream at the smallest chunk size. Every realistic LLM streaming
738
+ chunk size (≥64 bytes) is unchanged — `big_table` at chunk=64 is 73 ms,
739
+ chunk=256 is 38 ms, etc.
740
+
741
+ ## 0.5.3 — 2026-05-28
742
+
743
+ ### Performance
744
+
745
+ - **Streaming long open resumable containers is now O(n).** A long
746
+ `> [!NOTE]` alert, a `>`-quoted explanation, or a flat bullet/ordered list
747
+ used to re-run scan + inline render over the whole growing inner on every
748
+ append (O(n²)). Three new tail caches mirror the existing fence/table
749
+ pattern:
750
+
751
+ - `ContainerCache` — single-paragraph blockquote / GitHub alert. Wraps
752
+ the existing paragraph-cache (inline-boundary commit) with a
753
+ `>`-stripped inner buffer; the wrapper HTML (`<blockquote>` /
754
+ alert `<div>`) is built once at arm time, each new `> ` line is
755
+ stripped once into the inner buffer, only the unsettled inline tail is
756
+ re-rendered. Bails on a blank `>`-line (paragraph break inside the
757
+ container), lazy continuation, or `\r`.
758
+
759
+ - `ListCache` — tight, flat list (the LLM-emit shape: one sibling marker
760
+ per line, no blanks, no continuation, no nesting). Opener
761
+ (`<ul>` / `<ol start=N>`) pre-rendered at arm time; each new sibling
762
+ line renders directly into the cache as a tight `<li>…</li>` (GFM
763
+ task-list `[ ] `/`[x] ` supported). Bails on the first blank line
764
+ (loose-list signal), non-marker line, over-edge marker (nested), or
765
+ foreign-family marker — the full path handles those.
766
+
767
+ Measured at 50 KB (best of 7), before → after:
768
+
769
+ | shape | chunk=16 | chunk=256 |
770
+ |-----------------|-------------------|-----------------|
771
+ | `big_blockquote`| 5164 → **22 ms** | 332 → **8.5 ms**|
772
+ | `big_list` | 6141 → **18 ms** | 391 → **7.4 ms**|
773
+ | `big_alert` | 6298 → **28 ms** | 404 → **11 ms** |
774
+
775
+ At 200 KB, `big_list` chunk=256 was extrapolating to ~6.2 s before the
776
+ cache; now **36 ms** (~170×). Every realistic streaming shape now has a
777
+ flat chunk-size curve.
778
+
779
+ Output is byte-identical. Parity gated by `tests/container_cache.rs`
780
+ (blockquote + all five alert kinds, dir_auto, CRLF, lazy continuation,
781
+ multi-paragraph fallback, 400-line stress) and `tests/list_cache.rs` (5
782
+ marker families, ordered with non-default start, dir_auto, CRLF, loose /
783
+ nested / multi-line fallback, 400-item stress).
784
+
785
+ ### Documentation
786
+
787
+ - Reworded the "future plugin slot" comments in `renderers/Math.tsx` and
788
+ `renderers/Mermaid.tsx`. The actual extension path is the
789
+ `components.MathBlock` / `components.Mermaid` overrides, which already
790
+ works end-to-end.
791
+
792
+ ### Known limitations
793
+
794
+ - The three new caches disarm when `gfmFootnotes` is on, mirroring
795
+ `TableCache` from 0.5.2: cell-level `[^x]` occurrence ids would diverge
796
+ across the cache vs. full-reparse boundary. Footnotes + a long container
797
+ / table stays on the full O(n²) path — rare combination, may be lifted
798
+ in a later release by tracking per-cache footnote-occ deltas.
799
+ - The blockquote/alert cache covers the *single-paragraph* inner case (the
800
+ realistic LLM shape). A long open container with a multi-block inner
801
+ (lists inside, fenced code inside, etc.) still routes through the full
802
+ path. The bench's `big_blockquote` / `big_alert` are single-paragraph
803
+ shapes — what these caches were built for.
804
+
805
+ ## 0.5.2 — 2026-05-28
806
+
807
+ ### Performance
808
+
809
+ - **Streaming a long GFM table is now O(n) at every chunk size.** Tables already
810
+ rendered visually incrementally (header at the delimiter row, rows append as
811
+ they arrive) — but `render_table` re-walked every row on every append, so the
812
+ total work was O(n²) once chunks exceeded ~30 bytes (a row). The fix is an
813
+ incremental `TableCache` that mirrors the existing code/math `FenceCache`:
814
+ `<thead>` is pre-rendered once, each newly-complete `<tr>` is folded into the
815
+ cached prefix, and only the trailing partial row is re-rendered each append.
816
+ Output is byte-identical; parity gated by `tests/table_cache.rs` (every chunk
817
+ size 1..=9 × char-by-char against one-shot, with alignments, inline markdown,
818
+ link refs, CRLF fallback, and a 400-row stress case).
819
+
820
+ Measured on a 200 KB table (best of 7 — chunk varies on each row):
821
+
822
+ | chunk | before | after | speedup |
823
+ |------:|---------:|------:|--------:|
824
+ | 16 | 143 ms | 145 ms | ~1× (was already fast) |
825
+ | 64 | 20807 ms | 78 ms | **267×** |
826
+ | 128 | 10414 ms | 54 ms | **193×** |
827
+ | 256 | 5373 ms | 40 ms | **134×** |
828
+ | 512 | 2608 ms | 34 ms | **77×** |
829
+ | 1024 | 1322 ms | 31 ms | **43×** |
830
+
831
+ The pre-fix bench printed only chunks 16 and 256, which hid the regression
832
+ (16 was fine, 256 was the cliff floor). The bench now sweeps 16/64/128/256/
833
+ 512/1024 so the next regression in this shape can't slip in unnoticed.
834
+
835
+ Footnotes are the one combination still on the full O(n²) path: the
836
+ cell-level `[^x]` occurrence counter would diverge across the
837
+ cache/full-reparse boundary, so the cache disarms when `gfmFootnotes` is on
838
+ (rare enough to defer to a later release).
839
+
840
+ ## 0.5.1 — 2026-05-27
841
+
842
+ ### Performance
843
+
844
+ - A document with a very large number of link-reference definitions is now O(n)
845
+ instead of O(n²). The committed reference table was cloned on every append
846
+ (O(refs) per chunk); it's now shared into each render via an `Rc` (O(1)) with a
847
+ two-level lookup (committed, then the uncommitted tail), and folded in place
848
+ via `Rc::make_mut` once the render's clone is dropped. A 235 KB
849
+ reference-definition stream at 16-byte chunks: **~1,395 ms → ~53 ms** (~26×).
850
+ This was believed to be the last remaining O(n²) streaming shape; in fact a
851
+ long open GFM table was still O(n²) (fixed in 0.5.2 — `big_table` at
852
+ chunk=256 went from ~5,400 ms to ~40 ms). Output is unchanged.
853
+
854
+ ## 0.5.0 — 2026-05-27
855
+
856
+ ### Fixed
857
+
858
+ - **Streaming GFM tables now render incrementally.** A table no longer waits for
859
+ the whole block to arrive: the header renders the moment the delimiter row
860
+ (`|---|`) streams in, and each body row appends as it arrives. Previously the
861
+ incremental paragraph fast-path kept extending the header line as a paragraph
862
+ and only formed the table on a full reparse, so a streaming table appeared all
863
+ at once. The fast-path now bails (like it does for a setext underline) when a
864
+ delimiter row forms a table with its preceding header. Output is unchanged for
865
+ one-shot parsing; streamed output now matches one-shot at every prefix.
866
+
867
+ ### Added
868
+
869
+ - **`<FluxMarkdown sanitize={fn} />`** — an optional HTML sanitizer hook. When
870
+ provided, flux-md runs every block's HTML through it before injecting via
871
+ `innerHTML`, **including the streaming (open/speculative) tail** that the raw
872
+ fast path would otherwise expose. Bring your own sanitizer (e.g.
873
+ `DOMPurify.sanitize`) to render untrusted / LLM HTML with `unsafeHtml` on;
874
+ flux-md stays zero-dep. Built-in code/math renderers (already-escaped content)
875
+ are not run through it, so highlighting and math markup are preserved. Omitting
876
+ the prop is byte-identical and zero-cost.
877
+
878
+ ## 0.4.0 — 2026-05-27
879
+
880
+ ### Added
881
+
882
+ - **`componentTags`** — opt-in custom component tags. List tag names (e.g.
883
+ `componentTags: ['Thinking', 'Callout']`) and a `<Thinking>…</Thinking>` in the
884
+ stream renders as a component whose **inner content is parsed as markdown** —
885
+ safely, **without `unsafeHtml`**: the tag is allowlisted and its attributes are
886
+ sanitized (event handlers dropped, dangerous URL schemes neutralized). The
887
+ container spans blank lines (unlike a raw HTML block) up to its matching close
888
+ tag, supports nesting, and ignores a `</Tag>` inside a code fence. Each renders
889
+ as a `Component` block dispatched on the React side via `components[tag]` (e.g.
890
+ `components.Thinking`) or the generic `components.Component`, receiving `{ tag,
891
+ attrs, … }`. Off unless configured; tag names match case-sensitively.
892
+
893
+ ### Performance
894
+
895
+ - Streaming a long open display-math block (`$$…$$` / `\[…\]`) is now O(n)
896
+ instead of O(n²). The incremental fence cache that already covered code fences
897
+ was generalized to math fences: an append only escapes the newly arrived lines
898
+ instead of re-scanning and re-escaping the whole growing body. Measured on a
899
+ 200 KB `$$…$$` block at 16-byte chunks: **16,271 ms → ~93 ms** (~174×). Output
900
+ is byte-identical (gated by `tests/math_fence_cache.rs`).
901
+ - A long trailing run of link-reference / footnote definitions now commits
902
+ incrementally instead of being re-scanned on every append. Previously such a
903
+ run produced no renderable blocks, so the committed offset never advanced. A
904
+ document ending in a large reference section streams ~10× faster (235 KB at
905
+ 16-byte chunks: **13,799 ms → ~1,380 ms**). Output is byte-identical (gated by
906
+ `tests/ref_defs_streaming.rs`).
907
+
908
+ ## 0.3.2 — 2026-05-27
909
+
910
+ ### Documentation
911
+
912
+ - Rewrote the README to describe flux-md on its own terms and removed all
913
+ references to and comparisons with other libraries. No code changes — the
914
+ published API and behavior are identical to 0.3.1.
915
+ - Fixed the React quick-start example: import `useEffect` and guard the async
916
+ append loop so it can't run after unmount or a stream change.
917
+
918
+ ## 0.3.1 — 2026-05-27
919
+
920
+ ### Performance
921
+
922
+ - Streaming a long unbroken paragraph is now O(n) instead of O(n²) — including
923
+ paragraphs **dense with inline constructs** (emphasis, code spans, links,
924
+ inline math), not just plain text. The open paragraph commits its settled
925
+ prefix and re-renders only the short active tail. Because inline output isn't
926
+ prefix-stable (a late `*` re-emphasizes earlier text, a late backtick opens a
927
+ code span), the stable boundary is computed inside the inline renderer itself:
928
+ it tracks unmatched openers, unpaired forward-pairable emphasis, and resolved
929
+ emphasis spans, and commits only up to the largest provably-final cut. Output
930
+ is byte-identical. Measured on 200 KB single paragraphs at 16-byte chunks:
931
+ plain **34,167 ms → ~130 ms** (~260×); emphasis-rich **60,569 ms → ~157 ms**
932
+ (~386×).
933
+ - The open-code-fence fast path no longer clones the accumulated escaped body on
934
+ every append; it assembles the block HTML directly from the cached pieces,
935
+ dropping one full O(body) copy per append. A 200 KB fence streams in **~82 ms**
936
+ at 16-byte chunks (was ~154 ms, ~1.9×). Output is byte-identical.
937
+
938
+ ## 0.3.0
939
+
940
+ ### Added
941
+
942
+ - **`gfmMath`** — opt-in math. Inline `$…$` and `\(…\)`; display `$$…$$` and
943
+ `\[…\]`. Inline `$` uses the pandoc rule, so currency like `$5 and $10` stays
944
+ literal. Emits KaTeX-ready markup (`<span class="math math-inline">` /
945
+ `<div class="math math-display">`) carrying the LaTeX as text content — bring
946
+ your own KaTeX (flux-md stays zero-dep) or override `components.MathBlock`
947
+ (which receives the LaTeX as `text`). Display fences are blank-line tolerant
948
+ and stream incrementally. Off by default.
949
+ - **`dirAuto`** — opt-in per-block `dir="auto"` on block-level text elements
950
+ (`p`, `h1`–`h6`, `blockquote`, `ul`/`ol`/`li`, `table`, alerts, footnotes), so
951
+ the browser detects each block's direction (RTL/LTR) independently in
952
+ mixed-language documents. Code blocks stay LTR. Off by default.
953
+
954
+ ### Performance
955
+
956
+ - Streaming a long fenced code block is now **O(n) instead of O(n²)**: an open
957
+ code fence caches its escaped body and extends it by only the newly arrived
958
+ lines. Measured on a 200 KB fence — **14,278 ms → 230 ms** at 16-byte chunks,
959
+ **898 ms → 22 ms** at 256-byte chunks. Output is byte-identical.
960
+ - Dropped a redundant per-append clone of the link-reference table.
961
+
962
+ ### Known limitations
963
+
964
+ - Streaming a very long **unbroken** paragraph (no blank lines) is still O(n²):
965
+ inline rendering re-runs over the whole paragraph each chunk, and unlike code
966
+ it can't be prefix-cached (a late `*` can emphasize earlier text). Tracked for
967
+ a future release; breaking the text into paragraphs avoids it.
968
+
969
+ ### Internal
970
+
971
+ - Added a Rust streaming-throughput benchmark (`cargo run --release --example
972
+ bench`) plus char-by-char streaming-parity tests for the code-fence cache,
973
+ math, and bidi paths.
974
+
975
+ ## 0.2.0
976
+
977
+ - Initial public release: zero-dep streaming markdown, Rust→WASM core, one Web
978
+ Worker per stream, CommonMark 0.31 (652/652) + GFM (tables, strikethrough,
979
+ task lists, extended autolinks, GitHub alerts, footnotes).