flux-md 0.15.0 → 0.16.0

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/src/dom.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { FluxClient } from "./client";
2
2
  import { highlight } from "./hi";
3
- import type { Block, BlockComponentProps, BlockKindTag } from "./types-core";
3
+ import { morph } from "./morph";
4
+ import type { Align, Block, BlockComponentProps, BlockKindTag, ListData, RenderMetricsHook, TableData } from "./types-core";
4
5
  import { blockProps, extractLang } from "./block-props";
5
6
 
6
7
  /**
@@ -28,6 +29,18 @@ import { blockProps, extractLang } from "./block-props";
28
29
  export interface MountHandle {
29
30
  destroy(): void;
30
31
  refresh(): void;
32
+ /**
33
+ * The id of the streaming **tail** block — the one block that may re-render on
34
+ * the next patch (a committed block's node is frozen, so its id never appears
35
+ * here). Returns `null` when no block is open (idle / fully committed).
36
+ *
37
+ * Purely derived from the live snapshot; reading it renders nothing and mutates
38
+ * nothing. It exists so a fine-grained framework binding (Solid `createMemo`,
39
+ * Vue `computed`, Svelte `derived`) can narrow a reactive cell to *just the tail*
40
+ * for its own scheduling/diagnostics — the DOM is already updated by the
41
+ * renderer's own subscribe loop, so this never changes what is drawn.
42
+ */
43
+ openBlockId(): number | null;
31
44
  }
32
45
 
33
46
  export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
@@ -61,6 +74,16 @@ export interface MountOptions {
61
74
  highlightCode?: boolean;
62
75
  /** Coalesce patches into one DOM write per animation frame. Default true. */
63
76
  batch?: boolean;
77
+ /**
78
+ * Opt-in (default false). When a generic open/streaming block grows, morph its
79
+ * existing DOM subtree **in place** toward the new HTML instead of rebuilding
80
+ * the whole node with `innerHTML`. The browser then only repaints/relayouts
81
+ * the parts that changed, and focus/text-selection inside the streaming tail
82
+ * survive a token append. The default path (full rebuild) is byte-identical
83
+ * and unchanged; this only affects generic blocks rendered via the `innerHTML`
84
+ * fast path (not code/math/mermaid/component overrides). The morphed subtree is
85
+ * equivalent to the rebuilt one. */
86
+ morphOpenBlocks?: boolean;
64
87
  /** Appended to the root's `className` (the `flux-md` class is always present). */
65
88
  className?: string;
66
89
  /** Set on the root element. */
@@ -74,6 +97,15 @@ export interface MountOptions {
74
97
  ariaLive?: "off" | "polite" | "assertive";
75
98
  /** Live-region atomicity; pair with `ariaLive`. Off by default. */
76
99
  ariaAtomic?: boolean;
100
+ /**
101
+ * Optional render-churn probe. Fires once per ACTUAL node build/rebuild of a
102
+ * block — never for a committed block whose node is reused untouched on a
103
+ * tail-only patch. The callback gets the block id and a {@link RenderMetrics}
104
+ * sample (per-block `renderCount`/rebuild count, `speculativeToggleCount`,
105
+ * `lastRenderMs`, `kind`). Zero overhead when omitted, and advances
106
+ * `client.getMetrics().rebuildCount`.
107
+ */
108
+ onRenderMetrics?: RenderMetricsHook;
77
109
  }
78
110
 
79
111
  // Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
@@ -93,6 +125,33 @@ interface MountedBlock {
93
125
  open: boolean;
94
126
  speculative: boolean;
95
127
  kind: BlockKindTag;
128
+ // Render-churn probe state (only maintained when an onRenderMetrics hook is
129
+ // wired; otherwise these stay at their initial values and are never read).
130
+ renderCount: number;
131
+ toggleCount: number;
132
+ // Set only for an OPEN table rendered via the keyed-tbody path (blockData on).
133
+ // Lets a later patch update just the growing trailing row in place instead of
134
+ // rebuilding the whole node.
135
+ table?: KeyedTable;
136
+ // True when `node` is the generic `<div class="flux-block…">` whose entire
137
+ // `innerHTML` is exactly `html` (no special wrapper, no sanitizer transform).
138
+ // Only such a node is eligible for the prefix-extension tail-append fast path.
139
+ generic: boolean;
140
+ }
141
+
142
+ // Incremental keyed-tbody state for one OPEN table. `<tr>` nodes for committed
143
+ // rows are appended once and never rebuilt; only the last (open) row's cells are
144
+ // re-set each patch — never a whole-`<tbody>` rebuild.
145
+ interface KeyedTable {
146
+ table: HTMLTableElement;
147
+ tbody: HTMLTableSectionElement | null;
148
+ scope: boolean;
149
+ // Number of LEADING rows whose `<tr>` is frozen in the DOM (built once and
150
+ // never touched again). The last data row is OPEN — re-rendered each patch —
151
+ // so after every update `committed === rows.length - 1`.
152
+ committed: number;
153
+ // The current open trailing `<tr>` (re-rendered each patch); replaced in place.
154
+ lastRow: HTMLTableRowElement | null;
96
155
  }
97
156
 
98
157
  export function mountFluxMarkdown(
@@ -107,9 +166,11 @@ export function mountFluxMarkdown(
107
166
  // Normalize "no overrides" to undefined so the fast path doesn't churn.
108
167
  const components =
109
168
  options.components && Object.keys(options.components).length > 0 ? options.components : undefined;
110
- const { sanitize, virtualize, stickToBottom } = options;
169
+ const { sanitize, virtualize, stickToBottom, onRenderMetrics } = options;
170
+ const hasPerf = typeof performance !== "undefined";
111
171
  const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
112
172
  const batch = options.batch !== false && typeof requestAnimationFrame === "function";
173
+ const morphOpenBlocks = options.morphOpenBlocks === true;
113
174
 
114
175
  const root = document.createElement("div");
115
176
  root.className = options.className ? `flux-md ${options.className}` : "flux-md";
@@ -133,6 +194,10 @@ export function mountFluxMarkdown(
133
194
  let order: number[] = [];
134
195
  let dead = false;
135
196
  let frame = 0;
197
+ // Set by `renderBlockContent` for the call in flight: true only when it took
198
+ // the generic `<div class="flux-block…">innerHTML=html` path (no override, no
199
+ // dedicated renderer). Read immediately after the render to tag the mount.
200
+ let lastRenderGeneric = false;
136
201
 
137
202
  function sync(): void {
138
203
  if (dead) return;
@@ -146,10 +211,16 @@ export function mountFluxMarkdown(
146
211
  seen.add(b.id);
147
212
  const existing = mounted.get(b.id);
148
213
  if (!existing) {
149
- const node = renderBlock(b);
150
- mounted.set(b.id, {
151
- id: b.id, node, html: b.html, open: b.open, speculative: b.speculative, kind: b.kind.type,
152
- });
214
+ const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
215
+ const mb: MountedBlock = {
216
+ id: b.id, node: undefined as unknown as HTMLElement,
217
+ html: b.html, open: b.open, speculative: b.speculative, kind: b.kind.type,
218
+ renderCount: 0, toggleCount: 0, generic: false,
219
+ };
220
+ mb.node = renderBlock(b, mb);
221
+ mb.generic = lastRenderGeneric;
222
+ mounted.set(b.id, mb);
223
+ if (onRenderMetrics) noteRender(mb, b, t0);
153
224
  continue;
154
225
  }
155
226
  // Unchanged fingerprint → reuse the node untouched. Committed blocks land
@@ -158,14 +229,86 @@ export function mountFluxMarkdown(
158
229
  if (existing.html === b.html && existing.open === b.open && existing.speculative === b.speculative) {
159
230
  continue;
160
231
  }
161
- // Changed rebuild and swap in place.
162
- const node = renderBlock(b);
232
+ const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
233
+ // Keyed-table fast path: an OPEN table that already mounted via the keyed
234
+ // tbody updates only its growing trailing row in place — committed `<tr>`
235
+ // nodes are never rebuilt. Reuses the same block node (no replaceWith).
236
+ // This is still a render of the node, so it feeds the render-churn probe.
237
+ if (existing.table && b.open && b.kind.type === "Table") {
238
+ const data = tableData(b);
239
+ if (data) {
240
+ syncTbody(existing.table, data);
241
+ if (onRenderMetrics) noteRender(existing, b, t0);
242
+ // Keep the wrapper's speculative class in sync (parity with the
243
+ // full-rebuild path) without recreating the node.
244
+ if (existing.speculative !== b.speculative) {
245
+ existing.node.classList.toggle("flux-speculative", b.speculative);
246
+ }
247
+ existing.html = b.html;
248
+ existing.open = b.open;
249
+ existing.speculative = b.speculative;
250
+ continue;
251
+ }
252
+ }
253
+ // Opt-in morph fast path: an open generic block that only grew its HTML
254
+ // (same kind, still routed through the innerHTML path) is morphed in place,
255
+ // preserving the node's identity, focus, and selection. Falls through to a
256
+ // full rebuild for anything not eligible (commit transition, kind change,
257
+ // code/math/mermaid/override blocks). This is still a render of the node, so
258
+ // it feeds the render-churn probe.
259
+ if (
260
+ morphOpenBlocks &&
261
+ b.open &&
262
+ existing.open &&
263
+ existing.generic &&
264
+ existing.kind === b.kind.type &&
265
+ usesGenericPath(b)
266
+ ) {
267
+ morph(existing.node, sanitize ? sanitize(b.html) : b.html);
268
+ if (onRenderMetrics) noteRender(existing, b, t0);
269
+ existing.html = b.html;
270
+ existing.speculative = b.speculative;
271
+ existing.node.className = genericClassName(b);
272
+ // The node stays the generic innerHTML mirror, so it remains eligible for
273
+ // the prefix-append / morph fast paths on later patches.
274
+ existing.generic = !sanitize;
275
+ continue;
276
+ }
277
+ // Prefix-extension tail-append fast path (generic blocks only, no
278
+ // sanitizer). When the new html merely *appends* one or more WHOLE
279
+ // top-level sibling elements to the old html, we can splice the suffix
280
+ // onto the live node instead of rebuilding the whole subtree. The result
281
+ // is byte-identical to a full rebuild because the appended suffix is
282
+ // self-contained markup that begins a new depth-0 sibling — the browser
283
+ // parses it the same way whether appended or rendered whole. This is still
284
+ // a render of the node, so it feeds the render-churn probe.
285
+ if (
286
+ !sanitize &&
287
+ existing.generic &&
288
+ existing.kind === b.kind.type &&
289
+ existing.open === b.open &&
290
+ existing.speculative === b.speculative &&
291
+ b.html.length > existing.html.length &&
292
+ b.html.startsWith(existing.html) &&
293
+ isDepth0Boundary(existing.html, b.html)
294
+ ) {
295
+ existing.node.insertAdjacentHTML("beforeend", b.html.slice(existing.html.length));
296
+ if (onRenderMetrics) noteRender(existing, b, t0);
297
+ existing.html = b.html;
298
+ continue;
299
+ }
300
+ // Changed → rebuild and swap in place. A table that just closed (or whose
301
+ // data vanished) drops its keyed manager and re-renders the full HTML once.
302
+ existing.table = undefined;
303
+ const node = renderBlock(b, existing);
163
304
  existing.node.replaceWith(node);
164
305
  existing.node = node;
306
+ if (onRenderMetrics) noteRender(existing, b, t0);
165
307
  existing.html = b.html;
166
308
  existing.open = b.open;
167
309
  existing.speculative = b.speculative;
168
310
  existing.kind = b.kind.type;
311
+ existing.generic = lastRenderGeneric;
169
312
  }
170
313
 
171
314
  // Drop ids no longer present (reset() empties the snapshot; a speculative
@@ -183,6 +326,22 @@ export function mountFluxMarkdown(
183
326
  reconcileChildren();
184
327
  }
185
328
 
329
+ // Fire the render-churn probe for one actual node build/rebuild. `mb` carries
330
+ // the PRE-update fingerprint (its `speculative` is the prior value) so the
331
+ // toggle count is correct; the caller updates the fingerprint afterward. Only
332
+ // called when an onRenderMetrics hook is wired, so it stays zero-cost off.
333
+ function noteRender(mb: MountedBlock, b: Block, t0: number): void {
334
+ mb.renderCount++;
335
+ if (mb.speculative !== b.speculative) mb.toggleCount++;
336
+ client.__noteRebuild();
337
+ onRenderMetrics!(b.id, {
338
+ renderCount: mb.renderCount,
339
+ speculativeToggleCount: mb.toggleCount,
340
+ lastRenderMs: hasPerf ? performance.now() - t0 : 0,
341
+ kind: b.kind.type,
342
+ });
343
+ }
344
+
186
345
  // Keyed reconcile with a single forward cursor (O(n), not O(n²)): walk the
187
346
  // desired order and the live children in lockstep, inserting/moving only a
188
347
  // node whose live position differs. The `.flux-bottom-anchor` is never part of
@@ -205,8 +364,8 @@ export function mountFluxMarkdown(
205
364
  }
206
365
  }
207
366
 
208
- function renderBlock(b: Block): HTMLElement {
209
- const content = renderBlockContent(b);
367
+ function renderBlock(b: Block, mb: MountedBlock): HTMLElement {
368
+ const content = renderBlockContent(b, mb);
210
369
  // Virtualize only *closed* blocks. Unlike the JSX renderer (which wraps in
211
370
  // an extra div) the DOM renderer sets the properties on the block node
212
371
  // directly — one of the documented byte-faithfulness divergences.
@@ -218,8 +377,9 @@ export function mountFluxMarkdown(
218
377
  return content;
219
378
  }
220
379
 
221
- function renderBlockContent(b: Block): HTMLElement {
380
+ function renderBlockContent(b: Block, mb: MountedBlock): HTMLElement {
222
381
  const kind = b.kind.type;
382
+ lastRenderGeneric = false;
223
383
 
224
384
  // 1. Block-kind override (a Component block dispatches on its tag first).
225
385
  if (components) {
@@ -243,17 +403,219 @@ export function mountFluxMarkdown(
243
403
  return renderMermaid(b);
244
404
  }
245
405
 
406
+ // 2b. Keyed-table path for the streaming tail: an OPEN table with `blockData`
407
+ // renders a real `<table>` whose committed `<tr>` nodes are appended once and
408
+ // frozen, so a later patch updates only the growing trailing row. Closed
409
+ // tables (and blockData-off tables) take the generic full-HTML path below
410
+ // (closed nodes are frozen by the fingerprint check, already free).
411
+ if (kind === "Table" && b.open) {
412
+ const data = tableData(b);
413
+ if (data) return buildKeyedTable(b, data, mb);
414
+ }
415
+
416
+ // 2c. Keyed list renderer (opt-in: only when `blockData` is on, so
417
+ // `kind.data.items` carries per-item inner HTML). For an OPEN list, stamp one
418
+ // `<li>` per item — each item's inner HTML routed through the SAME sanitize
419
+ // path the generic innerHTML branch uses — so the rebuilt list tracks the
420
+ // structured items instead of re-parsing the whole `<ul>`/`<ol>` HTML. Closed
421
+ // lists fall through (their node is reused untouched, never rebuilt).
422
+ if (b.open && kind === "List") {
423
+ const keyed = renderKeyedList(b);
424
+ if (keyed) return keyed;
425
+ }
426
+
246
427
  // 3. Generic fast path.
247
428
  const node = document.createElement("div");
429
+ node.className = genericClassName(b);
430
+ // Streaming-tail keyed path: an OPEN Blockquote / Alert with structured
431
+ // `nested` data (blockData on) builds its wrapper with one child node per
432
+ // inner sub-block instead of a single full-wrapper `innerHTML`. Each child's
433
+ // `html` is the SAME safe-allowlist-serialized fragment as the corresponding
434
+ // slice of `b.html` (no new innerHTML hole). A `sanitize` hook disables it
435
+ // (it must run over the full wrapper string). Closed blocks fall through —
436
+ // their node fingerprint is stable, so they are never rebuilt anyway.
437
+ if (b.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
438
+ const wrapper = renderKeyedContainer(b);
439
+ if (wrapper) {
440
+ node.appendChild(wrapper);
441
+ return node;
442
+ }
443
+ }
444
+ node.innerHTML = sanitize ? sanitize(b.html) : b.html;
445
+ // Eligible for the prefix-append fast path only when no sanitizer rewrote
446
+ // the html (the stored `html` must equal the node's actual innerHTML source).
447
+ lastRenderGeneric = !sanitize;
448
+ return node;
449
+ }
450
+
451
+ // Build a `<div class="flux-block flux-block-list flux-open …"><ul|ol>…</ul|ol>`
452
+ // node from the structured `kind.data.items`, one `<li>` per item with its inner
453
+ // HTML sanitized via the shared `sanitize` path. Returns `null` when the items
454
+ // channel is absent (blockData off) so the caller falls back to opaque HTML.
455
+ function renderKeyedList(b: Block): HTMLElement | null {
456
+ const ld = b.kind.data as ListData | undefined;
457
+ const items = ld?.items;
458
+ if (!items || items.length === 0) return null;
459
+ const node = document.createElement("div");
248
460
  node.className =
249
- "flux-block flux-block-" +
250
- kind.toLowerCase() +
461
+ "flux-block flux-block-list" +
251
462
  (b.open ? " flux-open" : "") +
252
463
  (b.speculative ? " flux-speculative" : "");
253
- node.innerHTML = sanitize ? sanitize(b.html) : b.html;
464
+ const list = document.createElement(ld?.ordered ? "ol" : "ul");
465
+ if (ld?.ordered && ld.start !== undefined && ld.start !== 1) {
466
+ list.setAttribute("start", String(ld.start));
467
+ }
468
+ for (const it of items) {
469
+ const li = document.createElement("li");
470
+ li.innerHTML = sanitize ? sanitize(it.html) : it.html;
471
+ list.appendChild(li);
472
+ }
473
+ node.appendChild(list);
474
+ return node;
475
+ }
476
+
477
+ // Build a Blockquote / Alert wrapper with KEYED inner sub-block nodes from the
478
+ // structured `nested` channel. The wrapper element + its attributes (`dir`/
479
+ // `class`/`data-alert`/`role`) come from `b.html`'s opening tag so the streamed
480
+ // wrapper is byte-faithful; the alert title `<p>` is kept as the first child
481
+ // (it is the wrapper, not a body block). Returns null when `nested` is absent.
482
+ function renderKeyedContainer(b: Block): HTMLElement | null {
483
+ const nested = (b.kind.data as { nested?: { html: string }[] } | undefined)?.nested;
484
+ if (!Array.isArray(nested)) return null;
485
+ const tagName = b.kind.type === "Alert" ? "div" : "blockquote";
486
+ const wrapper = document.createElement(tagName);
487
+ applyOpenTagAttrs(wrapper, b.html);
488
+ if (b.kind.type === "Alert") {
489
+ const title = alertTitleHtml(b.html);
490
+ if (title) {
491
+ const t = document.createElement("div");
492
+ t.innerHTML = title;
493
+ const titleNode = t.firstElementChild;
494
+ if (titleNode) wrapper.appendChild(titleNode);
495
+ }
496
+ }
497
+ for (let i = 0; i < nested.length; i++) {
498
+ const child = document.createElement("div");
499
+ child.innerHTML = nested[i].html;
500
+ const inner = child.firstElementChild;
501
+ // A nested block is a single root element (`<p>…</p>`, `<ul>…</ul>`, …);
502
+ // unwrap the temp `<div>` so the wrapper holds the real element directly.
503
+ wrapper.appendChild(inner ?? child);
504
+ }
505
+ return wrapper;
506
+ }
507
+
508
+ // Build the initial keyed table node + manager. The `<thead>` and all-but-last
509
+ // `<tr>` are emitted once; the manager remembers the committed row count so a
510
+ // later patch (via syncTbody) only re-renders the open trailing row.
511
+ function buildKeyedTable(b: Block, data: TableData, mb: MountedBlock): HTMLElement {
512
+ const node = document.createElement("div");
513
+ node.className = "flux-block flux-block-table flux-open" + (b.speculative ? " flux-speculative" : "");
514
+ const table = document.createElement("table");
515
+ if (b.html.startsWith('<table dir="auto"')) table.setAttribute("dir", "auto");
516
+ const scope = b.html.includes('<th scope="col"');
517
+
518
+ const thead = document.createElement("thead");
519
+ const htr = document.createElement("tr");
520
+ for (let j = 0; j < data.headers.length; j++) {
521
+ htr.appendChild(makeCell("th", data.headers[j].html, data.aligns[j] ?? null, scope));
522
+ }
523
+ thead.appendChild(htr);
524
+ table.appendChild(thead);
525
+
526
+ const km: KeyedTable = { table, tbody: null, scope, committed: 0, lastRow: null };
527
+ mb.table = km;
528
+ node.appendChild(table);
529
+ syncTbody(km, data);
254
530
  return node;
255
531
  }
256
532
 
533
+ // Append any newly-committed rows once, then (re)render only the open trailing
534
+ // row. Shared by build (committed===0) and update. The whole `<tbody>` is never
535
+ // rebuilt — committed `<tr>` nodes keep their identity across patches.
536
+ function syncTbody(km: KeyedTable, data: TableData): void {
537
+ const n = data.rows.length;
538
+ if (n === 0) {
539
+ // No body rows yet (header-only streamed table). Tear down any stale tbody.
540
+ if (km.tbody) {
541
+ km.tbody.remove();
542
+ km.tbody = null;
543
+ }
544
+ km.committed = 0;
545
+ km.lastRow = null;
546
+ return;
547
+ }
548
+ if (!km.tbody) {
549
+ km.tbody = document.createElement("tbody");
550
+ km.table.appendChild(km.tbody);
551
+ }
552
+ const tbody = km.tbody;
553
+ // The prior open trailing row is now superseded — drop it before freezing the
554
+ // rows that have since committed and rendering the new trailing row.
555
+ if (km.lastRow) {
556
+ km.lastRow.remove();
557
+ km.lastRow = null;
558
+ }
559
+ // Freeze every row from the first uncommitted up to (but not including) the
560
+ // last: append its `<tr>` once and never touch it again (committed cell html
561
+ // is byte-stable).
562
+ for (let i = km.committed; i < n - 1; i++) {
563
+ tbody.appendChild(makeRow(data.rows[i], data.aligns));
564
+ }
565
+ km.committed = n - 1;
566
+ // Render the still-OPEN last row and remember it so the next patch replaces it.
567
+ const last = makeRow(data.rows[n - 1], data.aligns);
568
+ tbody.appendChild(last);
569
+ km.lastRow = last;
570
+ }
571
+
572
+ function makeRow(cells: TableData["rows"][number], aligns: Align[]): HTMLTableRowElement {
573
+ const tr = document.createElement("tr");
574
+ for (let j = 0; j < cells.length; j++) {
575
+ tr.appendChild(makeCell("td", cells[j].html, aligns[j] ?? null, false));
576
+ }
577
+ return tr;
578
+ }
579
+
580
+ function makeCell(tag: "th" | "td", html: string, align: Align, scope: boolean): HTMLElement {
581
+ const cell = document.createElement(tag);
582
+ if (tag === "th" && scope) cell.setAttribute("scope", "col");
583
+ if (align) cell.style.textAlign = align;
584
+ // Route cell html through the same sanitize path the generic block uses.
585
+ cell.innerHTML = sanitize ? sanitize(html) : html;
586
+ return cell;
587
+ }
588
+
589
+ // The class string for a generic-path block node. Shared by the initial
590
+ // render and the in-place morph branch so a morphed node keeps the exact
591
+ // class string (e.g. dropping `flux-speculative`) a rebuild would have set.
592
+ function genericClassName(b: Block): string {
593
+ return (
594
+ "flux-block flux-block-" +
595
+ b.kind.type.toLowerCase() +
596
+ (b.open ? " flux-open" : "") +
597
+ (b.speculative ? " flux-speculative" : "")
598
+ );
599
+ }
600
+
601
+ // True when a block renders through the generic `innerHTML` fast path — the
602
+ // only path the in-place morph applies to. Mirrors the dispatch order in
603
+ // renderBlockContent: an override (block-kind or Component tag) or a dedicated
604
+ // renderer (highlighted code / math / mermaid) all opt OUT of morphing.
605
+ function usesGenericPath(b: Block): boolean {
606
+ const kind = b.kind.type;
607
+ if (components) {
608
+ if (kind === "Component") {
609
+ const tag = (b.kind.data as { tag?: string } | undefined)?.tag;
610
+ if ((tag && components[tag]) || components.Component) return false;
611
+ }
612
+ if (components[kind]) return false;
613
+ }
614
+ if (kind === "CodeBlock") return !highlightCode;
615
+ if (kind === "MathBlock" || kind === "Mermaid") return false;
616
+ return true;
617
+ }
618
+
257
619
  // An override may return an element (used directly) or an HTML string (wrapped
258
620
  // in a div so the renderer always owns a single block node to track/swap).
259
621
  function wrapOverrideResult(result: HTMLElement | string): HTMLElement {
@@ -430,9 +792,110 @@ export function mountFluxMarkdown(
430
792
  if (dead) return;
431
793
  sync();
432
794
  },
795
+ openBlockId() {
796
+ return tailOpenBlockId(client.getSnapshot());
797
+ },
433
798
  };
434
799
  }
435
800
 
801
+ // The structured `TableData` (opt-in `blockData`) on a Table block, or
802
+ // `undefined` when the flag is off (the keyed path then falls back to full HTML).
803
+ function tableData(b: Block): TableData | undefined {
804
+ if (b.kind.type !== "Table") return undefined;
805
+ const data = b.kind.data as TableData | undefined;
806
+ return data && Array.isArray(data.rows) ? data : undefined;
807
+ }
808
+
809
+ // HTML void elements: they self-terminate, so they never push element depth.
810
+ const VOID_ELEMENTS = new Set([
811
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link",
812
+ "meta", "param", "source", "track", "wbr",
813
+ ]);
814
+
815
+ /**
816
+ * True when `prefix` is a complete run of balanced top-level markup (element
817
+ * depth returns to 0 at its end and any trailing whitespace/text is harmless)
818
+ * AND the appended suffix `full.slice(prefix.length)` begins a NEW depth-0
819
+ * sibling element (an opening tag, not a close tag / text / mid-tag).
820
+ *
821
+ * When both hold, splicing the suffix onto the live node via
822
+ * `insertAdjacentHTML('beforeend', suffix)` yields the exact same DOM the
823
+ * browser would build from parsing `full` whole — the appended markup is a
824
+ * self-contained sibling appended after the last existing child. Any other
825
+ * shape (an unclosed element at the prefix boundary, a suffix that continues
826
+ * text, closes a tag, or splits a tag) must fall back to a full rebuild.
827
+ *
828
+ * The scan is single-pass over `prefix` (O(prefix length)); it is run only on a
829
+ * confirmed `startsWith` prefix extension, so the amortized streaming cost stays
830
+ * proportional to the bytes seen.
831
+ */
832
+ function isDepth0Boundary(prefix: string, full: string): boolean {
833
+ // Suffix must open a new element: '<' immediately followed by an ASCII letter.
834
+ const c0 = full.charCodeAt(prefix.length);
835
+ if (c0 !== 60 /* '<' */) return false;
836
+ const c1 = full.charCodeAt(prefix.length + 1);
837
+ const isLetter = (c1 >= 65 && c1 <= 90) || (c1 >= 97 && c1 <= 122);
838
+ if (!isLetter) return false;
839
+
840
+ // Walk `prefix`, tracking element depth. Bail (return false) on anything we
841
+ // cannot cheaply prove balanced: comments, CDATA, processing instructions,
842
+ // or any tag that leaves the cursor inside markup at the end.
843
+ let depth = 0;
844
+ let i = 0;
845
+ const n = prefix.length;
846
+ while (i < n) {
847
+ const lt = prefix.indexOf("<", i);
848
+ if (lt === -1) break; // only text remains; depth unchanged
849
+ i = lt + 1;
850
+ if (i >= n) return false; // trailing '<' with nothing after → mid-tag
851
+ const ch = prefix.charCodeAt(i);
852
+ // Comments / CDATA / declarations / PIs: not handled — fall back.
853
+ if (ch === 33 /* '!' */ || ch === 63 /* '?' */) return false;
854
+ let closing = false;
855
+ if (ch === 47 /* '/' */) {
856
+ closing = true;
857
+ i++;
858
+ }
859
+ // Read the tag name.
860
+ const nameStart = i;
861
+ while (i < n) {
862
+ const t = prefix.charCodeAt(i);
863
+ const nameChar =
864
+ (t >= 65 && t <= 90) || (t >= 97 && t <= 122) || (t >= 48 && t <= 57) || t === 45;
865
+ if (!nameChar) break;
866
+ i++;
867
+ }
868
+ if (i === nameStart) return false; // '<' not followed by a tag name
869
+ const name = prefix.slice(nameStart, i).toLowerCase();
870
+ // Find the tag's '>' (attribute values here never contain a literal '>'
871
+ // because the renderer emits entity-escaped attributes; if we hit EOF first
872
+ // the prefix ends mid-tag → not a boundary).
873
+ const gt = prefix.indexOf(">", i);
874
+ if (gt === -1) return false;
875
+ const selfClosing = prefix.charCodeAt(gt - 1) === 47; /* '/' */
876
+ i = gt + 1;
877
+ if (closing) {
878
+ depth--;
879
+ if (depth < 0) return false; // unbalanced close
880
+ } else if (!selfClosing && !VOID_ELEMENTS.has(name)) {
881
+ depth++;
882
+ }
883
+ }
884
+ return depth === 0;
885
+ }
886
+
887
+ /**
888
+ * Derive the streaming tail's block id from an ordered snapshot: the id of the
889
+ * last block when it is open, else `null`. The open block is always the tail by
890
+ * construction (the parser only keeps the final block speculative/open), so this
891
+ * is an O(1) read of the last element — no scan. Shared so the framework
892
+ * adapters expose the same "what may re-render next" signal as the DOM handle.
893
+ */
894
+ export function tailOpenBlockId(snapshot: readonly Block[]): number | null {
895
+ const tail = snapshot.length > 0 ? snapshot[snapshot.length - 1] : undefined;
896
+ return tail && tail.open ? tail.id : null;
897
+ }
898
+
436
899
  // Local copy of the canonical code-text decoder (kept here so dom.ts depends
437
900
  // only on neutral modules; block-props.ts keeps its own private copy too).
438
901
  function decodeCodeText(html: string): string {
@@ -445,3 +908,28 @@ function decodeCodeText(html: string): string {
445
908
  .replace(/&#39;/g, "'")
446
909
  .replace(/&amp;/g, "&");
447
910
  }
911
+
912
+ // Attributes the Rust renderer emits on a blockquote / alert wrapper open tag
913
+ // (`dir`/`class`/`data-alert`/`role`). Whitelisted (not a generic HTML parser):
914
+ // only these names are forwarded onto the keyed wrapper element so it is
915
+ // byte-faithful to the full-wrapper innerHTML path.
916
+ const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
917
+ function applyOpenTagAttrs(el: HTMLElement, html: string): void {
918
+ const gt = html.indexOf(">");
919
+ const open = gt < 0 ? html : html.slice(0, gt);
920
+ let m: RegExpExecArray | null;
921
+ CONTAINER_ATTR_RE.lastIndex = 0;
922
+ while ((m = CONTAINER_ATTR_RE.exec(open))) {
923
+ const name = m[1].toLowerCase();
924
+ if (name === "class" || name === "dir" || name === "role" || name.startsWith("data-")) {
925
+ el.setAttribute(name, m[2]);
926
+ }
927
+ }
928
+ }
929
+
930
+ // Extract an alert's title `<p class="markdown-alert-title"…>Title</p>` from the
931
+ // wrapper HTML so the keyed path keeps it as the first child (never in `nested`).
932
+ function alertTitleHtml(html: string): string {
933
+ const m = html.match(/<p class="markdown-alert-title"[^>]*>[\s\S]*?<\/p>/);
934
+ return m ? m[0] : "";
935
+ }
package/src/element.ts CHANGED
@@ -90,6 +90,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
90
90
  return this.#components;
91
91
  }
92
92
  set components(value: DomComponents | undefined) {
93
+ if (value === this.#components) return; // no-op re-assign: don't remount
93
94
  this.#components = value;
94
95
  if (this.#connected) this.#remount();
95
96
  }
@@ -98,6 +99,7 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
98
99
  return this.#sanitize;
99
100
  }
100
101
  set sanitize(value: ((html: string) => string) | undefined) {
102
+ if (value === this.#sanitize) return; // no-op re-assign: don't remount
101
103
  this.#sanitize = value;
102
104
  if (this.#connected) this.#remount();
103
105
  }
@@ -155,10 +157,16 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
155
157
  }
156
158
  }
157
159
 
158
- attributeChangedCallback(name: string, _old: string | null, _new: string | null): void {
160
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
159
161
  // attributeChangedCallback fires before connectedCallback for attributes
160
162
  // present at upgrade; ignore until connected so config reads happen once.
161
163
  if (!this.#connected) return;
164
+ // setAttribute fires this on EVERY set, including setting an attribute to
165
+ // its current value (common when a host framework re-applies the same
166
+ // attrs on re-render). A no-op value change must not tear down the client
167
+ // and reparse the whole document — only a genuine change proceeds.
168
+ // (Attribute removal yields null, distinct from an empty string.)
169
+ if (oldValue === newValue) return;
162
170
 
163
171
  if (name === "markdown" || name === "src") {
164
172
  // One-shot content source change — only for a self-owned client. A