flux-md 0.15.1 → 0.16.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/README.md CHANGED
@@ -241,7 +241,11 @@ controlled-string helpers wrap; in vanilla you call it directly.
241
241
 
242
242
  `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
243
243
  Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
244
- (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
244
+ (default true), `batch` (default true — one DOM write per `requestAnimationFrame`),
245
+ `morphOpenBlocks` (default false — morph a growing generic open block's subtree in
246
+ place instead of rebuilding it via `innerHTML`, so only the changed parts repaint
247
+ and focus/selection in the streaming tail survive; the rendered result is
248
+ equivalent to the default rebuild path).
245
249
  Block-kind overrides use `components` keyed by block-kind (`CodeBlock`, `Table`,
246
250
  `Alert`, `Component`, …) with values `(props) => HTMLElement | string`. Tag-level
247
251
  (lowercase `a`/`table`/`code`) overrides are **React-only** — there's no virtual
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Zero-dep streaming markdown for the browser. Rust→WASM core, Web Worker per stream, incremental parse with speculative closure.",
5
5
  "type": "module",
6
6
  "sideEffects": ["./src/worker.ts", "./src/styles.css"],
@@ -22,8 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
- "README.md",
26
- "CHANGELOG.md"
25
+ "README.md"
27
26
  ],
28
27
  "peerDependencies": {
29
28
  "react": ">=18",
@@ -2,6 +2,7 @@ import type {
2
2
  Block,
3
3
  BlockComponentProps,
4
4
  CodeBlockData,
5
+ ContainerData,
5
6
  HeadingData,
6
7
  ListData,
7
8
  MathBlockData,
@@ -86,7 +87,7 @@ export function blockProps(block: Block): BlockComponentProps {
86
87
  speculative: block.speculative,
87
88
  };
88
89
  const data = block.kind.data as
89
- | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; tag?: string; attrs?: [string, string][] }
90
+ | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; items?: { html: string }[]; tag?: string; attrs?: [string, string][] }
90
91
  | undefined;
91
92
  if (block.kind.type === "CodeBlock") {
92
93
  // Prefer the structured `code` (present when blockData is on) over the HTML
@@ -105,9 +106,10 @@ export function blockProps(block: Block): BlockComponentProps {
105
106
  }
106
107
  } else if (block.kind.type === "List") {
107
108
  // Structured list data is present only when blockData is on (the `start` key
108
- // rides the opt-in channel); surface it as the typed convenience field.
109
+ // rides the opt-in channel); surface it as the typed convenience field,
110
+ // including the opt-in per-item HTML (`items`) for a keyed renderer.
109
111
  if (data && typeof data.start === "number") {
110
- props.list = { ordered: !!data.ordered, start: data.start } as ListData;
112
+ props.list = { ordered: !!data.ordered, start: data.start, items: data.items } as ListData;
111
113
  }
112
114
  } else if (block.kind.type === "Component") {
113
115
  props.tag = data?.tag ?? "";
@@ -126,6 +128,14 @@ export function blockProps(block: Block): BlockComponentProps {
126
128
  if (typeof block.kind.data === "object" && block.kind.data !== null) {
127
129
  props.heading = block.kind.data as HeadingData;
128
130
  }
131
+ } else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
132
+ // When `blockData` is on, a Blockquote's `kind.data` is `{ nested }` and an
133
+ // Alert's is `{ kind, nested }`; off, a Blockquote has no `data` and an Alert
134
+ // only `{ kind }`. Surface the keyed `nested` sub-blocks only when present.
135
+ const cd = block.kind.data as { nested?: { html: string }[] } | undefined;
136
+ if (cd && Array.isArray(cd.nested)) {
137
+ props.container = { nested: cd.nested } as ContainerData;
138
+ }
129
139
  }
130
140
  return props;
131
141
  }
package/src/client.ts CHANGED
@@ -316,6 +316,20 @@ export class FluxClient {
316
316
  private lastContent = "";
317
317
  private contentDone = false;
318
318
 
319
+ // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
320
+ // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
321
+ // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
322
+ // renders once per frame instead of once per patch (the DOM renderer already
323
+ // batches to 1/frame independently). Lossless: committed blocks are
324
+ // reference-stable, so a dropped intermediate notify only skips a tail-only
325
+ // render that the next flush supersedes. The finalize/done patch is exempt and
326
+ // flushes synchronously, never deferred to the next frame.
327
+ private coalesce = false;
328
+ private rafHandle: number | null = null;
329
+ // Set by finalize(); the next patch's emit flushes synchronously (a 'done'
330
+ // notification must not be deferred a frame) and clears it.
331
+ private finalizePending = false;
332
+
319
333
  // Perf
320
334
  private appendedBytes = 0;
321
335
  private patchCount = 0;
@@ -324,6 +338,11 @@ export class FluxClient {
324
338
  private firstAppendMs = 0;
325
339
  private retainedBytes = 0;
326
340
  private wasmMemoryBytes = 0;
341
+ // Render-path observability (advanced ONLY when an onRenderMetrics hook is
342
+ // wired into a renderer; zero-cost otherwise). renderCount = React BlockView
343
+ // body renders; rebuildCount = DOM node rebuilds.
344
+ private renderCount = 0;
345
+ private rebuildCount = 0;
327
346
 
328
347
  /**
329
348
  * @param options.pool worker pool to join (defaults to the shared
@@ -337,6 +356,14 @@ export class FluxClient {
337
356
  * order, after the store updates) — for side effects like lazily
338
357
  * highlighting a finished code block or analytics. A committed block never
339
358
  * re-fires; the streaming tail does not (subscribe for live tail updates).
359
+ * @param options.coalesce opt-in (default `false`): collapse multiple
360
+ * intra-frame patch notifications into ONE `requestAnimationFrame`-scheduled
361
+ * flush to subscribers, so a React `useSyncExternalStore` consumer renders at
362
+ * most once per frame instead of once per patch. Lossless — committed blocks
363
+ * are reference-stable, so only superseded tail-only renders are skipped. The
364
+ * stream-completion (finalize) patch always flushes synchronously, and a
365
+ * pending frame is cancelled on `reset()`/`destroy()`. No effect when
366
+ * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
340
367
  */
341
368
  constructor(
342
369
  options: {
@@ -344,12 +371,14 @@ export class FluxClient {
344
371
  config?: ParserConfig;
345
372
  onError?: (err: { message: string; fatal?: boolean }) => void;
346
373
  onBlock?: (block: Block) => void;
374
+ coalesce?: boolean;
347
375
  } = {},
348
376
  ) {
349
377
  this.pool = options.pool ?? getDefaultPool();
350
378
  this.config = options.config;
351
379
  this.onError = options.onError;
352
380
  this.onBlock = options.onBlock;
381
+ this.coalesce = options.coalesce ?? false;
353
382
  }
354
383
 
355
384
  /**
@@ -401,6 +430,9 @@ export class FluxClient {
401
430
 
402
431
  finalize() {
403
432
  const pw = this.ensureAcquired();
433
+ // The terminal patch this triggers must reach subscribers synchronously, not
434
+ // a frame late — mark it so the next emit flushes now even under coalescing.
435
+ this.finalizePending = true;
404
436
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
405
437
  }
406
438
 
@@ -538,10 +570,14 @@ export class FluxClient {
538
570
  this.wasmMemoryBytes = 0;
539
571
  this.lastContent = ""; // setContent baseline: the worker drops the parser here
540
572
  this.contentDone = false;
573
+ // A frame coalesced from the just-cleared stream is now stale — cancel it so
574
+ // it can't fire a notify referencing content reset() just dropped.
575
+ this.cancelFrame();
576
+ this.finalizePending = false;
541
577
  // Same streamId + worker — the worker frees and lazily recreates the parser.
542
578
  const pw = this.ensureAcquired();
543
579
  this.pool.send(pw, { type: "reset", streamId: this.streamId });
544
- if (hadContent) this.emit();
580
+ if (hadContent) this.emit(true); // clear-the-view notify is synchronous
545
581
  }
546
582
 
547
583
  destroy() {
@@ -552,6 +588,9 @@ export class FluxClient {
552
588
  // We deliberately do NOT null this.pw here: StrictMode's destroy()→reattach()
553
589
  // on the SAME instance needs the same pw/streamId to re-register.
554
590
  if (this.pw) this.pool.release(this.streamId, this.pw);
591
+ // Drop any pending coalesced frame so it can't fire into cleared listeners.
592
+ this.cancelFrame();
593
+ this.finalizePending = false;
555
594
  this.listeners.clear();
556
595
  this.attached = false;
557
596
  }
@@ -592,6 +631,25 @@ export class FluxClient {
592
631
 
593
632
  getSnapshot = (): Block[] => this.store.snapshot;
594
633
 
634
+ /**
635
+ * Internal: a renderer with an `onRenderMetrics` hook calls this once per
636
+ * actual React block render so `getMetrics().renderCount` aggregates churn.
637
+ * No-op cost when no hook is wired (it is simply never called). Not part of
638
+ * the public API surface — the underscore marks it renderer-internal.
639
+ */
640
+ __noteRender(): void {
641
+ this.renderCount++;
642
+ }
643
+
644
+ /**
645
+ * Internal: the DOM renderer calls this once per actual node rebuild (the
646
+ * changed-block branch) when an `onRenderMetrics` hook is wired, so
647
+ * `getMetrics().rebuildCount` aggregates churn. Never called without a hook.
648
+ */
649
+ __noteRebuild(): void {
650
+ this.rebuildCount++;
651
+ }
652
+
595
653
  getMetrics() {
596
654
  const elapsed = this.firstAppendMs ? Math.max(1, performance.now() - this.firstAppendMs) : 1;
597
655
  return {
@@ -608,6 +666,11 @@ export class FluxClient {
608
666
  // clients on the same worker report the same number. Use Math.max (not
609
667
  // sum) when aggregating across clients; summing double-counts.
610
668
  wasmMemoryBytes: this.wasmMemoryBytes,
669
+ // Render-path churn (0 unless an onRenderMetrics hook is wired into a
670
+ // renderer): renderCount = React block-body renders, rebuildCount = DOM
671
+ // node rebuilds. Committed blocks memo-skip, so they contribute once.
672
+ renderCount: this.renderCount,
673
+ rebuildCount: this.rebuildCount,
611
674
  };
612
675
  }
613
676
 
@@ -658,7 +721,11 @@ export class FluxClient {
658
721
  this.wasmMemoryBytes = msg.wasmMemoryBytes;
659
722
  this.patchCount += 1;
660
723
  this.lastPatchMs = performance.now();
661
- this.emit();
724
+ // The post-finalize (done) patch must notify synchronously — never defer
725
+ // stream completion to the next frame. One-shot: cleared after use.
726
+ const sync = this.finalizePending;
727
+ this.finalizePending = false;
728
+ this.emit(sync);
662
729
  // After subscribers see the new snapshot, fire the per-block hook for
663
730
  // anything that just committed (document order). A throw here is
664
731
  // isolated by the pool's dispatch boundary and won't skip emit().
@@ -677,7 +744,38 @@ export class FluxClient {
677
744
  }
678
745
  }
679
746
 
680
- private emit() {
747
+ /**
748
+ * Notify subscribers of a new snapshot.
749
+ *
750
+ * With `coalesce` off (default) this is fully synchronous, exactly as before.
751
+ * With it on and `requestAnimationFrame` available, a normal emit only
752
+ * *schedules* a single per-frame flush — repeated intra-frame emits collapse
753
+ * into one notify. `sync` forces an immediate flush (stream completion / reset)
754
+ * and cancels any frame already pending so the snapshot is delivered once.
755
+ */
756
+ private emit(sync = false) {
757
+ if (this.coalesce && !sync && typeof requestAnimationFrame === "function") {
758
+ if (this.rafHandle !== null) return; // a flush is already scheduled this frame
759
+ this.rafHandle = requestAnimationFrame(() => {
760
+ this.rafHandle = null;
761
+ this.flushNow();
762
+ });
763
+ return;
764
+ }
765
+ // Synchronous path: drop any pending frame so its notify can't double-fire
766
+ // after this one, then flush immediately.
767
+ this.cancelFrame();
768
+ this.flushNow();
769
+ }
770
+
771
+ private flushNow() {
681
772
  for (const fn of this.listeners) fn();
682
773
  }
774
+
775
+ private cancelFrame() {
776
+ if (this.rafHandle !== null) {
777
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafHandle);
778
+ this.rafHandle = null;
779
+ }
780
+ }
683
781
  }