flux-md 0.8.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,39 @@ 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
+
7
40
  ## 0.8.0 — 2026-05-29
8
41
 
9
42
  A self-review of 0.7.0 (adversarial multi-agent pass) fixed two robustness gaps
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
@@ -265,7 +293,10 @@ class FluxClient {
265
293
  onBlock?: (block: Block) => void; // fires once per block as it commits
266
294
  });
267
295
  append(chunk: string): void; // queue text for parsing
268
- pipeFrom(src: ReadableStream<Uint8Array> | Response): Promise<void>; // read → append → finalize
296
+ pipeFrom( // read → append → finalize
297
+ src: ReadableStream<Uint8Array> | Response | AsyncIterable<string>,
298
+ opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
299
+ ): Promise<void>;
269
300
  finalize(): void; // mark stream complete
270
301
  reset(): void; // wipe and reuse
271
302
  destroy(): void; // free this stream's parser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.8.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
@@ -128,6 +128,18 @@ export class FluxPool {
128
128
  }
129
129
  }
130
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
+
131
143
  send(pw: PoolWorker, msg: ToWorker): void {
132
144
  pw.worker.postMessage(msg);
133
145
  }
@@ -273,6 +285,7 @@ export class FluxClient {
273
285
  private store: BlockStore = emptyBlockStore();
274
286
  private onError?: (err: { message: string; fatal?: boolean }) => void;
275
287
  private onBlock?: (block: Block) => void;
288
+ private attached = true;
276
289
 
277
290
  // Perf
278
291
  private appendedBytes = 0;
@@ -340,15 +353,42 @@ export class FluxClient {
340
353
  }
341
354
 
342
355
  /**
343
- * Pipe a byte stream straight in: read it to completion, `append()` each
344
- * decoded chunk, then `finalize()`. The LLM-native path — `await
345
- * client.pipeFrom(await fetch("/api/chat"))` (pass a `Response` or its
346
- * `ReadableStream` body). `TextDecoder({ stream: true })` carries a multibyte
347
- * sequence that straddles a chunk boundary into the next read. Resolves once
348
- * finalized; rejects (without finalizing) if the stream errors/aborts — abort
349
- * the underlying fetch to cancel. Browser-only (uses `TextDecoder`).
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`).
350
371
  */
351
- async pipeFrom(source: ReadableStream<Uint8Array> | Response): Promise<void> {
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.
352
392
  const body = "body" in source ? source.body : source;
353
393
  if (!body) {
354
394
  // An empty Response body (e.g. 204) is a completed, empty stream.
@@ -356,17 +396,30 @@ export class FluxClient {
356
396
  return;
357
397
  }
358
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 });
359
406
  const decoder = new TextDecoder();
360
407
  try {
361
408
  for (;;) {
362
409
  const { done, value } = await reader.read();
410
+ if (signal?.aborted) return; // superseded: no finalize (cancel already fired)
363
411
  if (done) break;
364
412
  if (value) this.append(decoder.decode(value, { stream: true }));
365
413
  }
366
414
  this.append(decoder.decode()); // flush any trailing partial sequence
367
415
  this.finalize();
368
416
  } finally {
369
- reader.releaseLock();
417
+ signal?.removeEventListener("abort", onAbort);
418
+ try {
419
+ reader.releaseLock();
420
+ } catch {
421
+ /* already released (e.g. by cancel) */
422
+ }
370
423
  }
371
424
  }
372
425
 
@@ -385,9 +438,27 @@ export class FluxClient {
385
438
  }
386
439
 
387
440
  destroy() {
441
+ if (!this.attached) return; // idempotent
388
442
  // Free this stream's parser; the shared worker stays warm for siblings.
389
443
  this.pool.release(this.streamId, this.pw);
390
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;
391
462
  }
392
463
 
393
464
  subscribe = (fn: () => void) => {
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.8.0",
5
+ "version": "0.9.0",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "flux_md_core_bg.wasm",