brookmd 0.23.2 → 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/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,24 +25,51 @@ 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 {
36
- constructor(factory, cap) {
52
+ constructor(factory, cap, options = {}) {
37
53
  this.factory = factory;
38
54
  this.cap = cap;
55
+ this.bootTimeoutMs = options.bootTimeoutMs ?? 2e4;
56
+ this.startTimer = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
57
+ this.cancelTimer = options.clearTimeout ?? ((h) => clearTimeout(h));
39
58
  }
40
59
  factory;
41
60
  cap;
42
61
  workers = [];
43
62
  handlers = /* @__PURE__ */ new Map();
44
63
  nextStreamId = 1;
64
+ // Per-worker boot deadline: if a worker reports neither ready nor a fatal
65
+ // failure within this window it is failed with a clear message — the miss that
66
+ // otherwise leaves `<div class="brook-md">` permanently empty (a stale hashed
67
+ // worker URL 404s after a redeploy: the DOM fires `error`, but a browser that
68
+ // somehow swallowed it would hang forever). `0` / non-finite disables it.
69
+ bootTimeoutMs;
70
+ // Timer machinery, injectable so the deadline is testable with fake timers.
71
+ startTimer;
72
+ cancelTimer;
45
73
  /** Reserve a stream id and assign a worker, registering its message handler. */
46
74
  acquire(handler) {
47
75
  const streamId = this.nextStreamId++;
@@ -98,6 +126,7 @@ class BrookPool {
98
126
  /** Terminate every worker (test teardown / full shutdown). */
99
127
  disposeAll() {
100
128
  for (const pw of this.workers) {
129
+ this.clearBootTimer(pw);
101
130
  try {
102
131
  pw.worker.terminate();
103
132
  } catch {
@@ -109,6 +138,12 @@ class BrookPool {
109
138
  get workerCount() {
110
139
  return this.workers.length;
111
140
  }
141
+ /** Live stream→handler registrations. Introspection for tests/diagnostics —
142
+ * a fatal failure reaps the dead worker's entries, so this must not grow
143
+ * across a worker death + recovery cycle. */
144
+ get handlerCount() {
145
+ return this.handlers.size;
146
+ }
112
147
  // Create a new worker while under cap and every live worker is busy; otherwise
113
148
  // attach to the least-loaded LIVE worker. A fatally-failed worker is never
114
149
  // handed out (a stream on it would post into a dead worker and hang) — it is
@@ -128,41 +163,96 @@ class BrookPool {
128
163
  failed: null,
129
164
  streamCount: 0,
130
165
  streamIds: /* @__PURE__ */ new Set(),
131
- readyWaiters: []
166
+ readyWaiters: [],
167
+ bootTimer: null
132
168
  };
169
+ try {
170
+ pw.worker.addEventListener("error", (ev) => {
171
+ const detail = ev.message;
172
+ this.fail(pw, new Error(`brookmd worker failed to load${detail ? `: ${detail}` : ""}`));
173
+ });
174
+ } catch {
175
+ }
176
+ try {
177
+ pw.worker.addEventListener("messageerror", () => {
178
+ this.fail(pw, new Error("brookmd worker message could not be deserialized"));
179
+ });
180
+ } catch {
181
+ }
133
182
  pw.worker.addEventListener("message", (ev) => this.onMessage(pw, ev.data));
134
183
  this.workers.push(pw);
184
+ this.startBootTimer(pw);
135
185
  return pw;
136
186
  }
187
+ // Arm the per-worker boot deadline (no-op when disabled). Uses the injected
188
+ // timer so tests drive it deterministically, and `.unref()`s the handle (when
189
+ // present) so a pending deadline never keeps a Node/bun process alive.
190
+ startBootTimer(pw) {
191
+ if (!(this.bootTimeoutMs > 0) || !Number.isFinite(this.bootTimeoutMs)) return;
192
+ const timer = this.startTimer(() => {
193
+ if (!pw.ready && !pw.failed) {
194
+ this.fail(pw, new Error(`brookmd worker did not become ready within ${this.bootTimeoutMs}ms`));
195
+ }
196
+ }, this.bootTimeoutMs);
197
+ timer?.unref?.();
198
+ pw.bootTimer = timer;
199
+ }
200
+ clearBootTimer(pw) {
201
+ if (pw.bootTimer !== null) {
202
+ this.cancelTimer(pw.bootTimer);
203
+ pw.bootTimer = null;
204
+ }
205
+ }
137
206
  onMessage(pw, msg) {
138
207
  if (msg.type === "ready") {
139
208
  pw.ready = true;
209
+ this.clearBootTimer(pw);
140
210
  const waiters = pw.readyWaiters;
141
211
  pw.readyWaiters = [];
142
212
  for (const w of waiters) w.resolve();
143
213
  return;
144
214
  }
145
215
  if (msg.type === "error" && msg.fatal) {
146
- const err = new Error(msg.message);
147
- pw.failed = err;
148
- const waiters = pw.readyWaiters;
149
- pw.readyWaiters = [];
150
- for (const w of waiters) {
151
- try {
152
- w.reject(err);
153
- } catch {
154
- }
155
- }
156
- for (const sid of pw.streamIds) this.dispatch(sid, msg);
216
+ this.fail(pw, new Error(msg.message));
217
+ return;
218
+ }
219
+ this.dispatch(msg.streamId, msg);
220
+ }
221
+ /**
222
+ * Idempotent fatal-failure handler shared by every trigger: an in-band
223
+ * `{type:"error",fatal:true}` (WASM init), a DOM load `error`, a
224
+ * `messageerror`, and the boot deadline. First cause wins; later calls no-op.
225
+ *
226
+ * A fatally failed worker dooms every stream on it. Reject anyone awaiting
227
+ * readiness, then dispatch a synthetic fatal error to each live stream so its
228
+ * client's `onError` fires exactly as for a WASM-init fatal (the message
229
+ * carries no real streamId to route by). Finally evict the worker: terminate
230
+ * it and drop it from the pool — a dead worker can never parse again, so
231
+ * retaining it would leak an OS thread per failure and keep counting against
232
+ * `cap` until pick()'s cap branch dies and spawns workers unbounded. Reaping
233
+ * restores the cap and lets a fresh worker be made.
234
+ */
235
+ fail(pw, err) {
236
+ if (pw.failed) return;
237
+ pw.failed = err;
238
+ this.clearBootTimer(pw);
239
+ const waiters = pw.readyWaiters;
240
+ pw.readyWaiters = [];
241
+ for (const w of waiters) {
157
242
  try {
158
- pw.worker.terminate();
243
+ w.reject(err);
159
244
  } catch {
160
245
  }
161
- const idx = this.workers.indexOf(pw);
162
- if (idx !== -1) this.workers.splice(idx, 1);
163
- return;
164
246
  }
165
- this.dispatch(msg.streamId, msg);
247
+ const msg = { type: "error", streamId: -1, message: err.message, fatal: true };
248
+ for (const sid of pw.streamIds) this.dispatch(sid, msg);
249
+ for (const sid of pw.streamIds) this.handlers.delete(sid);
250
+ try {
251
+ pw.worker.terminate();
252
+ } catch {
253
+ }
254
+ const idx = this.workers.indexOf(pw);
255
+ if (idx !== -1) this.workers.splice(idx, 1);
166
256
  }
167
257
  // Route a message to a stream's handler, isolating a throwing client callback
168
258
  // (e.g. a user-supplied onError) so it can neither break the worker message
@@ -199,6 +289,9 @@ class BrookClient {
199
289
  streamId = 0;
200
290
  config;
201
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;
202
295
  listeners = /* @__PURE__ */ new Set();
203
296
  store = emptyBlockStore();
204
297
  onError;
@@ -209,6 +302,41 @@ class BrookClient {
209
302
  // parser there, so the baseline is stale and the document must be re-fed).
210
303
  lastContent = "";
211
304
  contentDone = false;
305
+ // --- Worker-failure recovery ---
306
+ // The terminal fatal error for this client's stream: non-null once its worker
307
+ // failed AND (if a recovery was attempted) the replacement also failed. Null
308
+ // while a recovery is in flight / succeeded, and reset by reset(). Surfaced by
309
+ // the `failed` getter.
310
+ failedError = null;
311
+ // Whether auto-recovery (and the buffer that feeds it) is enabled — off by the
312
+ // `recovery: false` constructor option. When off, nothing is buffered and a
313
+ // fatal worker death is immediately terminal in BOTH modes.
314
+ recovery = true;
315
+ // The full document driven into this stream so far — accumulated in append()
316
+ // (so it captures BOTH manual append/pipeFrom AND setContent, which drives via
317
+ // append(delta)). It is the baseline re-fed after a transient worker death.
318
+ // resetParser() clears it and the ensuing re-feed rebuilds it, so it stays
319
+ // exactly equal to the live document on every path.
320
+ recoveryBuffer = "";
321
+ // Buffer length captured at the last completed re-feed. append()'s growth
322
+ // re-arm compares against it: once the caller drives the buffer PAST this, a
323
+ // future death may heal again. Set to Infinity while a recovery is pending
324
+ // (fatal → microtask) so a stray chunk arriving before recover() runs can't
325
+ // spuriously re-arm; set to the re-fed length once recover() completes. Its
326
+ // "!== Infinity" also stands in for "a recovery is outstanding" in the
327
+ // setContent divergence re-arm below.
328
+ recoveredLen = Infinity;
329
+ // One-shot guard: set when a fatal failure schedules an auto-recovery re-feed
330
+ // so a replacement worker that also dies is NOT retried a second time. Re-armed
331
+ // (cleared) only when the caller drives NEW content — never on a mere
332
+ // successful patch, because a finalize()-that-traps document emits an append
333
+ // patch before it re-traps, and re-arming there would loop the same poison doc
334
+ // through workers forever. Two complementary re-arm rules clear it: append()'s
335
+ // buffer-GROWTH check (streaming past the recovered length) and setContent()'s
336
+ // DIVERGENCE check (content differs from the re-fed buffer — catches a
337
+ // same-length-or-shorter replacement the growth check misses). Also cleared on
338
+ // an explicit caller reset().
339
+ recoveryAttempted = false;
212
340
  // Opt-in rAF coalescing (see constructor `coalesce`). When on AND
213
341
  // requestAnimationFrame exists, intra-frame emit()s collapse into ONE
214
342
  // rAF-scheduled flush to listeners — the React useSyncExternalStore path then
@@ -250,6 +378,9 @@ class BrookClient {
250
378
  // reads between notifies must return the SAME reference — the
251
379
  // useSyncExternalStore cached-snapshot contract.
252
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;
253
384
  // Perf
254
385
  appendedBytes = 0;
255
386
  patchCount = 0;
@@ -287,6 +418,14 @@ class BrookClient {
287
418
  * stream-completion (finalize) patch always flushes synchronously, and a
288
419
  * pending frame is cancelled on `reset()`/`destroy()`. No effect when
289
420
  * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
421
+ * @param options.recovery opt-out (default `true`): transparently heal a
422
+ * TRANSIENT worker death. The client buffers the full driven document and, on
423
+ * a fatal worker failure, re-acquires a fresh worker and re-feeds it exactly
424
+ * once — the displayed view stays on screen, so a worker that 404s after a
425
+ * redeploy (or otherwise dies mid-stream) recovers invisibly instead of
426
+ * freezing the render. If the replacement ALSO dies the error surfaces
427
+ * (`failed` / `onError`). Set `false` to disable both the buffering and the
428
+ * auto-recovery — a fatal failure then goes straight to terminal.
290
429
  */
291
430
  constructor(options = {}) {
292
431
  this.pool = options.pool ?? getDefaultPool();
@@ -294,6 +433,7 @@ class BrookClient {
294
433
  this.onError = options.onError;
295
434
  this.onBlock = options.onBlock;
296
435
  this.coalesce = options.coalesce ?? false;
436
+ this.recovery = options.recovery ?? true;
297
437
  }
298
438
  /**
299
439
  * Lazily reserve this client's stream id and bind it to a pool worker. The
@@ -312,15 +452,52 @@ class BrookClient {
312
452
  */
313
453
  ensureAcquired() {
314
454
  if (this.pw && !this.pw.failed) return this.pw;
455
+ const rebinding = this.pw !== null;
315
456
  this.pw = null;
316
457
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
317
458
  this.streamId = streamId;
318
459
  this.pw = pw;
460
+ if (rebinding) {
461
+ this.configSent = false;
462
+ this.pendingRebind = true;
463
+ }
319
464
  return pw;
320
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
+ }
321
485
  get ready() {
322
486
  return this.pw?.ready ?? false;
323
487
  }
488
+ /**
489
+ * The fatal error that killed this client's worker, or `null` if healthy.
490
+ *
491
+ * Non-null only once a failure is TERMINAL: a worker that died with recovery
492
+ * off or nothing buffered to re-feed, or a client (either mode) whose one-shot
493
+ * auto-recovery re-feed ALSO hit a dying worker. It stays `null` throughout a
494
+ * successful transient recovery (the death heals invisibly) and is cleared
495
+ * again by {@link reset}. Pairs with `onError`, which fires on the same
496
+ * terminal failure.
497
+ */
498
+ get failed() {
499
+ return this.failedError;
500
+ }
324
501
  whenReady() {
325
502
  const pw = this.ensureAcquired();
326
503
  return this.pool.whenWorkerReady(pw);
@@ -335,12 +512,21 @@ class BrookClient {
335
512
  }
336
513
  append(chunk) {
337
514
  const pw = this.ensureAcquired();
515
+ this.settleRebind();
338
516
  if (this.firstAppendMs === 0) this.firstAppendMs = performance.now();
517
+ if (this.recovery) {
518
+ this.recoveryBuffer += chunk;
519
+ if (this.recoveryAttempted && this.recoveryBuffer.length > this.recoveredLen) {
520
+ this.recoveryAttempted = false;
521
+ }
522
+ }
339
523
  this.pool.send(pw, { type: "append", streamId: this.streamId, chunk, config: this.firstConfig(), epoch: this.epoch });
340
524
  }
341
525
  finalize() {
342
526
  const pw = this.ensureAcquired();
527
+ this.settleRebind();
343
528
  this.finalizePending = true;
529
+ this.contentDone = true;
344
530
  this.pool.send(pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig(), epoch: this.epoch });
345
531
  }
346
532
  /**
@@ -431,6 +617,9 @@ class BrookClient {
431
617
  * `components` instead, keeping the source append-only.
432
618
  */
433
619
  setContent(content, opts) {
620
+ if (this.recoveryAttempted && this.recoveredLen !== Infinity && content !== this.recoveryBuffer) {
621
+ this.recoveryAttempted = false;
622
+ }
434
623
  if (content !== this.lastContent) {
435
624
  if (!this.contentDone && content.startsWith(this.lastContent)) {
436
625
  if (this.lastContent === "" && content.length > 0 && this.getSnapshot().length > 0) {
@@ -456,6 +645,9 @@ class BrookClient {
456
645
  this.staleSnapshot = null;
457
646
  this.staleTrimmed = false;
458
647
  this.mergeCache = null;
648
+ this.failedError = null;
649
+ this.recoveryAttempted = false;
650
+ this.pendingRebind = false;
459
651
  this.resetParser();
460
652
  if (hadContent) this.emit(true);
461
653
  }
@@ -513,6 +705,8 @@ class BrookClient {
513
705
  this.wasmMemoryBytes = 0;
514
706
  this.lastContent = "";
515
707
  this.contentDone = false;
708
+ this.recoveryBuffer = "";
709
+ this.recoveredLen = Infinity;
516
710
  this.cancelFrame();
517
711
  this.finalizePending = false;
518
712
  this.epoch += 1;
@@ -555,7 +749,12 @@ class BrookClient {
555
749
  const cache = this.mergeCache;
556
750
  if (cache && cache.base === base && cache.trimmed === this.staleTrimmed) return cache.view;
557
751
  const view = this.mergeStale(base, cache && cache.trimmed === this.staleTrimmed ? cache : null);
558
- 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
+ }
559
758
  return view;
560
759
  };
561
760
  /**
@@ -586,6 +785,7 @@ class BrookClient {
586
785
  const stale = this.staleSnapshot;
587
786
  const len = this.staleTrimmed ? base.length : Math.max(base.length, stale.length);
588
787
  const view = new Array(len);
788
+ let dropped = false;
589
789
  for (let i = 0; i < len; i++) {
590
790
  const nb = i < base.length ? base[i] : void 0;
591
791
  if (nb !== void 0 && prev !== null && i < prev.base.length && prev.base[i] === nb) {
@@ -594,6 +794,10 @@ class BrookClient {
594
794
  }
595
795
  const ob = i < stale.length ? stale[i] : void 0;
596
796
  if (!nb) {
797
+ if (ob === void 0) {
798
+ dropped = true;
799
+ continue;
800
+ }
597
801
  view[i] = ob;
598
802
  continue;
599
803
  }
@@ -611,6 +815,11 @@ class BrookClient {
611
815
  }
612
816
  view[i] = { ...nb, id: this.idNamespace + nb.id };
613
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
+ }
614
823
  return view;
615
824
  }
616
825
  /**
@@ -717,15 +926,67 @@ class BrookClient {
717
926
  }
718
927
  break;
719
928
  }
720
- case "error":
721
- if (this.onError) {
722
- this.onError({ message: msg.message, fatal: msg.fatal });
723
- } else {
724
- console.error("brookmd worker error:", msg.message);
929
+ case "error": {
930
+ if (!msg.fatal) {
931
+ this.reportError(msg.message, msg.fatal);
932
+ break;
933
+ }
934
+ if (this.recovery && this.recoveryBuffer.length > 0 && !this.recoveryAttempted) {
935
+ this.recoveryAttempted = true;
936
+ this.recoveredLen = Infinity;
937
+ queueMicrotask(() => this.recover());
938
+ break;
725
939
  }
940
+ this.failedError = new Error(msg.message);
941
+ this.reportError(msg.message, msg.fatal);
726
942
  break;
943
+ }
944
+ }
945
+ }
946
+ // Surface a worker error to the caller's onError, falling back to console.
947
+ reportError(message, fatal) {
948
+ if (this.onError) {
949
+ this.onError({ message, fatal });
950
+ } else {
951
+ console.error("brookmd worker error:", message);
727
952
  }
728
953
  }
954
+ /**
955
+ * One-shot self-heal after a transient worker death, for BOTH drive modes.
956
+ * The dead worker was already evicted, so redriving the buffered document
957
+ * re-acquires a FRESH worker (ensureAcquired re-acquires because the old
958
+ * `pw.failed` is set). Reads the buffer at EXECUTION time, so a chunk that
959
+ * interleaved ahead of this microtask is included. Deliberately does NOT route
960
+ * through setContent(): that would stamp `lastContent`, flipping an append-mode
961
+ * client into setContent mode, and a second death would then re-feed a stale
962
+ * `lastContent` missing post-recovery chunks. `recoveryAttempted` is NOT reset
963
+ * here — if the replacement also dies before healing, the fatal path sees the
964
+ * flag still set and surfaces the error instead of looping.
965
+ */
966
+ recover() {
967
+ if (!this.attached) return;
968
+ const doc = this.recoveryBuffer;
969
+ const done = this.contentDone;
970
+ this.refeed(doc, done);
971
+ this.recoveredLen = doc.length;
972
+ this.lastContent = doc;
973
+ }
974
+ /**
975
+ * Rebuild the parser onto a fresh worker and re-feed `doc` as one atomic
976
+ * append (re-accumulating recoveryBuffer). Keeps the displayed view on screen
977
+ * across the swap by softReset-ing when something is rendered, so the document
978
+ * never blanks; falls back to a bare resetParser when the store is empty.
979
+ * Uses resetParser / softReset (NOT reset(), which would clear the one-shot
980
+ * recovery guards mid-heal). Re-finalizes when the buffered doc was finalized.
981
+ */
982
+ refeed(doc, done) {
983
+ const displayed = this.getSnapshot();
984
+ if (displayed.length > 0) this.softReset(displayed);
985
+ else this.resetParser();
986
+ this.pendingRebind = false;
987
+ this.append(doc);
988
+ if (done) this.finalize();
989
+ }
729
990
  /**
730
991
  * Notify subscribers of a new snapshot.
731
992
  *
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
  /**
@@ -183,7 +218,9 @@ export declare function __resetUnstableWarnings(): void;
183
218
  */
184
219
  export declare function useBrookStream(stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined, options?: {
185
220
  config?: ParserConfig;
186
- onError?: (err: Error) => void;
221
+ onError?: (err: Error & {
222
+ fatal?: boolean;
223
+ }) => void;
187
224
  }): BrookClient;
188
225
  /**
189
226
  * Own a {@link BrookClient} driven by a CONTROLLED full string — the bridge for
@@ -206,15 +243,22 @@ export declare function useBrookStream(stream: AsyncIterable<string> | ReadableS
206
243
  * (reattach re-feeds the document). For a true stream source
207
244
  * (`Response` / `ReadableStream` / SSE generator) use {@link useBrookStream}
208
245
  * instead — it avoids buffering the whole document as a string.
246
+ *
247
+ * Pass `onError` to be notified of a terminal worker failure (`err.fatal` set) or
248
+ * a parse error; defaults to `console.error`. A transient worker death heals
249
+ * invisibly (see the client's `recovery` option) and does not fire it.
209
250
  */
210
251
  export declare function useBrookMarkdownString(content: string, options?: {
211
252
  config?: ParserConfig;
212
253
  streaming?: boolean;
254
+ onError?: (err: Error & {
255
+ fatal?: boolean;
256
+ }) => void;
213
257
  }): BrookClient;
214
258
  declare function BrookMarkdownImpl(props: BrookMarkdownProps): import("react/jsx-runtime").JSX.Element;
215
259
  export declare const BrookMarkdown: import("react").MemoExoticComponent<typeof BrookMarkdownImpl>;
216
260
  export declare function blockKindProps(block: Block, components?: Components): BlockComponentProps;
217
- export declare function blocksEqual(prev: {
261
+ interface BlockViewProps {
218
262
  block: Block;
219
263
  components?: Components;
220
264
  virtualize?: boolean;
@@ -223,14 +267,12 @@ export declare function blocksEqual(prev: {
223
267
  onRenderMetrics?: RenderMetricsHook;
224
268
  decorators?: Decorator[];
225
269
  urlTransform?: UrlTransform;
226
- }, next: {
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
- }): 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;
236
278
  export {};