flux-md 0.7.0 → 0.9.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,68 @@ Notable changes to 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.9.0 — 2026-05-29
8
+
9
+ Kills the React streaming boilerplate. The common case — render an LLM stream —
10
+ goes from ~17 lines of hand-rolled lifecycle to one:
11
+
12
+ ```tsx
13
+ <FluxMarkdown stream={stream} />
14
+ ```
15
+
16
+ ### Added
17
+
18
+ - **`stream` prop on React `<FluxMarkdown>`** — pass an `AsyncIterable<string>`
19
+ (SSE deltas), a `Response`, or a `ReadableStream<Uint8Array>` and the
20
+ component owns an internal client, pipes the stream, supersedes it on change,
21
+ and destroys it on unmount. The `client` prop is unchanged (now optional);
22
+ passing a `client` keeps the existing caller-owned behavior.
23
+ - **`useFluxStream(stream, options?)` hook (React)** — same lifecycle, returns
24
+ the owned `FluxClient` (so you can read `outline()` / `getMetrics()` or pass it
25
+ to `<FluxMarkdown client={…} />`).
26
+ - **`pipeFrom` now also accepts an `AsyncIterable<string>`** and an optional
27
+ `{ signal }` — the signal is checked every iteration, so an aborted stream
28
+ appends no further chunks and is **not** finalized (and a byte reader is
29
+ `cancel()`'d). Existing `pipeFrom(Response | ReadableStream)` calls are
30
+ unchanged.
31
+
32
+ ### Notes
33
+
34
+ - A stream is single-use, so React StrictMode's dev-only double-mount may
35
+ truncate it in development; production mounts once and is unaffected (the
36
+ prior manual `useEffect` form had the same caveat).
37
+ - Rules of Hooks are respected — `<FluxMarkdown>` dispatches to one of two
38
+ sibling components, never a conditional hook.
39
+
40
+ ## 0.8.0 — 2026-05-29
41
+
42
+ A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
43
+ in the worker pool and added two small, streaming-native conveniences.
44
+
45
+ ### Added
46
+
47
+ - **`FluxClient.pipeFrom(src)`** — hand it a `Response` or a
48
+ `ReadableStream<Uint8Array>` and it reads the body, `append()`s each decoded
49
+ chunk, and `finalize()`s. The LLM-native one-liner:
50
+ `await client.pipeFrom(await fetch("/api/chat"))`.
51
+ - **`onBlock` option** — `new FluxClient({ onBlock })` fires once per block as it
52
+ commits (document order), for side effects like lazily highlighting a finished
53
+ code block or analytics. Committed blocks never re-fire.
54
+
55
+ ### Fixed
56
+
57
+ - **Worker pool: a throwing stream handler no longer breaks sibling streams.** A
58
+ user `onError` (or any handler) that threw could abort the fatal-error fan-out
59
+ mid-loop and escape the worker message listener; dispatch is now isolated.
60
+ - **Worker pool: a fatally-failed worker is no longer re-assigned.** `pick()`
61
+ skipped the `failed` flag, so after a WASM-init failure a new stream could be
62
+ routed onto the dead worker and hang (a client that didn't `await whenReady()`
63
+ had no safety net). Failed workers are now excluded from selection.
64
+ - **`<flux-markdown>`: manual `append()`/`finalize()` supersede an in-flight
65
+ `src` fetch** (mirroring `reset()`), so mixing the two can't interleave.
66
+ - Hardened the CI/publish tarball check (explicit failure if `npm pack` yields
67
+ no tarball) and documented the `htmlToText` core-HTML-only invariant.
68
+
7
69
  ## 0.7.0 — 2026-05-29
8
70
 
9
71
  DX, robustness, and accessibility round — the streaming core (perf, CommonMark
package/README.md CHANGED
@@ -52,33 +52,61 @@ for await (const delta of streamFromAi()) {
52
52
  client.finalize();
53
53
  ```
54
54
 
55
- In React:
55
+ In React — pass the stream straight to `<FluxMarkdown>`. It owns the client,
56
+ pipes the stream, supersedes it if it changes, and cleans up on unmount:
57
+
58
+ ```tsx
59
+ import { FluxMarkdown } from "flux-md/react";
60
+
61
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
62
+ return <FluxMarkdown stream={stream} />;
63
+ }
64
+ ```
65
+
66
+ `stream` accepts an `AsyncIterable<string>` (e.g. SSE deltas), a `Response`, or
67
+ a `ReadableStream<Uint8Array>` — so `<FluxMarkdown stream={await fetch("/api/chat")} />`
68
+ works too.
69
+
70
+ Need the client handle (for `outline()` / `getMetrics()` / a shared client)? Use
71
+ the `useFluxStream` hook — same lifecycle, returns the owned client:
72
+
73
+ ```tsx
74
+ import { FluxMarkdown, useFluxStream } from "flux-md/react";
75
+
76
+ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
77
+ const client = useFluxStream(stream);
78
+ return <FluxMarkdown client={client} />;
79
+ }
80
+ ```
81
+
82
+ <details>
83
+ <summary>Full manual control (caller-owned client)</summary>
84
+
85
+ When you want to drive the stream yourself, pass a `client` you own — the
86
+ component never destroys it:
56
87
 
57
88
  ```tsx
58
89
  import { useEffect, useState } from "react";
59
90
  import { FluxClient, FluxMarkdown } from "flux-md";
60
91
 
61
92
  export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
62
- // One client per component instance. Destroy on unmount, not on stream change.
63
93
  const [client] = useState(() => new FluxClient());
64
94
  useEffect(() => () => client.destroy(), [client]);
65
-
66
95
  useEffect(() => {
67
- let cancelled = false;
68
- (async () => {
69
- for await (const chunk of stream) {
70
- if (cancelled) return; // stream changed / unmounted mid-flight
71
- client.append(chunk);
72
- }
73
- if (!cancelled) client.finalize();
74
- })();
75
- return () => { cancelled = true; };
96
+ const ac = new AbortController();
97
+ client.pipeFrom(stream, { signal: ac.signal }); // pipeFrom also accepts AsyncIterable
98
+ return () => ac.abort();
76
99
  }, [client, stream]);
77
-
78
100
  return <FluxMarkdown client={client} />;
79
101
  }
80
102
  ```
81
103
 
104
+ </details>
105
+
106
+ > **StrictMode note:** a stream (SSE generator / `Response`) can be consumed only
107
+ > once, so React StrictMode's dev-only double-mount may truncate it in
108
+ > development. Production mounts once and is unaffected.
109
+
82
110
  Multiple concurrent streams just need multiple clients — each runs in its own worker, so they don't share main-thread budget.
83
111
 
84
112
  ## Framework bindings
@@ -262,8 +290,13 @@ class FluxClient {
262
290
  pool?: FluxPool;
263
291
  config?: ParserConfig;
264
292
  onError?: (err: { message: string; fatal?: boolean }) => void; // worker/parse + WASM-init errors
293
+ onBlock?: (block: Block) => void; // fires once per block as it commits
265
294
  });
266
295
  append(chunk: string): void; // queue text for parsing
296
+ pipeFrom( // read → append → finalize
297
+ src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
298
+ opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
299
+ ): Promise<void>;
267
300
  finalize(): void; // mark stream complete
268
301
  reset(): void; // wipe and reuse
269
302
  destroy(): void; // free this stream's parser
@@ -277,9 +310,18 @@ class FluxClient {
277
310
  }
278
311
  ```
279
312
 
313
+ `pipeFrom` is the LLM-native shortcut — hand it a `fetch` response and it
314
+ reads, appends, and finalizes for you:
315
+
316
+ ```ts
317
+ const client = new FluxClient();
318
+ await client.pipeFrom(await fetch("/api/chat")); // streams the body in, then finalizes
319
+ ```
320
+
280
321
  Pass `onError` to be notified of worker/parse errors and a fatal WASM-init
281
322
  failure (`{ fatal: true }`); without it, errors are only `console.error`'d and a
282
- load failure surfaces as a rejected `whenReady()`.
323
+ load failure surfaces as a rejected `whenReady()`. Pass `onBlock` to run a side
324
+ effect each time a block commits (e.g. lazy-highlight a finished code block).
283
325
 
284
326
  #### Per-stream config
285
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": ["./src/worker.ts"],
package/src/client.ts CHANGED
@@ -33,9 +33,11 @@ export interface OutlineEntry {
33
33
  }
34
34
 
35
35
  /** Strip tags (→ space) and decode the small entity set the core emits, then
36
- * collapse whitespace. The core's HTML is well-formed and escapes `>` inside
37
- * attributes, so the simple tag regex is safe here. `&amp;` decodes last so
38
- * `&amp;lt;` `&lt;`, not `<`. */
36
+ * collapse whitespace. INVARIANT: the simple `<[^>]*>` strip is only safe
37
+ * because every input here is HTML the Rust core produced via escape_html /
38
+ * escape_attr which escape `>` inside attribute values, so no `>` ever
39
+ * appears except as a real tag close. This must NOT be fed externally-authored
40
+ * HTML. `&amp;` decodes last so `&amp;lt;` → `&lt;`, not `<`. */
39
41
  function htmlToText(html: string): string {
40
42
  return html
41
43
  .replace(/<[^>]*>/g, " ")
@@ -126,6 +128,18 @@ export class FluxPool {
126
128
  }
127
129
  }
128
130
 
131
+ /** Inverse of {@link release}: re-register a stream's handler so it receives
132
+ * patches again. For React StrictMode's dev double-mount, which destroys a
133
+ * client on the simulated unmount and remounts the SAME instance. The worker
134
+ * lazily recreates the disposed parser on the next append. */
135
+ reattach(streamId: number, pw: PoolWorker, handler: (msg: FromWorker) => void): void {
136
+ if (!this.handlers.has(streamId)) {
137
+ pw.streamCount++;
138
+ pw.streamIds.add(streamId);
139
+ }
140
+ this.handlers.set(streamId, handler);
141
+ }
142
+
129
143
  send(pw: PoolWorker, msg: ToWorker): void {
130
144
  pw.worker.postMessage(msg);
131
145
  }
@@ -154,13 +168,17 @@ export class FluxPool {
154
168
  return this.workers.length;
155
169
  }
156
170
 
157
- // Create a new worker while under cap and every existing worker is busy;
158
- // otherwise attach to the least-loaded existing worker.
171
+ // Create a new worker while under cap and every live worker is busy; otherwise
172
+ // attach to the least-loaded LIVE worker. A fatally-failed worker is never
173
+ // handed out (a stream on it would post into a dead worker and hang) — it is
174
+ // retained only to reject outstanding whenWorkerReady waiters.
159
175
  private pick(): PoolWorker {
160
- if (this.workers.length < this.cap && this.workers.every((w) => w.streamCount > 0)) {
176
+ const live = this.workers.filter((w) => !w.failed);
177
+ if (this.workers.length < this.cap && live.every((w) => w.streamCount > 0)) {
161
178
  return this.create();
162
179
  }
163
- return this.workers.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
180
+ if (live.length === 0) return this.create();
181
+ return live.reduce((a, b) => (b.streamCount < a.streamCount ? b : a));
164
182
  }
165
183
 
166
184
  private create(): PoolWorker {
@@ -189,17 +207,34 @@ export class FluxPool {
189
207
  // A fatal (WASM-init) failure dooms every stream on this worker. Reject
190
208
  // anyone awaiting readiness, then notify each live stream's client so its
191
209
  // onError fires — the message carries no real streamId to route by. The
192
- // doomed worker stays in the pool: a later stream that pick()s it rejects
193
- // immediately via pw.failed (no auto-recovery — fine for a load failure).
210
+ // worker is kept only to reject those waiters; pick() never reuses it.
194
211
  const err = new Error(msg.message);
195
212
  pw.failed = err;
196
213
  const waiters = pw.readyWaiters;
197
214
  pw.readyWaiters = [];
198
- for (const w of waiters) w.reject(err);
199
- for (const sid of pw.streamIds) this.handlers.get(sid)?.(msg);
215
+ for (const w of waiters) {
216
+ try {
217
+ w.reject(err);
218
+ } catch {
219
+ /* a waiter's rejection handler is the caller's problem, not ours */
220
+ }
221
+ }
222
+ for (const sid of pw.streamIds) this.dispatch(sid, msg);
200
223
  return;
201
224
  }
202
- this.handlers.get(msg.streamId)?.(msg);
225
+ this.dispatch(msg.streamId, msg);
226
+ }
227
+
228
+ // Route a message to a stream's handler, isolating a throwing client callback
229
+ // (e.g. a user-supplied onError) so it can neither break the worker message
230
+ // loop nor starve sibling streams sharing this worker.
231
+ private dispatch(streamId: number, msg: FromWorker): void {
232
+ try {
233
+ this.handlers.get(streamId)?.(msg);
234
+ } catch (e) {
235
+ // eslint-disable-next-line no-console
236
+ console.error("flux: stream message handler threw", e);
237
+ }
203
238
  }
204
239
  }
205
240
 
@@ -249,6 +284,8 @@ export class FluxClient {
249
284
  private listeners = new Set<() => void>();
250
285
  private store: BlockStore = emptyBlockStore();
251
286
  private onError?: (err: { message: string; fatal?: boolean }) => void;
287
+ private onBlock?: (block: Block) => void;
288
+ private attached = true;
252
289
 
253
290
  // Perf
254
291
  private appendedBytes = 0;
@@ -267,17 +304,23 @@ export class FluxClient {
267
304
  * @param options.onError invoked on a worker/parse error or a fatal WASM-init
268
305
  * failure (`fatal: true`). Without it, errors are only `console.error`d and
269
306
  * a load failure surfaces solely as a rejected {@link FluxClient.whenReady}.
307
+ * @param options.onBlock invoked once per block as it commits (in document
308
+ * order, after the store updates) — for side effects like lazily
309
+ * highlighting a finished code block or analytics. A committed block never
310
+ * re-fires; the streaming tail does not (subscribe for live tail updates).
270
311
  */
271
312
  constructor(
272
313
  options: {
273
314
  pool?: FluxPool;
274
315
  config?: ParserConfig;
275
316
  onError?: (err: { message: string; fatal?: boolean }) => void;
317
+ onBlock?: (block: Block) => void;
276
318
  } = {},
277
319
  ) {
278
320
  this.pool = options.pool ?? getDefaultPool();
279
321
  this.config = options.config;
280
322
  this.onError = options.onError;
323
+ this.onBlock = options.onBlock;
281
324
  const { streamId, pw } = this.pool.acquire((msg) => this.onMessage(msg));
282
325
  this.streamId = streamId;
283
326
  this.pw = pw;
@@ -309,6 +352,77 @@ export class FluxClient {
309
352
  this.pool.send(this.pw, { type: "finalize", streamId: this.streamId, config: this.firstConfig() });
310
353
  }
311
354
 
355
+ /**
356
+ * Pipe a source straight in: read it to completion, `append()` each chunk,
357
+ * then `finalize()`. The LLM-native path — e.g.
358
+ * `await client.pipeFrom(await fetch("/api/chat"))`. Accepts:
359
+ * - a `Response` or its `ReadableStream<Uint8Array>` body (bytes; decoded
360
+ * with `TextDecoder({ stream: true })` so a multibyte sequence straddling
361
+ * a chunk boundary carries into the next read), or
362
+ * - an `AsyncIterable<string>` (e.g. an SSE delta generator) — string chunks
363
+ * appended verbatim.
364
+ *
365
+ * Pass `opts.signal` to supersede/cancel: the signal is checked on every
366
+ * iteration, so once aborted no further chunk is appended and **finalize is
367
+ * skipped** (a superseded stream must not finalize). For a byte source the
368
+ * reader is also `cancel()`'d to tear down the upstream. Resolves once
369
+ * finalized (or cleanly on abort); rejects if the source itself errors.
370
+ * Browser-only for byte sources (uses `TextDecoder`).
371
+ */
372
+ async pipeFrom(
373
+ source: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
374
+ opts?: { signal?: AbortSignal },
375
+ ): Promise<void> {
376
+ const signal = opts?.signal;
377
+
378
+ if (signal?.aborted) return; // already superseded before we started
379
+
380
+ // AsyncIterable<string> (SSE deltas, generators). Detected by elimination:
381
+ // a ReadableStream has `getReader`, a Response has `body` — neither here.
382
+ if (!("getReader" in source) && !("body" in source)) {
383
+ for await (const chunk of source as AsyncIterable<string>) {
384
+ if (signal?.aborted) return; // superseded/unmounted: drop late chunks, no finalize
385
+ this.append(chunk);
386
+ }
387
+ if (!signal?.aborted) this.finalize();
388
+ return;
389
+ }
390
+
391
+ // Byte source: a Response (use its body) or a ReadableStream directly.
392
+ const body = "body" in source ? source.body : source;
393
+ if (!body) {
394
+ // An empty Response body (e.g. 204) is a completed, empty stream.
395
+ this.finalize();
396
+ return;
397
+ }
398
+ const reader = body.getReader();
399
+ // A pending read() can't observe `aborted` until the next chunk; cancel()
400
+ // on abort tears down the upstream and resolves the pending read so the
401
+ // loop's post-read check fires and bails without finalizing.
402
+ const onAbort = () => {
403
+ reader.cancel().catch(() => {});
404
+ };
405
+ signal?.addEventListener("abort", onAbort, { once: true });
406
+ const decoder = new TextDecoder();
407
+ try {
408
+ for (;;) {
409
+ const { done, value } = await reader.read();
410
+ if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
411
+ if (done) break;
412
+ if (value) this.append(decoder.decode(value, { stream: true }));
413
+ }
414
+ this.append(decoder.decode()); // flush any trailing partial sequence
415
+ this.finalize();
416
+ } finally {
417
+ signal?.removeEventListener("abort", onAbort);
418
+ try {
419
+ reader.releaseLock();
420
+ } catch {
421
+ /* already released (e.g. by cancel) */
422
+ }
423
+ }
424
+ }
425
+
312
426
  reset() {
313
427
  this.store = emptyBlockStore();
314
428
  this.appendedBytes = 0;
@@ -324,9 +438,27 @@ export class FluxClient {
324
438
  }
325
439
 
326
440
  destroy() {
441
+ if (!this.attached) return; // idempotent
327
442
  // Free this stream's parser; the shared worker stays warm for siblings.
328
443
  this.pool.release(this.streamId, this.pw);
329
444
  this.listeners.clear();
445
+ this.attached = false;
446
+ }
447
+
448
+ /**
449
+ * Re-register with the pool after {@link destroy} so the client receives
450
+ * patches again. Needed only for React StrictMode's dev double-mount, where
451
+ * the renderer destroys on the simulated unmount then remounts the SAME
452
+ * client instance; apps don't normally call this. No-op if still attached.
453
+ */
454
+ reattach() {
455
+ if (this.attached) return;
456
+ this.pool.reattach(this.streamId, this.pw, (msg) => this.onMessage(msg));
457
+ this.attached = true;
458
+ // The worker discarded this stream's config on `dispose` (unlike `reset`,
459
+ // which keeps it), so re-send it on the next message — otherwise the parser
460
+ // would be rebuilt with library defaults (gfmMath / componentTags / … lost).
461
+ this.configSent = false;
330
462
  }
331
463
 
332
464
  subscribe = (fn: () => void) => {
@@ -397,6 +529,12 @@ export class FluxClient {
397
529
  this.patchCount += 1;
398
530
  this.lastPatchMs = performance.now();
399
531
  this.emit();
532
+ // After subscribers see the new snapshot, fire the per-block hook for
533
+ // anything that just committed (document order). A throw here is
534
+ // isolated by the pool's dispatch boundary and won't skip emit().
535
+ if (this.onBlock) {
536
+ for (const b of msg.patch.newly_committed) this.onBlock(b);
537
+ }
400
538
  break;
401
539
  case "error":
402
540
  if (this.onError) {
package/src/element.ts CHANGED
@@ -105,12 +105,17 @@ export function defineFluxMarkdown(tag = "flux-markdown"): void {
105
105
  // --- Self-owned-client methods -------------------------------------------
106
106
 
107
107
  append(chunk: string): void {
108
+ // Manual drive supersedes any in-flight `src` fetch (mixing the two is out
109
+ // of contract; this makes the manual stream win predictably instead of
110
+ // interleaving a late fetch chunk into it).
111
+ this.#cancelSrcStream();
108
112
  this.#ensureClient();
109
113
  this.#client!.append(chunk);
110
114
  }
111
115
 
112
116
  finalize(): void {
113
117
  // Only meaningful for a self-owned stream; a no-op if no client yet.
118
+ this.#cancelSrcStream();
114
119
  this.#client?.finalize();
115
120
  }
116
121
 
package/src/react.tsx CHANGED
@@ -1,6 +1,16 @@
1
- import { createElement, memo, useMemo, useSyncExternalStore, type CSSProperties } from "react";
1
+ import {
2
+ createElement,
3
+ memo,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ type CSSProperties,
10
+ } from "react";
2
11
  import type { Block, BlockComponentProps, Components } from "./types";
3
- import type { FluxClient } from "./client";
12
+ import { FluxClient } from "./client";
13
+ import type { ParserConfig } from "./types-core";
4
14
  import { CodeBlock } from "./renderers/CodeBlock";
5
15
  import { MathBlock } from "./renderers/Math";
6
16
  import { Mermaid } from "./renderers/Mermaid";
@@ -48,7 +58,23 @@ import { htmlToReact } from "./html-to-react";
48
58
  */
49
59
 
50
60
  interface FluxMarkdownProps {
51
- client: FluxClient;
61
+ /**
62
+ * A caller-owned client (you drive `append`/`finalize` and own its lifecycle —
63
+ * the component never destroys it). Mutually exclusive with `stream`; if both
64
+ * are given, `client` wins (a dev warning fires).
65
+ */
66
+ client?: FluxClient;
67
+ /**
68
+ * A stream to render directly — the 1-line common case. Pass a `Response`, a
69
+ * `ReadableStream<Uint8Array>`, or an `AsyncIterable<string>` (e.g. SSE
70
+ * deltas) and the component owns an internal client, pipes the stream, and
71
+ * destroys it on unmount. A new `stream` identity supersedes the old.
72
+ */
73
+ stream?: AsyncIterable<string> | ReadableStream<Uint8Array> | Response;
74
+ /** Parser config for the internally-created client (stream mode only). */
75
+ streamConfig?: ParserConfig;
76
+ /** Called if piping the `stream` rejects (the source errored). Not the worker error channel. */
77
+ onStreamError?: (err: Error) => void;
52
78
  components?: Components;
53
79
  /**
54
80
  * Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
@@ -77,7 +103,15 @@ interface FluxMarkdownProps {
77
103
  sanitize?: (html: string) => string;
78
104
  }
79
105
 
80
- function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanitize }: FluxMarkdownProps) {
106
+ // The original render path: subscribe to a (required, caller- or hook-owned)
107
+ // client and render its blocks. NEVER creates or destroys a client.
108
+ function FluxMarkdownFromClient({
109
+ client,
110
+ components,
111
+ virtualize,
112
+ stickToBottom,
113
+ sanitize,
114
+ }: FluxMarkdownProps & { client: FluxClient }) {
81
115
  const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
82
116
  // Normalize "no overrides" to a stable `undefined` so memo comparisons and
83
117
  // the fast path don't churn on an empty object identity.
@@ -92,6 +126,88 @@ function FluxMarkdownImpl({ client, components, virtualize, stickToBottom, sanit
92
126
  );
93
127
  }
94
128
 
129
+ /**
130
+ * Own a {@link FluxClient} for the lifetime of a component and drive it from a
131
+ * `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
132
+ * `AsyncIterable<string>`). Returns the client (read `outline()` / `getMetrics()`
133
+ * off it, or pass it to `<FluxMarkdown client={…} />`). The client is created
134
+ * once and destroyed on unmount; a new `stream` identity supersedes the old
135
+ * (the prior pipe is aborted, the parser is reset, the new stream is piped).
136
+ *
137
+ * Caveat (matches the manual `useEffect` form): a single-use stream — a
138
+ * `Response`/`ReadableStream`, or an async generator — can only be consumed
139
+ * once, so React **StrictMode**'s dev-only double-mount may truncate it in
140
+ * development. Production mounts once and is unaffected. If you need dev-exact
141
+ * streaming, drive a caller-owned client manually.
142
+ */
143
+ export function useFluxStream(
144
+ stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined,
145
+ options?: { config?: ParserConfig; onError?: (err: Error) => void },
146
+ ): FluxClient {
147
+ // One client per hook instance. (React StrictMode double-invokes this
148
+ // initializer in DEV, constructing a throwaway second client whose worker
149
+ // slot isn't reclaimed — a minor dev-only artifact; production runs it once.
150
+ // The committed client is what's used, and its lifecycle below is correct.)
151
+ const [client] = useState(() => new FluxClient({ config: options?.config }));
152
+ // Read onError through a ref so its identity never re-subscribes the stream.
153
+ const onErrorRef = useRef(options?.onError);
154
+ onErrorRef.current = options?.onError;
155
+ // Track the last stream so we reset() only on a genuine source change — never
156
+ // on a StrictMode replay of the same stream (which would discard its head).
157
+ const prevStream = useRef<typeof stream>(undefined);
158
+
159
+ // Own the client's pool attachment. On (re)mount, reattach (StrictMode's
160
+ // dev double-mount destroys on the simulated unmount, then remounts the SAME
161
+ // instance — without reattach its patches would be dropped and it'd render
162
+ // blank); destroy on real unmount.
163
+ useEffect(() => {
164
+ client.reattach();
165
+ return () => client.destroy();
166
+ }, [client]);
167
+
168
+ // Consume the current stream; supersede (abort, no finalize) on change/unmount.
169
+ useEffect(() => {
170
+ if (stream == null) return;
171
+ const ac = new AbortController();
172
+ if (prevStream.current !== undefined && prevStream.current !== stream) {
173
+ client.reset(); // a different stream replaced a prior one
174
+ }
175
+ prevStream.current = stream;
176
+ client.pipeFrom(stream, { signal: ac.signal }).catch((e) => {
177
+ if (!ac.signal.aborted) {
178
+ onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
179
+ }
180
+ });
181
+ return () => ac.abort();
182
+ }, [stream, client]);
183
+
184
+ return client;
185
+ }
186
+
187
+ // Stream mode: own a client via the hook, then render the normal client path.
188
+ function FluxMarkdownFromStream(props: FluxMarkdownProps) {
189
+ const client = useFluxStream(props.stream, {
190
+ config: props.streamConfig,
191
+ onError: props.onStreamError,
192
+ });
193
+ return <FluxMarkdownFromClient {...props} client={client} />;
194
+ }
195
+
196
+ // Dispatch by rendering one of two SIBLING components (never a hook in a branch,
197
+ // which would violate the Rules of Hooks): `stream` mode owns a client, `client`
198
+ // mode uses the caller's. `memo` skips re-render when props are unchanged. If
199
+ // both are given `client` wins (it owns the lifecycle); passing neither is a
200
+ // usage error and throws (rather than crashing cryptically downstream).
201
+ function FluxMarkdownImpl(props: FluxMarkdownProps) {
202
+ if (props.stream != null && props.client == null) {
203
+ return <FluxMarkdownFromStream {...props} />;
204
+ }
205
+ if (props.client == null) {
206
+ throw new Error("<FluxMarkdown>: pass either a `client` or a `stream` prop.");
207
+ }
208
+ return <FluxMarkdownFromClient {...(props as FluxMarkdownProps & { client: FluxClient })} />;
209
+ }
210
+
95
211
  export const FluxMarkdown = memo(FluxMarkdownImpl);
96
212
 
97
213
  function decodeEntities(s: string): string {
Binary file
@@ -2,7 +2,7 @@
2
2
  "name": "flux-md-core",
3
3
  "type": "module",
4
4
  "description": "Incremental, streaming-aware markdown parser with speculative closure",
5
- "version": "0.7.0",
5
+ "version": "0.9.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",