brookmd 0.24.0 → 0.25.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 CHANGED
@@ -4,6 +4,144 @@ Notable changes to brookmd (formerly `flux-md`). Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project aims to follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.25.1 — 2026-07-27
8
+
9
+ A correction release. 0.25.0 shipped one user-visible regression and a dev-gate
10
+ that failed open in production; both are fixed here, along with a pre-existing
11
+ memory leak on the opt-in `childMemo` path. Upgrade from 0.25.0.
12
+
13
+ ### Fixed
14
+
15
+ - **WITHDRAWN: the streaming component-tag deferral from 0.25.0.** That change
16
+ gated component rendering on `open_tail`, which a container propagates
17
+ UNCHANGED to every sub-block of every list item. A component tag alone in a
18
+ **non-last list item** — whose one-line body carries no trailing newline of its
19
+ own — therefore never satisfied the gate again, and rendered as the literal
20
+ text `<Thinking>` for the entire rest of the stream instead of mounting the
21
+ consumer's component. Measured 201/201 ticks affected in a 200-item list, and
22
+ the deferral bought exactly one tick across the three shapes it was written
23
+ for. `brookmd-core` is reverted to its pre-0.24.0 behavior here (0.24.1); the
24
+ convergence property 0.25.0 claimed is withdrawn with it. The one-tick issue it
25
+ was chasing is already contained by the per-block error boundary.
26
+ - **Dev-only warnings ran in production.** The gate read `globalThis.process?.env`,
27
+ which is a different member path from the `process.env.NODE_ENV` free
28
+ identifier that esbuild / Vite / Next substitute — so in every bundled browser
29
+ production build it evaluated to "development", printed to real users, and
30
+ prevented dead-code elimination of the message strings. Every gate is now at
31
+ the call site in the literal form bundlers fold. Verified against a real Vite
32
+ build: 11 dev-warning strings survived a production bundle before, 0 now.
33
+ (Note the trade: in a realm with no `process` at all — an unbundled CDN
34
+ consumer — these six development warnings no longer fire. Error reporting via
35
+ `onBlockError` / `console.error` is NOT gated and is unaffected.)
36
+ - **`childMemo` retained every intermediate tree on a single-element block**
37
+ (pre-existing, opt-in path). The cache key embeds the segment text, so a block
38
+ whose HTML is ONE top-level element — which is every core-emitted block kind —
39
+ could never hit: each patch wrote an entry that was never read again, and
40
+ `CHILD_MEMO_CAP` bounds entry count, not bytes. Peak RSS grew 154 MB → 1.65 GB
41
+ over 800 patches of a streaming table. Such blocks now take the ordinary
42
+ whole-block walk.
43
+
44
+ ### Changed
45
+
46
+ - Shipped bundles are smaller than 0.25.0: gzipped, minified, production —
47
+ `index` 14,232 → 13,407 B, `react` 14,185 → 13,365 B, `client` 4,651 → 4,283 B.
48
+ The boundary's `console.error` keeps everything actionable in production (block
49
+ id, kind, override keys, HTML excerpt, the error) and moves only the
50
+ explanatory prose behind the dev gate.
51
+
52
+ ## brookmd-react-native 0.1.5 — 2026-07-27
53
+
54
+ ### Fixed
55
+
56
+ - Vendored native binaries rebuilt against `brookmd-core` 0.24.1, so the on-device
57
+ parser no longer carries the component-tag deferral regression withdrawn in
58
+ brookmd 0.25.1 (a component tag alone in a non-last list item rendered as
59
+ literal text for the rest of the stream). No JS changes.
60
+
61
+ ## brookmd-react-native 0.1.4 — 2026-07-26
62
+
63
+ ### Fixed
64
+
65
+ - **The native transport shim routes worker listeners by event type.** brookmd
66
+ 0.24.0 taught `BrookPool` to listen on `error` / `messageerror` (the browser's
67
+ out-of-band worker-failure channels). `NativeWorker.addEventListener` ignored
68
+ its `type` argument and registered every listener in one set, so the pool's
69
+ fatal handler received the ordinary `ready` / `patch` envelopes and read the
70
+ first as `brookmd worker failed to load`, killing every stream before it
71
+ started. Neither channel can fire for an in-process shim — there is no script
72
+ URL to fetch and no structured-clone step — so they are now accepted and never
73
+ fired. This was latent: the package pinned `brookmd` to `^0.23.0`, which
74
+ predates those listeners, so it only surfaced when the range moved.
75
+ - `brookmd` dependency range corrected `^0.23.0` → `^0.25.0`; vendored native
76
+ binaries rebuilt against `brookmd-core` 0.24.0.
77
+
78
+ ## 0.25.0 — 2026-07-26
79
+
80
+ Fixes a crash class where a `components` override could be invoked with the
81
+ wrong prop shape and take the entire document down with it. If you pass
82
+ `components` and read `props.block` in any of them, upgrade.
83
+
84
+ ### Fixed
85
+
86
+ - **A `components` override no longer receives two incompatible prop shapes
87
+ without warning.** The map is consulted by two dispatchers: the block-kind
88
+ dispatcher (which supplies `BlockComponentProps`, including `block`) and the
89
+ element-name walker that powers `a`/`code`/`table` overrides (which supplies
90
+ attributes + `children` and **no `block`**). The same key reaches both — an
91
+ `inlineComponentTags` chip, or a `componentTags` tag nested inside a list item
92
+ or blockquote — so an override reading `props.block.…` threw
93
+ `can't access property "kind", block is undefined`, intermittently, depending
94
+ on where the model put the tag. Three defenses now apply: block-kind keys are
95
+ typed to `BlockComponentProps` (a mismatched override is a compile error), a
96
+ raw element whose name collides with a block-kind key is never dispatched to
97
+ that override, and every block renders inside its own error boundary.
98
+ - **A throwing override costs one block, not the document.** React unmounts the
99
+ whole tree on an uncaught render error, so a single bad override blanked the
100
+ page. Each block now has its own boundary; the failed block is skipped and
101
+ retried as soon as its HTML changes, so a streaming-tail failure heals itself
102
+ when the block settles.
103
+ - **The parser no longer emits a component tag it will retract.** A component
104
+ open tag was recognized as soon as it looked whole-line, but during streaming
105
+ end-of-buffer is not end-of-line — so `> <Thinking>x</Thinking>` rendered a
106
+ raw `<Thinking>` element for one tick before settling to escaped text. That
107
+ transient raw element is what reached overrides with the wrong props. A
108
+ component tag now opens a block only once its line is known to be complete.
109
+ - **Recovery no longer loses the parser config.** The worker keeps config per
110
+ stream id, so the one-shot recovery re-feed landed on a worker that had never
111
+ seen it while `configSent` stayed latched — the healed parser was silently
112
+ rebuilt with library defaults, dropping `componentTags`, `blockData`,
113
+ `gfmMath` and the whole `kind.data` structured channel for the rest of the
114
+ session.
115
+ - **A terminal worker failure no longer corrupts the document.** The store kept
116
+ the dead generation's blocks while the fresh parser renumbered from zero, so
117
+ the next append merged two generations under colliding ids (duplicate React
118
+ keys, silently overwritten blocks, a shrinking document). The generation now
119
+ restarts cleanly, with a one-time warning. Transient failures still heal
120
+ invisibly through recovery, unchanged.
121
+ - **Per-block error containment is free for committed blocks.** The boundary
122
+ lives inside the per-block memo, so a settled document re-renders no
123
+ boundaries when the streaming tail patches — a new React-side complexity gate
124
+ (`test/boundary-linearity.test.tsx`) pins this, counting work rather than
125
+ timing it, mirroring the Rust `scaling` gate.
126
+ - `applyPatch` and the stale-view merge now drop a malformed entry rather than
127
+ publishing a hole into the snapshot, and both renderers skip a block with no
128
+ `kind` instead of dereferencing it.
129
+
130
+ ### Added
131
+
132
+ - **`onBlockError`** on `<BrookMarkdown>` — fires when a block's render throws,
133
+ with `{ blockId, kind, componentKeys, html }`. Without it the same detail goes
134
+ to `console.error`.
135
+
136
+ ### Changed
137
+
138
+ - `Components` is now a mapped type: block-kind keys (`CodeBlock`, `Table`,
139
+ `Alert`, …) are typed to `BlockComponentProps`; every other key stays
140
+ permissive. Type-only, but it will surface existing mismatches at compile time.
141
+ - Requires `brookmd-core` 0.24.0 (the streaming component-tag fix above).
142
+ - `onBlockError` is identity-sensitive like `components` / `onRenderMetrics` —
143
+ hoist or memoize it, or every block re-renders on every patch.
144
+
7
145
  ## 0.24.0 — 2026-07-24
8
146
 
9
147
  ### Added
package/README.md CHANGED
@@ -766,6 +766,33 @@ block. The component receives [`BlockComponentProps`](#types): `{ block, html,
766
766
  open, speculative }`, plus `text`/`language` for code/math blocks (the alert
767
767
  type is at `block.kind.data.kind`).
768
768
 
769
+ > **One map, two prop contracts — the single biggest footgun.** The keys above
770
+ > are looked up by TWO dispatchers. The block-kind dispatcher passes
771
+ > `BlockComponentProps` (with `block`); the element dispatcher, which is what
772
+ > makes `a` / `code` / `table` overrides work, passes **the element's attributes
773
+ > and `children` only — no `block`**. The same name can hit both: an
774
+ > `inlineComponentTags` chip, or a `componentTags` tag that lands inside a list
775
+ > item or blockquote (where it is a real *nested* Component block, rendered as an
776
+ > element inside its container's HTML), takes the element path. So an override
777
+ > that reads `props.block.…` throws `can't access property "kind", block is
778
+ > undefined` for those occurrences — intermittently, because it depends on where
779
+ > the model happened to put the tag.
780
+ >
781
+ > Write any override for a name that can appear in both positions defensively:
782
+ >
783
+ > ```tsx
784
+ > const Thinking = ({ block, children }) =>
785
+ > block ? <Panel data={block.kind.data}>{children}</Panel> : <span>{children}</span>;
786
+ > ```
787
+ >
788
+ > Three things make this survivable rather than fatal: block-kind keys are typed
789
+ > to `BlockComponentProps`, so a mismatched override is a **compile** error; a raw
790
+ > element whose name collides with a block-kind key (`<Table>`, `<Alert>`… — only
791
+ > reachable with raw-HTML passthrough on) is never dispatched to that override;
792
+ > and every block renders inside its own **error boundary**, so a throwing
793
+ > override costs that one block instead of unmounting the document. Wire
794
+ > [`onBlockError`](#onblockerror) to see them.
795
+
769
796
  Rules worth knowing:
770
797
 
771
798
  - **There is no `node` prop / no hast tree.** Introspect via `className` /
@@ -928,9 +955,23 @@ tags only). It works everywhere inline content does — **including table cells*
928
955
  Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
929
956
  (`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
930
957
  two lists are independent: list a tag under `componentTags` for blocks,
931
- `inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
932
- unsupported position degrades **inertly** (escaped) — it never consumes
933
- surrounding content.
958
+ `inlineComponentTags` for inline, or both for both.
959
+
960
+ Where an allowlisted tag actually lands:
961
+
962
+ | Position | Result |
963
+ | --- | --- |
964
+ | Own line, top level | block `Component` — override gets `BlockComponentProps` |
965
+ | Own line inside a list item / blockquote | real **nested** Component block, emitted as an element inside the container's HTML — the override is dispatched by **element name**, so it gets attributes + `children` and **no `block`** |
966
+ | Mid-paragraph, listed in `inlineComponentTags` | inline element — attributes + `children`, no `block` |
967
+ | Mid-paragraph, NOT listed in `inlineComponentTags` | escaped text |
968
+ | Inside a table cell | escaped text (cells are inline-only) |
969
+
970
+ An allowlisted tag in a position that is not supported degrades **inertly**
971
+ (escaped) — it never consumes surrounding content. But note rows 2 and 3: those
972
+ DO render, through the element path, which is why an override that reads
973
+ `props.block` must guard for its absence (see [the two prop
974
+ contracts](#custom-components--overrides)).
934
975
 
935
976
  > **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
936
977
  > inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
@@ -1269,6 +1310,33 @@ re-snapping during streaming, so treat smooth following there as best-effort.
1269
1310
  > is the *shared* worker's heap — clients on the same worker report the same
1270
1311
  > value. Aggregate with `Math.max`, not a sum.
1271
1312
 
1313
+ <a id="onblockerror"></a>
1314
+
1315
+ ### When a block fails to render — `onBlockError`
1316
+
1317
+ Every block renders inside its own error boundary. React's default response to
1318
+ an uncaught render error is to unmount the **entire** tree, so before this a
1319
+ single throwing `components` override blanked the whole document. Now the
1320
+ failure costs that one block: the rest of the stream keeps rendering, and the
1321
+ block is retried as soon as its HTML changes (so a streaming-tail failure heals
1322
+ itself when the block settles).
1323
+
1324
+ ```tsx
1325
+ <BrookMarkdown
1326
+ client={client}
1327
+ components={components}
1328
+ onBlockError={(err, { blockId, kind, componentKeys, html }) => {
1329
+ reportToSentry(err, { blockId, kind, componentKeys, html });
1330
+ }}
1331
+ />
1332
+ ```
1333
+
1334
+ `componentKeys` is the override map's keys — the shortlist of suspects — and
1335
+ `html` is the first 200 chars of the block that failed. Without the hook the same
1336
+ detail goes to `console.error`. The overwhelmingly common cause is an override
1337
+ reading `props.block.…` on the element path, where there is no `block`; see [the
1338
+ two prop contracts](#custom-components--overrides).
1339
+
1272
1340
  ## Architecture
1273
1341
 
1274
1342
  ```
package/dist/client.d.ts CHANGED
@@ -156,6 +156,9 @@ export declare class BrookClient {
156
156
  private streamId;
157
157
  private config?;
158
158
  private configSent;
159
+ /** Set when ensureAcquired rebound this stream onto a fresh worker+parser
160
+ * after a fatal failure; consumed by the next content-bearing op. */
161
+ private pendingRebind;
159
162
  private listeners;
160
163
  private store;
161
164
  private onError?;
@@ -176,6 +179,9 @@ export declare class BrookClient {
176
179
  private staleTrimmed;
177
180
  private idNamespace;
178
181
  private mergeCache;
182
+ /** Set by mergeStale when it had to compact a hole out of the view, so
183
+ * getSnapshot skips caching a view whose indices no longer track `base`. */
184
+ private mergeDropped;
179
185
  private appendedBytes;
180
186
  private patchCount;
181
187
  private totalParseMicros;
@@ -245,6 +251,15 @@ export declare class BrookClient {
245
251
  * multiplexing (pick() is unchanged and remains the only path to create()).
246
252
  */
247
253
  private ensureAcquired;
254
+ /**
255
+ * A worker rebind left the store holding a dead generation's blocks while the
256
+ * fresh parser restarts ids at 0. Called by the ops that actually feed the
257
+ * parser, before they do. Recovery's re-feed handles this itself (it re-parses
258
+ * the whole document over a preserved view) and clears the flag; this is the
259
+ * path where recovery is off or already spent, where the honest outcome is a
260
+ * clean restart rather than two generations interleaved under colliding ids.
261
+ */
262
+ private settleRebind;
248
263
  get ready(): boolean;
249
264
  /**
250
265
  * The fatal error that killed this client's worker, or `null` if healthy.
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { warnOnce } from "./warn.js";
1
2
  import { createWorker } from "./asset-urls.js";
2
3
  function emptyBlockStore() {
3
4
  return { committed: /* @__PURE__ */ new Map(), committedOrder: [], active: [], snapshot: [] };
@@ -24,12 +25,31 @@ function applyPatch(store, patch) {
24
25
  }
25
26
  store.active = active;
26
27
  const next = new Array(store.committedOrder.length + store.active.length);
28
+ let w = 0;
27
29
  for (let i = 0; i < store.committedOrder.length; i++) {
28
- next[i] = store.committed.get(store.committedOrder[i]);
30
+ const b = store.committed.get(store.committedOrder[i]);
31
+ if (b === void 0) {
32
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
33
+ warnOnce(
34
+ "orphan-committed",
35
+ `brookmd: committed block ${store.committedOrder[i]} is missing from the store and was dropped. This is a bug in brookmd \u2014 please report it.`
36
+ );
37
+ }
38
+ continue;
39
+ }
40
+ next[w++] = b;
29
41
  }
30
42
  for (let i = 0; i < store.active.length; i++) {
31
- next[store.committedOrder.length + i] = store.active[i];
43
+ const b = store.active[i];
44
+ if (b === void 0) {
45
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
46
+ warnOnce("orphan-active", `brookmd: active block at index ${i} is undefined and was dropped.`);
47
+ }
48
+ continue;
49
+ }
50
+ next[w++] = b;
32
51
  }
52
+ if (w !== next.length) next.length = w;
33
53
  store.snapshot = next;
34
54
  }
35
55
  class BrookPool {
@@ -273,6 +293,9 @@ class BrookClient {
273
293
  streamId = 0;
274
294
  config;
275
295
  configSent = false;
296
+ /** Set when ensureAcquired rebound this stream onto a fresh worker+parser
297
+ * after a fatal failure; consumed by the next content-bearing op. */
298
+ pendingRebind = false;
276
299
  listeners = /* @__PURE__ */ new Set();
277
300
  store = emptyBlockStore();
278
301
  onError;
@@ -359,6 +382,9 @@ class BrookClient {
359
382
  // reads between notifies must return the SAME reference — the
360
383
  // useSyncExternalStore cached-snapshot contract.
361
384
  mergeCache = null;
385
+ /** Set by mergeStale when it had to compact a hole out of the view, so
386
+ * getSnapshot skips caching a view whose indices no longer track `base`. */
387
+ mergeDropped = false;
362
388
  // Perf
363
389
  appendedBytes = 0;
364
390
  patchCount = 0;
@@ -430,12 +456,38 @@ class BrookClient {
430
456
  */
431
457
  ensureAcquired() {
432
458
  if (this.pw && !this.pw.failed) return this.pw;
459
+ const rebinding = this.pw !== null;
433
460
  this.pw = null;
434
461
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
435
462
  this.streamId = streamId;
436
463
  this.pw = pw;
464
+ if (rebinding) {
465
+ this.configSent = false;
466
+ this.pendingRebind = true;
467
+ }
437
468
  return pw;
438
469
  }
470
+ /**
471
+ * A worker rebind left the store holding a dead generation's blocks while the
472
+ * fresh parser restarts ids at 0. Called by the ops that actually feed the
473
+ * parser, before they do. Recovery's re-feed handles this itself (it re-parses
474
+ * the whole document over a preserved view) and clears the flag; this is the
475
+ * path where recovery is off or already spent, where the honest outcome is a
476
+ * clean restart rather than two generations interleaved under colliding ids.
477
+ */
478
+ settleRebind() {
479
+ if (!this.pendingRebind) return;
480
+ if (this.failedError === null) return;
481
+ this.pendingRebind = false;
482
+ if (this.store.snapshot.length === 0 && !this.staleSnapshot) return;
483
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
484
+ warnOnce(
485
+ "rebind-reset",
486
+ "brookmd: the parser was rebuilt on a new worker after a fatal failure, so the document restarted. Blocks rendered before the failure were dropped because the fresh parser renumbers from zero. Enable `recovery` (the default) to re-feed and heal invisibly instead."
487
+ );
488
+ }
489
+ this.resetParser();
490
+ }
439
491
  get ready() {
440
492
  return this.pw?.ready ?? false;
441
493
  }
@@ -466,6 +518,7 @@ class BrookClient {
466
518
  }
467
519
  append(chunk) {
468
520
  const pw = this.ensureAcquired();
521
+ this.settleRebind();
469
522
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
470
523
  if (this.recovery) {
471
524
  this.recoveryBuffer += chunk;
@@ -477,6 +530,7 @@ class BrookClient {
477
530
  }
478
531
  finalize() {
479
532
  const pw = this.ensureAcquired();
533
+ this.settleRebind();
480
534
  this.finalizePending = true;
481
535
  this.contentDone = true;
482
536
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
@@ -599,6 +653,7 @@ class BrookClient {
599
653
  this.mergeCache = null;
600
654
  this.failedError = null;
601
655
  this.recoveryAttempted = false;
656
+ this.pendingRebind = false;
602
657
  this.resetParser();
603
658
  if (hadContent) this.emit(true);
604
659
  }
@@ -700,7 +755,12 @@ class BrookClient {
700
755
  const cache = this.mergeCache;
701
756
  if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
702
757
  const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
703
- this.mergeCache = { base, trimmed: this.staleTrimmed, view };
758
+ if (this.mergeDropped) {
759
+ this.mergeDropped = false;
760
+ this.mergeCache = null;
761
+ } else {
762
+ this.mergeCache = { base, trimmed: this.staleTrimmed, view };
763
+ }
704
764
  return view;
705
765
  };
706
766
  /**
@@ -731,6 +791,7 @@ class BrookClient {
731
791
  const stale = this.staleSnapshot;
732
792
  const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
733
793
  const view = new Array(len);
794
+ let dropped = false;
734
795
  for (let i = 0; i < len; i++) {
735
796
  const nb = i < base.length ? base[i] : void 0;
736
797
  if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
@@ -739,6 +800,10 @@ class BrookClient {
739
800
  }
740
801
  const ob = i < stale.length ? stale[i] : void 0;
741
802
  if (!nb) {
803
+ if (ob === void 0) {
804
+ dropped = true;
805
+ continue;
806
+ }
742
807
  view[i] = ob;
743
808
  continue;
744
809
  }
@@ -756,6 +821,13 @@ class BrookClient {
756
821
  }
757
822
  view[i] = { ...nb, id: this.idNamespace + nb.id };
758
823
  }
824
+ if (dropped) {
825
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
826
+ warnOnce("merge-hole", "brookmd: the stale-merge produced an empty position \u2014 compacting.");
827
+ }
828
+ this.mergeDropped = true;
829
+ return view.filter((b) => b !== void 0);
830
+ }
759
831
  return view;
760
832
  }
761
833
  /**
@@ -919,6 +991,7 @@ class BrookClient {
919
991
  const displayed = this.getSnapshot();
920
992
  if (displayed.length > 0) this.softReset(displayed);
921
993
  else this.resetParser();
994
+ this.pendingRebind = false;
922
995
  this.append(doc);
923
996
  if (done) this.finalize();
924
997
  }
package/dist/dom.js CHANGED
@@ -55,9 +55,11 @@ function mountBrookMarkdown(client, container, options = {}) {
55
55
  const snapshot = client.getSnapshot();
56
56
  const nextOrder = new Array(snapshot.length);
57
57
  const seen = /* @__PURE__ */ new Set();
58
+ let w = 0;
58
59
  for (let i = 0; i < snapshot.length; i++) {
59
60
  const b = snapshot[i];
60
- nextOrder[i] = b.id;
61
+ if (b == null || b.kind == null) continue;
62
+ nextOrder[w++] = b.id;
61
63
  seen.add(b.id);
62
64
  const existing = mounted.get(b.id);
63
65
  if (!existing) {
@@ -131,6 +133,7 @@ function mountBrookMarkdown(client, container, options = {}) {
131
133
  }
132
134
  }
133
135
  }
136
+ if (w !== nextOrder.length) nextOrder.length = w;
134
137
  order = nextOrder;
135
138
  reconcileChildren();
136
139
  }
@@ -1,6 +1,21 @@
1
1
  import { createElement, Fragment } from "react";
2
2
  import { decorateSegments } from "./decorate.js";
3
3
  import { decodeEntities, safeUrl } from "./url-safety.js";
4
+ import { warnOnce } from "./warn.js";
5
+ const BLOCK_KIND_KEYS = /* @__PURE__ */ new Set([
6
+ "Paragraph",
7
+ "Heading",
8
+ "CodeBlock",
9
+ "MathBlock",
10
+ "Mermaid",
11
+ "List",
12
+ "Blockquote",
13
+ "Alert",
14
+ "Table",
15
+ "Rule",
16
+ "Html",
17
+ "Component"
18
+ ]);
4
19
  const VOID = /* @__PURE__ */ new Set([
5
20
  "area",
6
21
  "base",
@@ -220,6 +235,19 @@ function pushDecoratedText(out, text, decorators, ancestors, keyBase) {
220
235
  out.push(createElement(Fragment, { key: keyBase + ":" + i }, replacement));
221
236
  }
222
237
  }
238
+ function resolveTagType(tag, components) {
239
+ const c = tag.charCodeAt(0);
240
+ if (c >= 65 && c <= 90 && BLOCK_KIND_KEYS.has(tag)) {
241
+ if (components[tag] !== void 0 && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
242
+ warnOnce(
243
+ "kind-key-as-tag:" + tag,
244
+ `brookmd: raw <${tag}> in the rendered HTML collides with the block-kind override key "${tag}". A block-kind override receives \`BlockComponentProps\` (with \`block\`), which an inline element cannot supply, so the raw tag is rendered as a plain element instead. Rename the raw tag or the override key.`
245
+ );
246
+ }
247
+ return tag;
248
+ }
249
+ return components[tag] ?? tag;
250
+ }
223
251
  function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
224
252
  const out = [];
225
253
  for (let idx = 0; idx < nodes.length; idx++) {
@@ -233,7 +261,7 @@ function nodesToReact(nodes, components, keyPrefix, ctx, ancestors) {
233
261
  continue;
234
262
  }
235
263
  const key = keyPrefix + idx;
236
- const type = components[n.tag] ?? n.tag;
264
+ const type = resolveTagType(n.tag, components);
237
265
  const props = attrsToProps(n.tag, n.attrs, key, ctx?.urlTransform);
238
266
  if (VOID.has(n.tag.toLowerCase())) {
239
267
  out.push(createElement(type, props));
@@ -307,6 +335,7 @@ function htmlToReact(html, components, childMemoMap, opts) {
307
335
  const ctx = opts && (opts.decorators || opts.urlTransform) ? { decorators: opts.decorators, urlTransform: opts.urlTransform } : void 0;
308
336
  if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "", ctx, []);
309
337
  const segs = topLevelSegments(html);
338
+ if (segs.length < 2) return nodesToReact(parseTrustedHtml(html), components, "", ctx, []);
310
339
  const out = [];
311
340
  for (let idx = 0; idx < segs.length; idx++) {
312
341
  const seg = segs[idx];
package/dist/react.d.ts CHANGED
@@ -165,6 +165,41 @@ interface BrookMarkdownProps {
165
165
  * concurrent deferral of the visible tail.
166
166
  */
167
167
  deferTail?: boolean;
168
+ /**
169
+ * Called when a single block's render THROWS — almost always from inside a
170
+ * `components` override, not from brookmd itself.
171
+ *
172
+ * Every block is wrapped in its own error boundary, so a throwing override
173
+ * costs you that one block instead of unmounting the whole document (React's
174
+ * default for an uncaught render error is to unmount the entire tree — a blank
175
+ * page). The failed block renders nothing; the rest of the stream keeps going,
176
+ * and later patches retry it.
177
+ *
178
+ * Without this hook the failure still goes to `console.error` with the block
179
+ * id, kind, override keys, and an HTML excerpt. Wire it to your error reporter
180
+ * to get the same detail in production.
181
+ *
182
+ * **HOIST / memoize this** (same trap as `components` / `onRenderMetrics`): a
183
+ * fresh closure each render busts every block's memo and re-renders the whole
184
+ * document on every patch.
185
+ *
186
+ * The most common cause by far: an override registered for a *block-kind* or
187
+ * *component-tag* key reading `props.block.…`, invoked on the tag path (the
188
+ * same tag nested inside another block, or used inline), where `block` is not
189
+ * supplied. Guard with `if (!block) return <>{children}</>`.
190
+ */
191
+ onBlockError?: (error: Error, info: BlockErrorInfo) => void;
192
+ }
193
+ /** Context handed to {@link BrookMarkdownProps.onBlockError}. */
194
+ export interface BlockErrorInfo {
195
+ /** The block's stable parser-assigned id (also its React key). */
196
+ blockId: number;
197
+ /** The block kind that failed, e.g. `"Component"` / `"Table"`. */
198
+ kind: string;
199
+ /** The override keys in play — the shortlist of suspects. */
200
+ componentKeys: string[];
201
+ /** First 200 chars of the block's rendered HTML, to identify the content. */
202
+ html: string;
168
203
  }
169
204
  export declare function __resetUnstableWarnings(): void;
170
205
  /**
@@ -223,16 +258,7 @@ export declare function useBrookMarkdownString(content: string, options?: {
223
258
  declare function BrookMarkdownImpl(props: BrookMarkdownProps): import("react/jsx-runtime").JSX.Element;
224
259
  export declare const BrookMarkdown: import("react").MemoExoticComponent<typeof BrookMarkdownImpl>;
225
260
  export declare function blockKindProps(block: Block, components?: Components): BlockComponentProps;
226
- export declare function blocksEqual(prev: {
227
- block: Block;
228
- components?: Components;
229
- virtualize?: boolean;
230
- sanitize?: (html: string) => string;
231
- childMemo?: boolean;
232
- onRenderMetrics?: RenderMetricsHook;
233
- decorators?: Decorator[];
234
- urlTransform?: UrlTransform;
235
- }, next: {
261
+ interface BlockViewProps {
236
262
  block: Block;
237
263
  components?: Components;
238
264
  virtualize?: boolean;
@@ -241,5 +267,12 @@ export declare function blocksEqual(prev: {
241
267
  onRenderMetrics?: RenderMetricsHook;
242
268
  decorators?: Decorator[];
243
269
  urlTransform?: UrlTransform;
244
- }): boolean;
270
+ /** Diagnostic passthrough for the boundary; derived from `components`, so its
271
+ * identity is stable whenever `components` is. */
272
+ componentKeys?: string[];
273
+ onBlockError?: (error: Error, info: BlockErrorInfo) => void;
274
+ }
275
+ export declare function blocksEqual(prev: BlockViewProps, next: BlockViewProps): boolean;
276
+ export declare function __getBoundaryRenders(): number;
277
+ export declare function __resetBoundaryRenders(): void;
245
278
  export {};
package/dist/react.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import {
3
+ Component,
3
4
  createElement,
4
5
  memo,
5
6
  useEffect,
@@ -14,7 +15,18 @@ import { CodeBlock } from "./renderers/CodeBlock.js";
14
15
  import { MathBlock } from "./renderers/Math.js";
15
16
  import { Mermaid } from "./renderers/Mermaid.js";
16
17
  import { htmlToReact } from "./html-to-react.js";
18
+ import { warnOnce } from "./warn.js";
17
19
  const NO_DEFER_BLOCKS = [];
20
+ const EMPTY_KEYS = [];
21
+ function skipBadBlock(index) {
22
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
23
+ warnOnce(
24
+ "bad-block",
25
+ `brookmd: snapshot position ${index} has no block kind and was skipped. This indicates a corrupted block store \u2014 please report it.`
26
+ );
27
+ }
28
+ return null;
29
+ }
18
30
  const warnedUnstable = /* @__PURE__ */ new Set();
19
31
  function useUnstablePropWarning(name, value) {
20
32
  const ref = useRef(value);
@@ -22,8 +34,7 @@ function useUnstablePropWarning(name, value) {
22
34
  const prevDefined = ref.current !== void 0 && ref.current !== null;
23
35
  const nextDefined = value !== void 0 && value !== null;
24
36
  ref.current = value;
25
- const env = globalThis.process?.env;
26
- if (prevDefined && nextDefined && !warnedUnstable.has(name) && (!env || env.NODE_ENV !== "production")) {
37
+ if (prevDefined && nextDefined && !warnedUnstable.has(name) && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
27
38
  warnedUnstable.add(name);
28
39
  console.warn(
29
40
  `<BrookMarkdown>: the \`${name}\` prop changed identity between renders. Hoist it to module scope or wrap it in useMemo \u2014 a fresh identity each render busts the per-block memo and re-parses every block on every patch.`
@@ -49,7 +60,8 @@ function BrookMarkdownFromClient({
49
60
  onRenderMetrics,
50
61
  deferTail,
51
62
  decorators,
52
- urlTransform
63
+ urlTransform,
64
+ onBlockError
53
65
  }) {
54
66
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
55
67
  useUnstablePropWarning("decorators", decorators);
@@ -61,6 +73,7 @@ function BrookMarkdownFromClient({
61
73
  () => components && Object.keys(components).length > 0 ? components : void 0,
62
74
  [components]
63
75
  );
76
+ const componentKeys = useMemo(() => comps ? Object.keys(comps) : EMPTY_KEYS, [comps]);
64
77
  const onMetrics = useMemo(
65
78
  () => onRenderMetrics ? (id2, m) => {
66
79
  client.__noteRender();
@@ -78,20 +91,29 @@ function BrookMarkdownFromClient({
78
91
  "aria-live": ariaLive,
79
92
  "aria-atomic": ariaAtomic,
80
93
  children: [
81
- rendered.map((b) => /* @__PURE__ */ jsx(
82
- BlockView,
83
- {
84
- block: b,
85
- components: comps,
86
- virtualize,
87
- sanitize,
88
- childMemo,
89
- onRenderMetrics: onMetrics,
90
- decorators,
91
- urlTransform
92
- },
93
- b.id
94
- )),
94
+ rendered.map(
95
+ (b, i) => (
96
+ // The guard runs BEFORE `key={b.id}` is evaluated: a malformed entry
97
+ // must not throw here (it would take the whole document down), and the
98
+ // store's density invariant is not something a renderer should bet on.
99
+ b == null || b.kind == null ? skipBadBlock(i) : /* @__PURE__ */ jsx(
100
+ BlockView,
101
+ {
102
+ block: b,
103
+ components: comps,
104
+ virtualize,
105
+ sanitize,
106
+ childMemo,
107
+ onRenderMetrics: onMetrics,
108
+ decorators,
109
+ urlTransform,
110
+ componentKeys,
111
+ onBlockError
112
+ },
113
+ b.id
114
+ )
115
+ )
116
+ ),
95
117
  stickToBottom && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { scrollSnapAlign: "end" }, className: "brook-bottom-anchor" })
96
118
  ]
97
119
  }
@@ -466,7 +488,8 @@ function renderBlockContent({
466
488
  decorators,
467
489
  urlTransform
468
490
  }) {
469
- const kind = block.kind.type;
491
+ const kind = block?.kind?.type;
492
+ if (kind === void 0) return null;
470
493
  const hasInlineTransforms = !!decorators || !!urlTransform;
471
494
  if (components) {
472
495
  if (kind === "Component") {
@@ -545,14 +568,80 @@ function renderBlockContent({
545
568
  );
546
569
  }
547
570
  function blocksEqual(prev, next) {
571
+ if (prev.block == null || next.block == null) return prev.block === next.block;
548
572
  return prev.block.id === next.block.id && prev.block.html === next.block.html && prev.block.open === next.block.open && prev.block.speculative === next.block.speculative && prev.components === next.components && prev.virtualize === next.virtualize && prev.sanitize === next.sanitize && prev.childMemo === next.childMemo && prev.onRenderMetrics === next.onRenderMetrics && // Identity compare: an unstable decorators/urlTransform (fresh each render)
549
573
  // busts the memo so every committed block re-decorates — the O(n²) footgun
550
574
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
551
- prev.decorators === next.decorators && prev.urlTransform === next.urlTransform;
575
+ prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
576
+ // is a fresh closure per render and would re-render every block on every
577
+ // patch. Hoist or memoize it (documented alongside the other hooks).
578
+ prev.onBlockError === next.onBlockError && prev.componentKeys === next.componentKeys;
579
+ }
580
+ let boundaryRenders = 0;
581
+ function __getBoundaryRenders() {
582
+ return boundaryRenders;
583
+ }
584
+ function __resetBoundaryRenders() {
585
+ boundaryRenders = 0;
586
+ }
587
+ function BlockViewOuter(props) {
588
+ const { block, componentKeys, onBlockError } = props;
589
+ return /* @__PURE__ */ jsx(
590
+ BlockBoundary,
591
+ {
592
+ blockId: block.id,
593
+ kind: block.kind.type,
594
+ html: block.html,
595
+ componentKeys: componentKeys ?? EMPTY_KEYS,
596
+ onBlockError,
597
+ children: /* @__PURE__ */ jsx(BlockViewImpl, { ...props })
598
+ }
599
+ );
600
+ }
601
+ const BlockView = memo(BlockViewOuter, blocksEqual);
602
+ class BlockBoundary extends Component {
603
+ state = { caught: false, failedHtml: null };
604
+ static getDerivedStateFromError() {
605
+ return { caught: true };
606
+ }
607
+ /** Retry once the block's HTML moves on. A streaming-tail failure — a
608
+ * speculatively-closed tag, a prop that is only transiently absent — then
609
+ * heals itself when the block settles, instead of leaving a hole for the rest
610
+ * of the session. */
611
+ static getDerivedStateFromProps(props, state) {
612
+ if (state.caught && state.failedHtml !== null && state.failedHtml !== props.html) {
613
+ return { caught: false, failedHtml: null };
614
+ }
615
+ return null;
616
+ }
617
+ componentDidCatch(error) {
618
+ const info = {
619
+ blockId: this.props.blockId,
620
+ kind: this.props.kind,
621
+ componentKeys: this.props.componentKeys,
622
+ html: this.props.html.slice(0, 200)
623
+ };
624
+ this.setState({ failedHtml: this.props.html });
625
+ if (this.props.onBlockError) {
626
+ this.props.onBlockError(error, info);
627
+ return;
628
+ }
629
+ const hint = typeof process !== "undefined" && process.env.NODE_ENV !== "production" ? ` This is almost always a \`components\` override throwing. If it is registered for a block-kind or component-tag key, note that the SAME key is also dispatched for a matching element nested inside a block's HTML \u2014 that call gets attributes + children only, with no \`block\` prop. Guard with \`if (!block) return <>{children}</>\`.` : "";
630
+ console.error(
631
+ `brookmd: block ${info.blockId} (${info.kind}) failed to render and was skipped.${hint}`,
632
+ { componentKeys: info.componentKeys, html: info.html },
633
+ error
634
+ );
635
+ }
636
+ render() {
637
+ boundaryRenders++;
638
+ return this.state.caught ? null : this.props.children;
639
+ }
552
640
  }
553
- const BlockView = memo(BlockViewImpl, blocksEqual);
554
641
  export {
555
642
  BrookMarkdown,
643
+ __getBoundaryRenders,
644
+ __resetBoundaryRenders,
556
645
  __resetUnstableWarnings,
557
646
  blockKindProps,
558
647
  blocksEqual,
@@ -3,7 +3,8 @@ import { htmlToReact } from "./html-to-react.js";
3
3
  import { blockKindProps } from "./react.js";
4
4
  import { parseToBlocks } from "./server.js";
5
5
  function renderStaticBlock(block, components) {
6
- const kind = block.kind.type;
6
+ const kind = block?.kind?.type;
7
+ if (kind === void 0) return null;
7
8
  if (components) {
8
9
  if (kind === "Component") {
9
10
  const tag = block.kind.data?.tag;
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType } from "react";
2
+ import type { BlockComponentProps, BlockKindTag } from "./types-core.js";
2
3
  /**
3
4
  * Override map for {@link BrookMarkdown}. Keys are either lowercase HTML tag
4
5
  * names (`table`, `a`, `code`, `h1`… — react-markdown style, applied inside a
@@ -6,8 +7,35 @@ import type { ComponentType } from "react";
6
7
  * `CodeBlock`, `Table` — replace the whole block renderer). Values are a React
7
8
  * component or an HTML tag string.
8
9
  *
9
- * Tag-level components receive the element's parsed attributes (with
10
- * `class`→`className`, `style` as an object) plus `children`. Block-kind
11
- * components receive `BlockComponentProps`. There is no `node` prop.
10
+ * ## Two prop contracts read this before writing an override
11
+ *
12
+ * The same map is consulted by two dispatchers, and they pass different props:
13
+ *
14
+ * - **Block contract.** A block-kind key, or a `componentTags` tag matched at
15
+ * block level, receives {@link BlockComponentProps} — `block`, `html`, `open`,
16
+ * `speculative` (plus `tag`/`attrs`/`children` for component tags).
17
+ * - **Tag contract.** The SAME key is also matched by *element name* while
18
+ * converting a block's HTML to React — which is how `a`/`code`/`table`
19
+ * overrides work, and also how an `inlineComponentTags` chip, or a component
20
+ * tag nested inside a list item / blockquote, is rendered. That call passes
21
+ * the element's attributes and `children` only: **there is no `block` prop.**
22
+ *
23
+ * So a component registered for a tag that can appear in both positions must not
24
+ * assume `block` exists:
25
+ *
26
+ * ```tsx
27
+ * const Thinking = ({ block, children }: any) =>
28
+ * block ? <Panel data={block.kind.data}>{children}</Panel> : <span>{children}</span>;
29
+ * ```
30
+ *
31
+ * Block-kind keys are typed to {@link BlockComponentProps} below so the mismatch
32
+ * is a compile error rather than a runtime `undefined` deref; brookmd also
33
+ * refuses to dispatch a raw element whose name collides with a block-kind key,
34
+ * and wraps every block in an error boundary so a throwing override costs one
35
+ * block instead of the document.
12
36
  */
13
- export type Components = Record<string, ComponentType<any> | string>;
37
+ export type Components = {
38
+ [K in BlockKindTag]?: ComponentType<BlockComponentProps> | string;
39
+ } & {
40
+ [tag: string]: ComponentType<any> | string | undefined;
41
+ };
package/dist/warn.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Warn once per `id`. Callers own the production gate — see the contract above. */
2
+ export declare function warnOnce(id: string, message: string): boolean;
3
+ /** Test-only: clear the latch so a test can assert a warning fires. */
4
+ export declare function __resetWarnOnce(): void;
package/dist/warn.js ADDED
@@ -0,0 +1,14 @@
1
+ const warned = /* @__PURE__ */ new Set();
2
+ function warnOnce(id, message) {
3
+ if (warned.has(id)) return false;
4
+ warned.add(id);
5
+ console.warn(message);
6
+ return true;
7
+ }
8
+ function __resetWarnOnce() {
9
+ warned.clear();
10
+ }
11
+ export {
12
+ __resetWarnOnce,
13
+ warnOnce
14
+ };
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brookmd",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": ["./dist/worker.js", "./dist/styles.css"],