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/CHANGELOG.md CHANGED
@@ -4,6 +4,110 @@ 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
+
74
+ ## 0.24.0 — 2026-07-24
75
+
76
+ ### Added
77
+
78
+ - **Worker load failures are now detected and self-heal.** A worker script
79
+ that fails to load (e.g. a stale hashed worker URL held by an already-open
80
+ tab after a redeploy) fires a DOM `error` event instead of ever posting a
81
+ message; previously nothing listened, so the client waited forever and the
82
+ container stayed permanently empty with no console output. The pool now
83
+ listens for `error`/`messageerror`, arms a per-worker boot deadline
84
+ (default 20s, configurable/disable-able via the `BrookPool` options), and
85
+ routes every fatal trigger — WASM init failure, load error, deserialization
86
+ error, deadline — through one idempotent failure path: pending waiters
87
+ reject, each affected client's `onError` fires, and the dead worker is
88
+ terminated and evicted so the pool capacity recovers.
89
+ - **One-shot automatic recovery.** A client whose worker dies transiently
90
+ heals invisibly: the driven document accumulates in a recovery buffer (both
91
+ `setContent` and `append`/`pipeFrom` modes) and is re-fed once to a fresh
92
+ worker through the preserved-view swap path, so the rendered view never
93
+ blanks and in-flight chunks are folded in safely. Recovery re-arms only
94
+ when the caller advances the content, so a document that deterministically
95
+ crashes the parser surfaces an error after exactly one retry instead of
96
+ respawning workers. Opt out with `new BrookClient({ recovery: false })`
97
+ for memory-sensitive giant documents.
98
+ - **`client.failed: Error | null`** — synchronous getter for terminal worker
99
+ failure (stays `null` through a successful invisible heal), for rendering a
100
+ degraded fallback.
101
+ - **React hooks error surface.** `useBrookStream`'s `onError` now also
102
+ receives worker-level errors (with a `fatal` flag on the `Error`), and
103
+ `useBrookMarkdownString` gains an `onError` option; both default to
104
+ `console.error`.
105
+
106
+ ### Fixed
107
+
108
+ - Fatally failed workers no longer leak their per-stream handler-map entries
109
+ in the pool.
110
+
7
111
  ## 0.23.2 — 2026-07-22
8
112
 
9
113
  ### Fixed
package/README.md CHANGED
@@ -555,7 +555,9 @@ class BrookClient {
555
555
  config?: ParserConfig;
556
556
  onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
557
557
  onBlock?: (block: Block) => void; // fires once per block as it commits
558
+ recovery?: boolean; // auto-heal a transient worker death (default true)
558
559
  });
560
+ get failed(): Error | null; // terminal worker failure, else null (null through heals)
559
561
  append(chunk: string): void; // queue text for parsing
560
562
  pipeFrom( // read → append → finalize
561
563
  src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
@@ -591,6 +593,16 @@ failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
591
593
  load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
592
594
  effect each time a block commits (e.g. lazy-highlight a finished code block).
593
595
 
596
+ A **transient worker death** heals invisibly by default: if a worker dies
597
+ mid-stream (e.g. a stale hashed worker URL 404s after a redeploy), the client
598
+ buffers the driven document, re-acquires a fresh worker, and re-feeds it once —
599
+ the view stays on screen and `onError` does **not** fire. Only if the replacement
600
+ also dies is the failure terminal (`onError` with `{ fatal: true }`, and
601
+ `client.failed` becomes the `Error`; it is `null` while healthy and through a
602
+ successful heal). Set `recovery: false` to disable the buffer and auto-recovery
603
+ (a fatal death is then immediately terminal) — worth it for memory-sensitive,
604
+ very large documents where retaining the full source is undesirable.
605
+
594
606
  #### Per-stream config
595
607
 
596
608
  ```ts
@@ -754,6 +766,33 @@ block. The component receives [`BlockComponentProps`](#types): `{ block, html,
754
766
  open, speculative }`, plus `text`/`language` for code/math blocks (the alert
755
767
  type is at `block.kind.data.kind`).
756
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
+
757
796
  Rules worth knowing:
758
797
 
759
798
  - **There is no `node` prop / no hast tree.** Introspect via `className` /
@@ -916,9 +955,23 @@ tags only). It works everywhere inline content does — **including table cells*
916
955
  Tag names match **case-sensitively** and dispatch verbatim to `components[tag]`
917
956
  (`<tik>`→`components.tik`, `<Cite>`→`components.Cite`). The
918
957
  two lists are independent: list a tag under `componentTags` for blocks,
919
- `inlineComponentTags` for inline, or both for both. An allowlisted tag used in an
920
- unsupported position degrades **inertly** (escaped) — it never consumes
921
- 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)).
922
975
 
923
976
  > **Link-bridge alternative.** Before `inlineComponentTags`, the way to get an
924
977
  > inline custom element was the link bridge: emit `[$AAPL](tik://AAPL)` and
@@ -1257,6 +1310,33 @@ re-snapping during streaming, so treat smooth following there as best-effort.
1257
1310
  > is the *shared* worker's heap — clients on the same worker report the same
1258
1311
  > value. Aggregate with `Math.max`, not a sum.
1259
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
+
1260
1340
  ## Architecture
1261
1341
 
1262
1342
  ```
package/dist/client.d.ts CHANGED
@@ -30,7 +30,9 @@ export declare function applyPatch(store: BlockStore, patch: Patch): void;
30
30
  interface PoolWorker {
31
31
  worker: WorkerLike;
32
32
  ready: boolean;
33
- /** Set once WASM init fails; whenWorkerReady rejects with this thereafter. */
33
+ /** Set once the worker fails fatally (WASM init, a DOM load `error`, a
34
+ * `messageerror`, or the boot deadline); whenWorkerReady rejects with this
35
+ * thereafter. */
34
36
  failed: Error | null;
35
37
  streamCount: number;
36
38
  /** Live stream ids on this worker — so a fatal failure can notify each one. */
@@ -39,6 +41,10 @@ interface PoolWorker {
39
41
  resolve: () => void;
40
42
  reject: (e: Error) => void;
41
43
  }>;
44
+ /** Handle for the boot deadline that fails a worker which never reports ready.
45
+ * Opaque (a `number` in the browser, a `Timeout` in Node/bun, or a test
46
+ * fake's id) — cleared on ready, on failure, and on pool disposal. */
47
+ bootTimer: unknown;
42
48
  }
43
49
  /**
44
50
  * A pool of Web Workers, each multiplexing many `BrookParser`s keyed by stream
@@ -59,7 +65,14 @@ export declare class BrookPool {
59
65
  private workers;
60
66
  private handlers;
61
67
  private nextStreamId;
62
- constructor(factory: () => WorkerLike, cap: number);
68
+ private bootTimeoutMs;
69
+ private startTimer;
70
+ private cancelTimer;
71
+ constructor(factory: () => WorkerLike, cap: number, options?: {
72
+ bootTimeoutMs?: number;
73
+ setTimeout?: (fn: () => void, ms: number) => unknown;
74
+ clearTimeout?: (handle: unknown) => void;
75
+ });
63
76
  /** Reserve a stream id and assign a worker, registering its message handler. */
64
77
  acquire(handler: (msg: FromWorker) => void): {
65
78
  streamId: number;
@@ -88,9 +101,30 @@ export declare class BrookPool {
88
101
  /** Terminate every worker (test teardown / full shutdown). */
89
102
  disposeAll(): void;
90
103
  get workerCount(): number;
104
+ /** Live stream→handler registrations. Introspection for tests/diagnostics —
105
+ * a fatal failure reaps the dead worker's entries, so this must not grow
106
+ * across a worker death + recovery cycle. */
107
+ get handlerCount(): number;
91
108
  private pick;
92
109
  private create;
110
+ private startBootTimer;
111
+ private clearBootTimer;
93
112
  private onMessage;
113
+ /**
114
+ * Idempotent fatal-failure handler shared by every trigger: an in-band
115
+ * `{type:"error",fatal:true}` (WASM init), a DOM load `error`, a
116
+ * `messageerror`, and the boot deadline. First cause wins; later calls no-op.
117
+ *
118
+ * A fatally failed worker dooms every stream on it. Reject anyone awaiting
119
+ * readiness, then dispatch a synthetic fatal error to each live stream so its
120
+ * client's `onError` fires exactly as for a WASM-init fatal (the message
121
+ * carries no real streamId to route by). Finally evict the worker: terminate
122
+ * it and drop it from the pool — a dead worker can never parse again, so
123
+ * retaining it would leak an OS thread per failure and keep counting against
124
+ * `cap` until pick()'s cap branch dies and spawns workers unbounded. Reaping
125
+ * restores the cap and lets a fresh worker be made.
126
+ */
127
+ private fail;
94
128
  private dispatch;
95
129
  }
96
130
  /** The process-wide default pool every `BrookClient` shares unless given one. */
@@ -122,6 +156,9 @@ export declare class BrookClient {
122
156
  private streamId;
123
157
  private config?;
124
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;
125
162
  private listeners;
126
163
  private store;
127
164
  private onError?;
@@ -129,6 +166,11 @@ export declare class BrookClient {
129
166
  private attached;
130
167
  private lastContent;
131
168
  private contentDone;
169
+ private failedError;
170
+ private recovery;
171
+ private recoveryBuffer;
172
+ private recoveredLen;
173
+ private recoveryAttempted;
132
174
  private coalesce;
133
175
  private rafHandle;
134
176
  private finalizePending;
@@ -137,6 +179,9 @@ export declare class BrookClient {
137
179
  private staleTrimmed;
138
180
  private idNamespace;
139
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;
140
185
  private appendedBytes;
141
186
  private patchCount;
142
187
  private totalParseMicros;
@@ -170,6 +215,14 @@ export declare class BrookClient {
170
215
  * stream-completion (finalize) patch always flushes synchronously, and a
171
216
  * pending frame is cancelled on `reset()`/`destroy()`. No effect when
172
217
  * `requestAnimationFrame` is unavailable (e.g. SSR) — emits stay synchronous.
218
+ * @param options.recovery opt-out (default `true`): transparently heal a
219
+ * TRANSIENT worker death. The client buffers the full driven document and, on
220
+ * a fatal worker failure, re-acquires a fresh worker and re-feeds it exactly
221
+ * once — the displayed view stays on screen, so a worker that 404s after a
222
+ * redeploy (or otherwise dies mid-stream) recovers invisibly instead of
223
+ * freezing the render. If the replacement ALSO dies the error surfaces
224
+ * (`failed` / `onError`). Set `false` to disable both the buffering and the
225
+ * auto-recovery — a fatal failure then goes straight to terminal.
173
226
  */
174
227
  constructor(options?: {
175
228
  pool?: BrookPool;
@@ -180,6 +233,7 @@ export declare class BrookClient {
180
233
  }) => void;
181
234
  onBlock?: (block: Block) => void;
182
235
  coalesce?: boolean;
236
+ recovery?: boolean;
183
237
  });
184
238
  /**
185
239
  * Lazily reserve this client's stream id and bind it to a pool worker. The
@@ -197,7 +251,27 @@ export declare class BrookClient {
197
251
  * multiplexing (pick() is unchanged and remains the only path to create()).
198
252
  */
199
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;
200
263
  get ready(): boolean;
264
+ /**
265
+ * The fatal error that killed this client's worker, or `null` if healthy.
266
+ *
267
+ * Non-null only once a failure is TERMINAL: a worker that died with recovery
268
+ * off or nothing buffered to re-feed, or a client (either mode) whose one-shot
269
+ * auto-recovery re-feed ALSO hit a dying worker. It stays `null` throughout a
270
+ * successful transient recovery (the death heals invisibly) and is cleared
271
+ * again by {@link reset}. Pairs with `onError`, which fires on the same
272
+ * terminal failure.
273
+ */
274
+ get failed(): Error | null;
201
275
  whenReady(): Promise<void>;
202
276
  private firstConfig;
203
277
  append(chunk: string): void;
@@ -354,6 +428,29 @@ export declare class BrookClient {
354
428
  */
355
429
  toPlaintext(): string;
356
430
  private onMessage;
431
+ private reportError;
432
+ /**
433
+ * One-shot self-heal after a transient worker death, for BOTH drive modes.
434
+ * The dead worker was already evicted, so redriving the buffered document
435
+ * re-acquires a FRESH worker (ensureAcquired re-acquires because the old
436
+ * `pw.failed` is set). Reads the buffer at EXECUTION time, so a chunk that
437
+ * interleaved ahead of this microtask is included. Deliberately does NOT route
438
+ * through setContent(): that would stamp `lastContent`, flipping an append-mode
439
+ * client into setContent mode, and a second death would then re-feed a stale
440
+ * `lastContent` missing post-recovery chunks. `recoveryAttempted` is NOT reset
441
+ * here — if the replacement also dies before healing, the fatal path sees the
442
+ * flag still set and surfaces the error instead of looping.
443
+ */
444
+ private recover;
445
+ /**
446
+ * Rebuild the parser onto a fresh worker and re-feed `doc` as one atomic
447
+ * append (re-accumulating recoveryBuffer). Keeps the displayed view on screen
448
+ * across the swap by softReset-ing when something is rendered, so the document
449
+ * never blanks; falls back to a bare resetParser when the store is empty.
450
+ * Uses resetParser / softReset (NOT reset(), which would clear the one-shot
451
+ * recovery guards mid-heal). Re-finalizes when the buffered doc was finalized.
452
+ */
453
+ private refeed;
357
454
  /**
358
455
  * Notify subscribers of a new snapshot.
359
456
  *