brookmd 0.27.0 → 0.29.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,102 @@ 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.29.0 — 2026-07-31
8
+
9
+ **Sublinear resources.** Total parse work has an Ω(n) floor and stays exactly
10
+ there — but retained memory and reopen latency don't have to be O(n), and now
11
+ they aren't. Requires `brookmd-core` 0.26.0. Both features are **web-path**
12
+ (WASM + TS client); the native bindings (React Native / Kotlin / Swift /
13
+ Flutter) share every parser-core improvement but consume `allBlocks()` and so
14
+ keep full retention — see the platform matrix in the root README.
15
+
16
+ ### Added
17
+
18
+ - **Instant thread reopen — `getPersistable()` / `hydrate()` /
19
+ `sourceFingerprint()`.** A finished (or in-flight) stream can be persisted
20
+ as a small JSON envelope — the blocks `getSnapshot()` showed plus a source
21
+ length/hash and a `done` flag — and a fresh `BrookClient` hydrates from it
22
+ with **no worker, no WASM, no parsing**: 1 MB / 2,250 blocks re-streams in
23
+ ~110 ms, hydrates in **~5.5 ms** (and a real browser reopen also skips
24
+ worker spin-up and WASM init). To *continue* a non-finalized thread, pass
25
+ the original source to `beginResume()`: the UI keeps showing hydrated
26
+ blocks while a background re-parse catches up — reusing the existing
27
+ divergence/adoption machinery, so every hydrated block keeps its exact id
28
+ across the swap and nothing visible remounts; appends stream live once
29
+ caught up. The envelope is a versioned **package** format
30
+ (`hydrateVersion`), deliberately not the wire contract; the README's
31
+ "Instant thread reopen" section documents the persistence and invalidation
32
+ contract.
33
+ - **`retainCommittedHtml` — the parser no longer hoards the rendered
34
+ document.** The core kept every committed block's HTML alive for
35
+ `allBlocks()` — which the web worker path never calls. The worker now
36
+ defaults the flag **off**: retained memory beyond the source buffer drops
37
+ to the open tail only (measured: **53–70% less total retention** on 1 MB
38
+ streams, with the flag-off overhead above the raw source staying under
39
+ 300 bytes at every point). Patches are byte-identical either way — committed
40
+ blocks are emitted exactly once and never re-read. Rust-side default stays
41
+ ON, so native bindings and `allBlocks()` consumers are unaffected;
42
+ `renderToString` pins it on (it assembles from `allBlocks`). Set
43
+ `retainCommittedHtml: true` in `ParserConfig` if you drive the worker and
44
+ want `allBlocks` fidelity.
45
+
46
+ ## 0.28.0 — 2026-07-31
47
+
48
+ **Browser-side linearity.** The parser and wire have been O(new bytes) per
49
+ append for a while; this release makes the *DOM application* match. Before, an
50
+ open (streaming) block was fully rebuilt on every animation frame — a 20 KB
51
+ highlighted code block wrote ~44 million characters of `innerHTML` over its
52
+ lifetime (≈2,150× the wire bytes), and a per-frame `replaceWith` destroyed any
53
+ text selection or `<pre>` scroll position inside the block. Every open-block
54
+ path now applies patches incrementally, measured at **2–3.4× the block's final
55
+ markup, flat across sizes** (1× is the write-it-once floor), and enforced by a
56
+ chars-written regression gate so browser-side linearity is CI-pinned like the
57
+ parser's scaling shapes.
58
+
59
+ ### Performance
60
+
61
+ - **Open blocks apply the wire's splice instead of rebuilding.** The delta
62
+ signal (`keep_units`) the wire already computed was being discarded at the
63
+ client; it now reaches both renderers, which rebuild only the element the
64
+ splice lands in (fast path fires ~94% of syncs; any ambiguity falls back to
65
+ a full rebuild, so correctness never depends on the fast path).
66
+ - **Open code blocks reuse the streaming highlighter's frozen prefix**: the
67
+ frozen markup is appended once and never rewritten; only the bounded
68
+ speculative tail repaints. 20 KB highlighted stream: ~44 MB written → ~370 KB
69
+ (**~120× less**).
70
+ - **Keyed list/container sync in the DOM renderer** (`blockData` on): settled
71
+ `<li>`s / nested container children are never re-rendered — new items append,
72
+ only the open last item repaints; a tight→loose flip resyncs once, keyed by
73
+ `(index, html)` so it cannot be silently missed. Streamed 20 KB list: 284× →
74
+ **2.9×**; blockquote: 318× → **3.4×**.
75
+ - **React's keyed container path now engages by default.** It was accidentally
76
+ gated behind a `components` map (git history shows the gate was incidental);
77
+ a default-config streaming blockquote/alert now renders keyed — 22.6× and
78
+ growing → **3.4× flat**. React and DOM implementations were cross-checked to
79
+ byte-identical work counts.
80
+ - Known documented fallback: a streamed table with `blockData: false` still
81
+ takes the full-rebuild path (the splice refuses table tag chains — foster
82
+ parenting). `blockData: true` tables were already incremental (1.7×). A
83
+ table-scoped splice was measured (15% improvement) and rejected as not worth
84
+ the parser-divergence risk.
85
+
86
+ ### Fixed
87
+
88
+ - **Text selection and `<pre>` scroll survive streaming.** Selecting text in
89
+ the already-settled part of a streaming block no longer collapses on the
90
+ next token; a code block's horizontal scroll position is preserved. Both are
91
+ pinned by tests with negative controls.
92
+
93
+ ### Changed
94
+
95
+ - The DOM inside an open, streaming `<code>` element is now two spans (frozen +
96
+ speculative tail) while streaming; it settles to the same single-markup form
97
+ as before when the block closes. CSS that targets `code > *` structurally
98
+ may observe the difference mid-stream only. Settled output is unchanged.
99
+ - React's default open blockquote/alert omits inter-block whitespace text
100
+ nodes mid-stream (matching the DOM renderer's long-standing keyed behavior);
101
+ layout is unaffected and settle output is byte-identical.
102
+
7
103
  ## 0.27.0 — 2026-07-31
8
104
 
9
105
  ### Added
package/README.md CHANGED
@@ -586,6 +586,11 @@ class BrookClient {
586
586
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
587
587
  subscribe(listener: () => void): () => void; // React-friendly store
588
588
  getSnapshot(): Block[]; // ordered current blocks
589
+ getPersistable(source?: string): PersistableSnapshot; // capture the rendered doc as JSON
590
+ hydrate( // restore it: no worker, no parse
591
+ snapshot: PersistableSnapshot,
592
+ opts?: { source?: string }, // source ⇒ a live thread can resume
593
+ ): void;
589
594
  outline(): { level: number; text: string; id: number }[]; // heading table-of-contents (works mid-stream)
590
595
  toPlaintext(): string; // rendered document as plain text (search / summaries)
591
596
  getMetrics(): { bytes, patches, totalParseMs, throughputKBs,
@@ -1284,6 +1289,101 @@ WASM `BrookParser`, native bindings, C ABI) keep byte-identical v1 wire by
1284
1289
  default and can opt in with `setWireDelta(true)` — see
1285
1290
  [`WIRE.md` §11](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md).
1286
1291
 
1292
+ ## Instant thread reopen — persist and hydrate
1293
+
1294
+ Reopening a long thread normally means re-feeding its whole source through the
1295
+ parser before the first paint: O(history) work between the click and the pixels,
1296
+ which is exactly when a chat UI feels frozen.
1297
+
1298
+ It is also unnecessary. A committed block is emitted **once** and is **final**,
1299
+ so the blocks a stream already produced are a complete description of the
1300
+ document — there is nothing to re-derive. Capture them, store them, restore them:
1301
+
1302
+ ```ts
1303
+ // When the thread closes (or at any checkpoint):
1304
+ await db.put(threadId, JSON.stringify(client.getPersistable()));
1305
+
1306
+ // When it reopens:
1307
+ const client = new BrookClient();
1308
+ client.hydrate(JSON.parse(await db.get(threadId))); // no worker, no WASM, no parse
1309
+ // <BrookMarkdown client={client} /> paints the whole thread on the first frame.
1310
+ ```
1311
+
1312
+ Measured on a 1 MB / ~2250-block thread: **~5 ms to hydrate vs ~110 ms to
1313
+ re-stream** it in 2 KB chunks (~51 ms even for a single-shot re-parse) — before
1314
+ counting the worker spin-up and WASM init a re-stream also pays. Hydration is
1315
+ O(blocks) of JSON handling; nothing parses.
1316
+
1317
+ Hydrated blocks are **ordinary committed blocks**: both renderers mount them
1318
+ through the same path as live ones, so a reopened thread renders byte-identical
1319
+ to the one the user closed.
1320
+
1321
+ ### What to store, and when to throw it away
1322
+
1323
+ ```ts
1324
+ interface PersistableSnapshot {
1325
+ hydrateVersion: number; // envelope version; hydrate() refuses what it can't read
1326
+ blocks: Block[]; // exactly what getSnapshot() showed
1327
+ sourceLength: number; // UTF-16 length of the markdown behind them
1328
+ sourceHash: string; // fingerprint of that markdown — a staleness check
1329
+ done: boolean; // had the stream been finalized?
1330
+ }
1331
+ ```
1332
+
1333
+ Store it as JSON beside the thread. **Invalidate it when your source moves on**:
1334
+ hydrate deliberately does *not* verify the blocks against the source, so a stale
1335
+ snapshot would paint stale HTML. That is what `sourceHash` is for:
1336
+
1337
+ ```ts
1338
+ import { sourceFingerprint } from "brookmd";
1339
+
1340
+ if (snapshot.sourceHash === sourceFingerprint(mySource)) {
1341
+ client.hydrate(snapshot); // instant
1342
+ } else {
1343
+ client.setContent(mySource, { done: true }); // the text changed — re-stream
1344
+ }
1345
+ ```
1346
+
1347
+ Discard a snapshot whose `hydrateVersion` your build doesn't read, too. Either
1348
+ way a failed hydrate is safe to fall back from: a malformed or unreadable
1349
+ snapshot is rejected **whole**, with nothing written until every block has
1350
+ validated.
1351
+
1352
+ > This envelope is a brookmd **package** format, *not* the parser wire contract
1353
+ > ([`WIRE.md`](https://github.com/siinghd/brookmd/blob/main/crates/brookmd-core/WIRE.md)).
1354
+ > The two are versioned independently.
1355
+
1356
+ ### Resuming a thread that was still streaming
1357
+
1358
+ A `done: false` snapshot was captured mid-stream. Pass the original markdown as
1359
+ `source` and the thread simply continues:
1360
+
1361
+ ```ts
1362
+ client.hydrate(snapshot, { source: markdownSoFar });
1363
+ client.append(nextDelta); // or client.pipeFrom(await fetch("/api/chat"))
1364
+ ```
1365
+
1366
+ The parser's internal state cannot be serialized (it is an `Rc` graph, not
1367
+ data), so continuing a document genuinely requires re-parsing what came before
1368
+ it — but that cost moves **off the critical path**. On the first `append`
1369
+ brookmd spins up the worker, re-feeds the source in the background, and keeps
1370
+ the hydrated blocks on screen throughout: the reader scrolls and reads while the
1371
+ parse catches up. New chunks need no buffering — they queue behind the re-feed
1372
+ and land the moment it completes (they arrive at model token speed; the parser
1373
+ catches up far faster). When the re-parse lands, unchanged blocks are adopted
1374
+ **by reference and by id**, so nothing re-renders and nothing remounts at the
1375
+ swap.
1376
+
1377
+ A `done: true` snapshot is terminal: it needs no `source`, no worker is ever
1378
+ created for it, and `append()` throws — a finished thread cannot be extended
1379
+ (`reset()` to start a new one). A `done: false` snapshot hydrated *without*
1380
+ `source` is view-only: it paints, but appending throws, because a document
1381
+ cannot be continued correctly without the text preceding it.
1382
+
1383
+ Nothing on the hydrate path touches a worker: `whenReady()` resolves immediately
1384
+ and `client.ready` is `true`, so a readiness gate never sits over a thread that
1385
+ is already on screen.
1386
+
1287
1387
  ## Security
1288
1388
 
1289
1389
  brookmd is XSS-safe by default — its HTML output is meant to be injected via
package/dist/client.d.ts CHANGED
@@ -26,6 +26,50 @@ export interface OutlineEntry {
26
26
  /** Stable block id — usable as a scroll target / React key. */
27
27
  id: number;
28
28
  }
29
+ /**
30
+ * A self-contained, JSON-serializable capture of everything a stream currently
31
+ * renders — produced by {@link BrookClient.getPersistable}, restored by
32
+ * {@link BrookClient.hydrate}.
33
+ *
34
+ * Reopening a long thread normally re-feeds its whole source through the parser
35
+ * before the first paint (O(source), and it blocks the reopen). Persist this
36
+ * next to the thread instead and restoring it is pure JSON handling: the blocks
37
+ * go straight into the store and both renderers mount them as ordinary
38
+ * committed blocks — **no worker, no WASM, no parse**.
39
+ *
40
+ * NOT the wire contract. The parser↔renderer wire (WIRE.md) is a separate,
41
+ * separately-versioned boundary; this envelope is a brookmd *package* format
42
+ * that only {@link BrookClient.hydrate} consumes.
43
+ */
44
+ export interface PersistableSnapshot {
45
+ /** Envelope version. {@link BrookClient.hydrate} refuses anything it does not
46
+ * know how to read, so a stored snapshot never half-restores. */
47
+ hydrateVersion: number;
48
+ /**
49
+ * The blocks exactly as {@link BrookClient.getSnapshot} showed them at
50
+ * capture: the committed history plus whatever tail was on screen. Plain
51
+ * JSON — a `Block` carries no hidden state (the renderers' splice bookkeeping
52
+ * lives in a WeakMap, never on the block), so `JSON.stringify` round-trips it.
53
+ */
54
+ blocks: Block[];
55
+ /** UTF-16 length of the markdown that produced `blocks`. */
56
+ sourceLength: number;
57
+ /** {@link sourceFingerprint} of that markdown. A staleness check — "is the
58
+ * source I stored still the one these blocks came from?" — and deliberately
59
+ * nothing more: not a checksum, not security. */
60
+ sourceHash: string;
61
+ /** Whether the captured stream had been finalized. A `done` snapshot is a
62
+ * terminal document: it needs no source and can never be resumed. */
63
+ done: boolean;
64
+ }
65
+ /**
66
+ * 32-bit FNV-1a over a string's UTF-16 code units, as 8 hex digits. Backs
67
+ * {@link PersistableSnapshot.sourceHash}: cheap enough to run over a megabyte
68
+ * of markdown, sharp enough to notice that a stored source changed. Exported so
69
+ * a caller can make the same staleness decision brookmd does —
70
+ * `snap.sourceHash === sourceFingerprint(mySource)`. Not a security primitive.
71
+ */
72
+ export declare function sourceFingerprint(source: string): string;
29
73
  export declare function applyPatch(store: BlockStore, patch: Patch): void;
30
74
  interface PoolWorker {
31
75
  worker: WorkerLike;
@@ -171,6 +215,7 @@ export declare class BrookClient {
171
215
  private recoveryBuffer;
172
216
  private recoveredLen;
173
217
  private recoveryAttempted;
218
+ private appendedAny;
174
219
  private coalesce;
175
220
  private rafHandle;
176
221
  private finalizePending;
@@ -182,6 +227,12 @@ export declare class BrookClient {
182
227
  /** Set by mergeStale when it had to compact a hole out of the view, so
183
228
  * getSnapshot skips caching a view whose indices no longer track `base`. */
184
229
  private mergeDropped;
230
+ private hydrated;
231
+ private hydratedDone;
232
+ private hydratedLength;
233
+ private hydratedHash;
234
+ private hydratedSource;
235
+ private resumed;
185
236
  private appendedBytes;
186
237
  private patchCount;
187
238
  private totalParseMicros;
@@ -261,6 +312,10 @@ export declare class BrookClient {
261
312
  */
262
313
  private settleRebind;
263
314
  get ready(): boolean;
315
+ /** True while a hydrated document has never been handed to a parser: the
316
+ * store holds restored blocks and no worker exists. Cleared once the resume
317
+ * re-feed is issued (or the caller re-drives the whole document itself). */
318
+ private get hydrationPending();
264
319
  /**
265
320
  * The fatal error that killed this client's worker, or `null` if healthy.
266
321
  *
@@ -329,6 +384,90 @@ export declare class BrookClient {
329
384
  setContent(content: string, opts?: {
330
385
  done?: boolean;
331
386
  }): void;
387
+ /**
388
+ * Capture everything this client currently renders as a plain-JSON
389
+ * {@link PersistableSnapshot}: `JSON.stringify` it, store it beside the
390
+ * thread, and {@link hydrate} it back later to repaint with **no parse at
391
+ * all**. This is the persistence half of instant thread reopen.
392
+ *
393
+ * The committed wire is already a complete serialization — a committed block
394
+ * is emitted exactly once and is final (WIRE.md §2) — so there is nothing to
395
+ * re-derive: the snapshot IS the document. Cost here is one pass to
396
+ * fingerprint the source; cost on the way back in is a `Map` fill.
397
+ *
398
+ * @param source the markdown driven into this stream, used ONLY to compute
399
+ * {@link PersistableSnapshot.sourceHash}. Optional, because a client with
400
+ * `recovery` on (the default) already retains it, as does a
401
+ * `setContent`-driven one; required in the single configuration that holds
402
+ * neither — `recovery: false` plus manual `append()` — where omitting it
403
+ * throws rather than persisting a snapshot no one can check for staleness.
404
+ */
405
+ getPersistable(source?: string): PersistableSnapshot;
406
+ /** The full driven document when this client happens to hold it: the recovery
407
+ * buffer is exactly that whenever `recovery` is on (the default), and
408
+ * setContent's baseline covers the recovery-off controlled-string mode.
409
+ * `null` means genuinely unknown — reachable only with recovery off AND
410
+ * manual appends. */
411
+ private retainedSource;
412
+ /**
413
+ * Restore a {@link PersistableSnapshot} into an untouched client. The blocks
414
+ * land in the store as ordinary committed blocks and `getSnapshot()` returns
415
+ * them immediately, so the first paint already has the whole document —
416
+ * **no worker is created, no WASM loads, nothing is parsed**. Reopening a
417
+ * thread costs O(blocks) of JSON handling instead of O(source) of parsing.
418
+ *
419
+ * Call it on a fresh client before anything is appended and, ideally, before
420
+ * the renderer mounts (hydrating an already-mounted client works — it
421
+ * notifies subscribers — but costs an extra render). Hydrating a client that
422
+ * already holds content throws.
423
+ *
424
+ * **Resuming a live thread.** A snapshot with `done: false` was still
425
+ * streaming. Pass `source` — the markdown behind the snapshot — and the first
426
+ * {@link append} rebuilds parser state in the background: the parser's
427
+ * internals are an `Rc` graph with no serialized form, so continuing a
428
+ * document genuinely requires re-parsing what came before it, but that cost
429
+ * moves OFF the critical path. The hydrated blocks stay on screen and the
430
+ * reader scrolls them while the worker catches up; new chunks queue behind the
431
+ * re-feed and land the moment it does. Without `source` the thread is
432
+ * view-only and appending throws — continuing a document correctly is not
433
+ * possible without the text that precedes it.
434
+ *
435
+ * **Hydration does not verify the blocks against the source.** The snapshot is
436
+ * trusted as produced; `sourceHash` exists so the CALLER can notice its stored
437
+ * source moved on and re-stream instead of painting stale HTML. The resume
438
+ * path re-checks it, lets the fresh parse win, and warns in dev.
439
+ *
440
+ * @throws if the envelope version is unknown, the snapshot is malformed, or
441
+ * this client already holds content. Validation completes before anything is
442
+ * written, so a rejected snapshot leaves the client exactly as it was.
443
+ */
444
+ hydrate(snapshot: PersistableSnapshot, opts?: {
445
+ source?: string;
446
+ }): void;
447
+ /**
448
+ * The first content-bearing op on a hydrated thread: give the parser back the
449
+ * state it could not be handed.
450
+ *
451
+ * There is no way around re-parsing — the core keeps `Rc` graphs with no
452
+ * `Deserialize` — but every part of that cost sits off the critical path. The
453
+ * hydrated blocks are already painted and STAY painted: `softReset` preserves
454
+ * them exactly as the setContent divergence swap does, so the reader keeps
455
+ * reading and scrolling while the worker chews through the history on its own
456
+ * thread. The caller's new chunks need no buffer of ours — `postMessage` is
457
+ * FIFO per worker, so they queue behind the re-feed and are parsed the instant
458
+ * it catches up. When the re-parse's patch lands, `mergeStale` adopts every
459
+ * unchanged block BY REFERENCE (same object, same id), so the swap re-renders
460
+ * and remounts nothing, and the live tail streams on from there.
461
+ */
462
+ private beginResume;
463
+ /** The resume re-feed is no longer wanted: either it has just been issued, or
464
+ * the caller took over by driving the whole document itself. Releases the
465
+ * retained source so one document, not two, stays in memory. */
466
+ private retireHydration;
467
+ /** Drop every trace of a hydrate. An explicit reset() is a brand-new
468
+ * document, so a restored view must not keep intercepting appends or re-feed
469
+ * a source that is no longer the one being streamed. */
470
+ private clearHydration;
332
471
  reset(): void;
333
472
  /**
334
473
  * setContent's divergence reset: rebuild the parser exactly like {@link reset},