brookmd 0.24.0 → 0.25.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/CHANGELOG.md CHANGED
@@ -4,6 +4,73 @@ 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.0 — 2026-07-26
8
+
9
+ Fixes a crash class where a `components` override could be invoked with the
10
+ wrong prop shape and take the entire document down with it. If you pass
11
+ `components` and read `props.block` in any of them, upgrade.
12
+
13
+ ### Fixed
14
+
15
+ - **A `components` override no longer receives two incompatible prop shapes
16
+ without warning.** The map is consulted by two dispatchers: the block-kind
17
+ dispatcher (which supplies `BlockComponentProps`, including `block`) and the
18
+ element-name walker that powers `a`/`code`/`table` overrides (which supplies
19
+ attributes + `children` and **no `block`**). The same key reaches both — an
20
+ `inlineComponentTags` chip, or a `componentTags` tag nested inside a list item
21
+ or blockquote — so an override reading `props.block.…` threw
22
+ `can't access property "kind", block is undefined`, intermittently, depending
23
+ on where the model put the tag. Three defenses now apply: block-kind keys are
24
+ typed to `BlockComponentProps` (a mismatched override is a compile error), a
25
+ raw element whose name collides with a block-kind key is never dispatched to
26
+ that override, and every block renders inside its own error boundary.
27
+ - **A throwing override costs one block, not the document.** React unmounts the
28
+ whole tree on an uncaught render error, so a single bad override blanked the
29
+ page. Each block now has its own boundary; the failed block is skipped and
30
+ retried as soon as its HTML changes, so a streaming-tail failure heals itself
31
+ when the block settles.
32
+ - **The parser no longer emits a component tag it will retract.** A component
33
+ open tag was recognized as soon as it looked whole-line, but during streaming
34
+ end-of-buffer is not end-of-line — so `> <Thinking>x</Thinking>` rendered a
35
+ raw `<Thinking>` element for one tick before settling to escaped text. That
36
+ transient raw element is what reached overrides with the wrong props. A
37
+ component tag now opens a block only once its line is known to be complete.
38
+ - **Recovery no longer loses the parser config.** The worker keeps config per
39
+ stream id, so the one-shot recovery re-feed landed on a worker that had never
40
+ seen it while `configSent` stayed latched — the healed parser was silently
41
+ rebuilt with library defaults, dropping `componentTags`, `blockData`,
42
+ `gfmMath` and the whole `kind.data` structured channel for the rest of the
43
+ session.
44
+ - **A terminal worker failure no longer corrupts the document.** The store kept
45
+ the dead generation's blocks while the fresh parser renumbered from zero, so
46
+ the next append merged two generations under colliding ids (duplicate React
47
+ keys, silently overwritten blocks, a shrinking document). The generation now
48
+ restarts cleanly, with a one-time warning. Transient failures still heal
49
+ invisibly through recovery, unchanged.
50
+ - **Per-block error containment is free for committed blocks.** The boundary
51
+ lives inside the per-block memo, so a settled document re-renders no
52
+ boundaries when the streaming tail patches — a new React-side complexity gate
53
+ (`test/boundary-linearity.test.tsx`) pins this, counting work rather than
54
+ timing it, mirroring the Rust `scaling` gate.
55
+ - `applyPatch` and the stale-view merge now drop a malformed entry rather than
56
+ publishing a hole into the snapshot, and both renderers skip a block with no
57
+ `kind` instead of dereferencing it.
58
+
59
+ ### Added
60
+
61
+ - **`onBlockError`** on `<BrookMarkdown>` — fires when a block's render throws,
62
+ with `{ blockId, kind, componentKeys, html }`. Without it the same detail goes
63
+ to `console.error`.
64
+
65
+ ### Changed
66
+
67
+ - `Components` is now a mapped type: block-kind keys (`CodeBlock`, `Table`,
68
+ `Alert`, …) are typed to `BlockComponentProps`; every other key stays
69
+ permissive. Type-only, but it will surface existing mismatches at compile time.
70
+ - Requires `brookmd-core` 0.24.0 (the streaming component-tag fix above).
71
+ - `onBlockError` is identity-sensitive like `components` / `onRenderMetrics` —
72
+ hoist or memoize it, or every block re-renders on every patch.
73
+
7
74
  ## 0.24.0 — 2026-07-24
8
75
 
9
76
  ### 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,27 @@ 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
+ warnOnce(
33
+ "orphan-committed",
34
+ `brookmd: committed block ${store.committedOrder[i]} is missing from the store and was dropped. This is a bug in brookmd \u2014 please report it.`
35
+ );
36
+ continue;
37
+ }
38
+ next[w++] = b;
29
39
  }
30
40
  for (let i = 0; i < store.active.length; i++) {
31
- next[store.committedOrder.length + i] = store.active[i];
41
+ const b = store.active[i];
42
+ if (b === void 0) {
43
+ warnOnce("orphan-active", `brookmd: active block at index ${i} is undefined and was dropped.`);
44
+ continue;
45
+ }
46
+ next[w++] = b;
32
47
  }
48
+ if (w !== next.length) next.length = w;
33
49
  store.snapshot = next;
34
50
  }
35
51
  class BrookPool {
@@ -273,6 +289,9 @@ class BrookClient {
273
289
  streamId = 0;
274
290
  config;
275
291
  configSent = false;
292
+ /** Set when ensureAcquired rebound this stream onto a fresh worker+parser
293
+ * after a fatal failure; consumed by the next content-bearing op. */
294
+ pendingRebind = false;
276
295
  listeners = /* @__PURE__ */ new Set();
277
296
  store = emptyBlockStore();
278
297
  onError;
@@ -359,6 +378,9 @@ class BrookClient {
359
378
  // reads between notifies must return the SAME reference — the
360
379
  // useSyncExternalStore cached-snapshot contract.
361
380
  mergeCache = null;
381
+ /** Set by mergeStale when it had to compact a hole out of the view, so
382
+ * getSnapshot skips caching a view whose indices no longer track `base`. */
383
+ mergeDropped = false;
362
384
  // Perf
363
385
  appendedBytes = 0;
364
386
  patchCount = 0;
@@ -430,12 +452,36 @@ class BrookClient {
430
452
  */
431
453
  ensureAcquired() {
432
454
  if (this.pw && !this.pw.failed) return this.pw;
455
+ const rebinding = this.pw !== null;
433
456
  this.pw = null;
434
457
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
435
458
  this.streamId = streamId;
436
459
  this.pw = pw;
460
+ if (rebinding) {
461
+ this.configSent = false;
462
+ this.pendingRebind = true;
463
+ }
437
464
  return pw;
438
465
  }
466
+ /**
467
+ * A worker rebind left the store holding a dead generation's blocks while the
468
+ * fresh parser restarts ids at 0. Called by the ops that actually feed the
469
+ * parser, before they do. Recovery's re-feed handles this itself (it re-parses
470
+ * the whole document over a preserved view) and clears the flag; this is the
471
+ * path where recovery is off or already spent, where the honest outcome is a
472
+ * clean restart rather than two generations interleaved under colliding ids.
473
+ */
474
+ settleRebind() {
475
+ if (!this.pendingRebind) return;
476
+ if (this.failedError === null) return;
477
+ this.pendingRebind = false;
478
+ if (this.store.snapshot.length === 0 && !this.staleSnapshot) return;
479
+ warnOnce(
480
+ "rebind-reset",
481
+ "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."
482
+ );
483
+ this.resetParser();
484
+ }
439
485
  get ready() {
440
486
  return this.pw?.ready ?? false;
441
487
  }
@@ -466,6 +512,7 @@ class BrookClient {
466
512
  }
467
513
  append(chunk) {
468
514
  const pw = this.ensureAcquired();
515
+ this.settleRebind();
469
516
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
470
517
  if (this.recovery) {
471
518
  this.recoveryBuffer += chunk;
@@ -477,6 +524,7 @@ class BrookClient {
477
524
  }
478
525
  finalize() {
479
526
  const pw = this.ensureAcquired();
527
+ this.settleRebind();
480
528
  this.finalizePending = true;
481
529
  this.contentDone = true;
482
530
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
@@ -599,6 +647,7 @@ class BrookClient {
599
647
  this.mergeCache = null;
600
648
  this.failedError = null;
601
649
  this.recoveryAttempted = false;
650
+ this.pendingRebind = false;
602
651
  this.resetParser();
603
652
  if (hadContent) this.emit(true);
604
653
  }
@@ -700,7 +749,12 @@ class BrookClient {
700
749
  const cache = this.mergeCache;
701
750
  if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
702
751
  const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
703
- this.mergeCache = { base, trimmed: this.staleTrimmed, view };
752
+ if (this.mergeDropped) {
753
+ this.mergeDropped = false;
754
+ this.mergeCache = null;
755
+ } else {
756
+ this.mergeCache = { base, trimmed: this.staleTrimmed, view };
757
+ }
704
758
  return view;
705
759
  };
706
760
  /**
@@ -731,6 +785,7 @@ class BrookClient {
731
785
  const stale = this.staleSnapshot;
732
786
  const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
733
787
  const view = new Array(len);
788
+ let dropped = false;
734
789
  for (let i = 0; i < len; i++) {
735
790
  const nb = i < base.length ? base[i] : void 0;
736
791
  if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
@@ -739,6 +794,10 @@ class BrookClient {
739
794
  }
740
795
  const ob = i < stale.length ? stale[i] : void 0;
741
796
  if (!nb) {
797
+ if (ob === void 0) {
798
+ dropped = true;
799
+ continue;
800
+ }
742
801
  view[i] = ob;
743
802
  continue;
744
803
  }
@@ -756,6 +815,11 @@ class BrookClient {
756
815
  }
757
816
  view[i] = { ...nb, id: this.idNamespace + nb.id };
758
817
  }
818
+ if (dropped) {
819
+ warnOnce("merge-hole", "brookmd: the stale-merge produced an empty position \u2014 compacting.");
820
+ this.mergeDropped = true;
821
+ return view.filter((b) => b !== void 0);
822
+ }
759
823
  return view;
760
824
  }
761
825
  /**
@@ -919,6 +983,7 @@ class BrookClient {
919
983
  const displayed = this.getSnapshot();
920
984
  if (displayed.length > 0) this.softReset(displayed);
921
985
  else this.resetParser();
986
+ this.pendingRebind = false;
922
987
  this.append(doc);
923
988
  if (done) this.finalize();
924
989
  }
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) {
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));
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,16 @@ 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
+ warnOnce(
23
+ "bad-block",
24
+ `brookmd: snapshot position ${index} has no block kind and was skipped. This indicates a corrupted block store \u2014 please report it.`
25
+ );
26
+ return null;
27
+ }
18
28
  const warnedUnstable = /* @__PURE__ */ new Set();
19
29
  function useUnstablePropWarning(name, value) {
20
30
  const ref = useRef(value);
@@ -49,7 +59,8 @@ function BrookMarkdownFromClient({
49
59
  onRenderMetrics,
50
60
  deferTail,
51
61
  decorators,
52
- urlTransform
62
+ urlTransform,
63
+ onBlockError
53
64
  }) {
54
65
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
55
66
  useUnstablePropWarning("decorators", decorators);
@@ -61,6 +72,7 @@ function BrookMarkdownFromClient({
61
72
  () => components && Object.keys(components).length > 0 ? components : void 0,
62
73
  [components]
63
74
  );
75
+ const componentKeys = useMemo(() => comps ? Object.keys(comps) : EMPTY_KEYS, [comps]);
64
76
  const onMetrics = useMemo(
65
77
  () => onRenderMetrics ? (id2, m) => {
66
78
  client.__noteRender();
@@ -78,20 +90,29 @@ function BrookMarkdownFromClient({
78
90
  "aria-live": ariaLive,
79
91
  "aria-atomic": ariaAtomic,
80
92
  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
- )),
93
+ rendered.map(
94
+ (b, i) => (
95
+ // The guard runs BEFORE `key={b.id}` is evaluated: a malformed entry
96
+ // must not throw here (it would take the whole document down), and the
97
+ // store's density invariant is not something a renderer should bet on.
98
+ b == null || b.kind == null ? skipBadBlock(i) : /* @__PURE__ */ jsx(
99
+ BlockView,
100
+ {
101
+ block: b,
102
+ components: comps,
103
+ virtualize,
104
+ sanitize,
105
+ childMemo,
106
+ onRenderMetrics: onMetrics,
107
+ decorators,
108
+ urlTransform,
109
+ componentKeys,
110
+ onBlockError
111
+ },
112
+ b.id
113
+ )
114
+ )
115
+ ),
95
116
  stickToBottom && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: { scrollSnapAlign: "end" }, className: "brook-bottom-anchor" })
96
117
  ]
97
118
  }
@@ -466,7 +487,8 @@ function renderBlockContent({
466
487
  decorators,
467
488
  urlTransform
468
489
  }) {
469
- const kind = block.kind.type;
490
+ const kind = block?.kind?.type;
491
+ if (kind === void 0) return null;
470
492
  const hasInlineTransforms = !!decorators || !!urlTransform;
471
493
  if (components) {
472
494
  if (kind === "Component") {
@@ -545,14 +567,79 @@ function renderBlockContent({
545
567
  );
546
568
  }
547
569
  function blocksEqual(prev, next) {
570
+ if (prev.block == null || next.block == null) return prev.block === next.block;
548
571
  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
572
  // busts the memo so every committed block re-decorates — the O(n²) footgun
550
573
  // the dev warning calls out. A hoisted/memoized value keeps the memo holding.
551
- prev.decorators === next.decorators && prev.urlTransform === next.urlTransform;
574
+ prev.decorators === next.decorators && prev.urlTransform === next.urlTransform && // Same identity rule as onRenderMetrics: an inline `onBlockError={() => …}`
575
+ // is a fresh closure per render and would re-render every block on every
576
+ // patch. Hoist or memoize it (documented alongside the other hooks).
577
+ prev.onBlockError === next.onBlockError && prev.componentKeys === next.componentKeys;
578
+ }
579
+ let boundaryRenders = 0;
580
+ function __getBoundaryRenders() {
581
+ return boundaryRenders;
582
+ }
583
+ function __resetBoundaryRenders() {
584
+ boundaryRenders = 0;
585
+ }
586
+ function BlockViewOuter(props) {
587
+ const { block, componentKeys, onBlockError } = props;
588
+ return /* @__PURE__ */ jsx(
589
+ BlockBoundary,
590
+ {
591
+ blockId: block.id,
592
+ kind: block.kind.type,
593
+ html: block.html,
594
+ componentKeys: componentKeys ?? EMPTY_KEYS,
595
+ onBlockError,
596
+ children: /* @__PURE__ */ jsx(BlockViewImpl, { ...props })
597
+ }
598
+ );
599
+ }
600
+ const BlockView = memo(BlockViewOuter, blocksEqual);
601
+ class BlockBoundary extends Component {
602
+ state = { caught: false, failedHtml: null };
603
+ static getDerivedStateFromError() {
604
+ return { caught: true };
605
+ }
606
+ /** Retry once the block's HTML moves on. A streaming-tail failure — a
607
+ * speculatively-closed tag, a prop that is only transiently absent — then
608
+ * heals itself when the block settles, instead of leaving a hole for the rest
609
+ * of the session. */
610
+ static getDerivedStateFromProps(props, state) {
611
+ if (state.caught && state.failedHtml !== null && state.failedHtml !== props.html) {
612
+ return { caught: false, failedHtml: null };
613
+ }
614
+ return null;
615
+ }
616
+ componentDidCatch(error) {
617
+ const info = {
618
+ blockId: this.props.blockId,
619
+ kind: this.props.kind,
620
+ componentKeys: this.props.componentKeys,
621
+ html: this.props.html.slice(0, 200)
622
+ };
623
+ this.setState({ failedHtml: this.props.html });
624
+ if (this.props.onBlockError) {
625
+ this.props.onBlockError(error, info);
626
+ return;
627
+ }
628
+ console.error(
629
+ `brookmd: block ${info.blockId} (${info.kind}) failed to render and was skipped. This is almost always a \`components\` override throwing. If the override 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
+ { componentKeys: info.componentKeys, html: info.html },
631
+ error
632
+ );
633
+ }
634
+ render() {
635
+ boundaryRenders++;
636
+ return this.state.caught ? null : this.props.children;
637
+ }
552
638
  }
553
- const BlockView = memo(BlockViewImpl, blocksEqual);
554
639
  export {
555
640
  BrookMarkdown,
641
+ __getBoundaryRenders,
642
+ __resetBoundaryRenders,
556
643
  __resetUnstableWarnings,
557
644
  blockKindProps,
558
645
  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,9 @@
1
+ /** True unless the bundler/runtime says NODE_ENV === "production". Read off
2
+ * `globalThis` so there is no @types/node dependency; bundlers inline
3
+ * `process.env.NODE_ENV`, and absence is treated as dev (same rule as the
4
+ * unstable-prop tripwire in react.tsx). */
5
+ export declare function isDev(): boolean;
6
+ /** Warn once per `id` (dev only). Returns true if it actually warned. */
7
+ export declare function warnOnce(id: string, message: string): boolean;
8
+ /** Test-only: clear the latch so a test can assert a warning fires. */
9
+ export declare function __resetWarnOnce(): void;
package/dist/warn.js ADDED
@@ -0,0 +1,19 @@
1
+ const warned = /* @__PURE__ */ new Set();
2
+ function isDev() {
3
+ const env = globalThis.process?.env;
4
+ return !env || env.NODE_ENV !== "production";
5
+ }
6
+ function warnOnce(id, message) {
7
+ if (!isDev() || warned.has(id)) return false;
8
+ warned.add(id);
9
+ console.warn(message);
10
+ return true;
11
+ }
12
+ function __resetWarnOnce() {
13
+ warned.clear();
14
+ }
15
+ export {
16
+ __resetWarnOnce,
17
+ isDev,
18
+ warnOnce
19
+ };
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.0",
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"],