flux-md 0.11.0 → 0.13.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,70 @@ 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.13.0 — 2026-06-04
8
+
9
+ ### Added
10
+
11
+ - **`FluxClient.setContent(content, { done })` + controlled-string helpers for
12
+ every binding** — a first-class bridge for UIs that hold a streaming message as
13
+ a single growing/controlled string prop (rather than a stream). setContent diffs
14
+ against the last value: a **prefix-extension** appends only the delta (committed
15
+ blocks stay put); any **divergence** (e.g. a finished message swapped for a
16
+ re-processed final string) resets and reparses. No hand-rolled diff, no
17
+ readiness gate. Pass `{ done: true }` / `streaming: false` to finalize. The
18
+ framework-neutral `setContent` is wrapped by an idiomatic, client-owning helper
19
+ per framework — React `useFluxMarkdownString`, Vue `useFluxMarkdownString`
20
+ (composable), Solid `createFluxMarkdownString`, Svelte `fluxMarkdownString`
21
+ (action) — each SSR-safe (feeds only in the client-only lifecycle hook). Vanilla
22
+ / `<flux-markdown>` use a caller-owned client + `setContent` directly.
23
+ - **`FluxPool.warm()`** — eagerly initialize one worker (`getDefaultPool().warm()`
24
+ on app load) so the one-time WASM init is off the first-token critical path; the
25
+ warm worker is the one the first stream attaches to, so the work isn't wasted.
26
+ - **Custom-component & `sanitize` overrides now apply to the OPEN (streaming)
27
+ block**, not just settled ones — a design-system renderer (Tailwind classes on
28
+ `p`/`ul`/`li`, inline `<a>`/`<code>` overrides) stays styled mid-stream instead
29
+ of only after a block commits. This also closes a gap where a supplied
30
+ `sanitize` previously bypassed component-rendered blocks; it now runs on every
31
+ block. The no-`components` path is unchanged (byte-identical `innerHTML`).
32
+
33
+ ### Fixed
34
+
35
+ - **Worker no longer drops the first chunk(s) under a slow WASM load.** The
36
+ worker buffered appends but did not gate parser creation on WASM readiness, so
37
+ an append that arrived before `init()` resolved would call `new FluxParser()`
38
+ against an uninitialized module — throwing `fluxparser_new of undefined` and
39
+ silently losing that chunk. Appends now accumulate (and `finalize` defers)
40
+ until init completes, then drain in order. Surfaced on a fresh Next.js /
41
+ Turbopack production load, where the worker+WASM fetch is slow enough to lose
42
+ the race; the fix is bundler-agnostic. The worker's message/readiness state
43
+ machine was extracted to `worker-core.ts` (dependency-injected, like
44
+ `FluxPool`'s worker factory) and now has a unit test (`worker-core.test.ts`)
45
+ covering the gate — buffer-until-ready, drain order, finalize/reset before
46
+ ready — so the regression can't silently return.
47
+ - **React 19 / Next.js type compatibility.** The shipped source used the global
48
+ `JSX.Element`, which React 19's `@types/react` removed — a consumer's
49
+ `next build` type-checks flux-md's source (it ships as `.tsx`) and failed with
50
+ *"Cannot find namespace 'JSX'"*. Now uses `ReactElement`, which type-checks
51
+ under `@types/react` 18 **and** 19.
52
+
53
+ ### Docs
54
+
55
+ - **Next.js (App Router) is now documented and verified** (Turbopack + webpack,
56
+ Next.js 16, `next dev` and `next build`): add flux-md to `transpilePackages`
57
+ and use it from a `"use client"` component. See the README's Next.js callout.
58
+
59
+ ## 0.12.0 — 2026-05-30
60
+
61
+ ### Added
62
+
63
+ - **Optional default theme — `import "flux-md/styles.css"`.** A drop-in stylesheet
64
+ for good-looking output out of the box, **including the built-in syntax
65
+ highlighter's colors** (without any CSS, `highlight()` output is uncolored).
66
+ Scoped to `.flux-md`, driven by `--flux-*` CSS variables (re-theme by overriding
67
+ a few), light by default with automatic dark via `prefers-color-scheme` (force
68
+ with `class="flux-md flux-dark"` / `flux-light`). Opt-in and zero-runtime — the
69
+ rendered HTML is unchanged; skip the import to bring your own CSS.
70
+
7
71
  ## 0.11.0 — 2026-05-30
8
72
 
9
73
  ### Added
package/README.md CHANGED
@@ -16,9 +16,9 @@ flux-md ships as **source** (TypeScript + the compiled WASM). The worker and
16
16
  WASM asset are referenced with the **web-standard `new URL(asset,
17
17
  import.meta.url)`** pattern, so any bundler with asset-module support resolves
18
18
  them: **Vite** (the reference setup), **webpack 5**, **Rollup** (with asset
19
- modules), and **Parcel**. Next.js (webpack/turbopack) should work but is
20
- untested file an issue if it doesn't. It is **browser-only** (it constructs
21
- Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
19
+ modules), **Parcel**, and **Next.js** (App Router Turbopack *and* webpack;
20
+ **verified on Next.js 16**, see the [Next.js callout](#nextjs) below). It is
21
+ **browser-only** (it constructs Web Workers); it does not run under SSR/RSC. The framework packages — `react`,
22
22
  `vue`, `svelte`, `solid-js` — are all **optional** peer dependencies; you only
23
23
  need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
24
24
  `flux-md/dom`, `flux-md/element`) needs none.
@@ -37,6 +37,54 @@ need the one whose binding you import. The core (`flux-md`, `flux-md/client`,
37
37
  >
38
38
  > No other bundler needs this — it's specific to Vite's optimizer.
39
39
 
40
+ <a id="nextjs"></a>
41
+
42
+ > **Next.js (App Router) — two requirements.** Verified on **Next.js 16** with
43
+ > **Turbopack** (the default for both `next dev` and `next build`). The same two
44
+ > requirements apply under webpack. Because flux-md ships TypeScript source:
45
+ >
46
+ > 1. **Transpile the package.** Next does not compile `node_modules` TypeScript
47
+ > by default — without this, Turbopack errors with *"Unknown module type"* on
48
+ > `react.tsx`. Add flux-md to `transpilePackages`:
49
+ >
50
+ > ```ts
51
+ > // next.config.ts
52
+ > import type { NextConfig } from "next";
53
+ > const nextConfig: NextConfig = { transpilePackages: ["flux-md"] };
54
+ > export default nextConfig;
55
+ > ```
56
+ >
57
+ > 2. **Use it from a Client Component.** `<FluxMarkdown>` uses React hooks (and
58
+ > spawns a Web Worker on mount), so it must carry `"use client"` — it can't be
59
+ > a Server Component. (It is still SSR-safe: on the server it renders an empty
60
+ > shell and only starts streaming after hydration, so there's no SSR crash —
61
+ > the constraint is hooks, not the worker.)
62
+ >
63
+ > ```tsx
64
+ > "use client";
65
+ > import { FluxMarkdown } from "flux-md/react";
66
+ >
67
+ > export default function Answer({ stream }: { stream: AsyncIterable<string> }) {
68
+ > return <FluxMarkdown stream={stream} />;
69
+ > }
70
+ > ```
71
+ >
72
+ > **Create the `stream` in Client Component code, not in a Server Component.**
73
+ > A `Response` / `ReadableStream` / `AsyncIterable` isn't serializable, so it
74
+ > can't be passed as a prop from a Server Component (e.g. `page.tsx`) — that
75
+ > throws *"Only plain objects can be passed to Client Components."* Pass a
76
+ > serializable prop (a URL, the chat messages) from the server and open the
77
+ > stream on the client — e.g. `stream={await fetch("/api/chat")}` from a client
78
+ > effect, or the `useFluxStream` hook (see [Quick start](#quick-start)).
79
+ >
80
+ > That's it — Turbopack bundles the worker and emits the `.wasm` to
81
+ > `_next/static/media` itself, so no extra asset/loader config is needed (and the
82
+ > Vite `optimizeDeps` workaround above does **not** apply). Both `next dev` and
83
+ > `next build && next start` are verified to spawn the worker, load the WASM, and
84
+ > stream markdown. _Dev tip:_ open the app on `localhost` — Next dev blocks
85
+ > cross-origin dev resources (HMR, chunks) from other hosts (e.g. `127.0.0.1`)
86
+ > unless you add them to `allowedDevOrigins` in `next.config`.
87
+
40
88
  ## Quick start
41
89
 
42
90
  ```ts
@@ -79,6 +127,38 @@ export function ChatMessage({ stream }: { stream: AsyncIterable<string> }) {
79
127
  }
80
128
  ```
81
129
 
130
+ ### Already holding a growing string? — `useFluxMarkdownString`
131
+
132
+ Many apps keep the streaming message as a **single growing string prop** (it
133
+ re-renders with the full text-so-far each token), not as a stream. Feed that
134
+ string straight in — `useFluxMarkdownString` diffs it for you and forwards only
135
+ the delta, so you don't hand-roll an append/reset bridge:
136
+
137
+ ```tsx
138
+ import { FluxMarkdown, useFluxMarkdownString } from "flux-md/react";
139
+
140
+ export function ChatMessage({ text, streaming }: { text: string; streaming: boolean }) {
141
+ const client = useFluxMarkdownString(text, { streaming });
142
+ return <FluxMarkdown client={client} />;
143
+ }
144
+ ```
145
+
146
+ It handles the two shapes a controlled string takes: a **prefix-extension** (the
147
+ common token-by-token growth) appends only the new suffix; a **divergence** (e.g.
148
+ the finished text swapped for a re-processed final string — bolded numbers,
149
+ wrapped tickers) resets and reparses. Pass `streaming: false` once the content is
150
+ final so the last block commits (a finished code fence then highlights). The
151
+ framework-neutral primitive is **`client.setContent(fullString, { done })`** —
152
+ use it from any binding.
153
+
154
+ > **Transforming streamed content?** If the enrichment runs **live per token**
155
+ > (e.g. bold every number as it arrives), do it at **render time** via
156
+ > [`components`](#custom-components--overrides) — keep the markdown source
157
+ > append-only so parsing stays incremental. Re-transforming the *whole* string
158
+ > each token (so earlier bytes change) forces `setContent` to reparse every tick
159
+ > (O(n²)); that's what render-time overrides avoid. `setContent`'s reset path is
160
+ > for the **once**-at-the-end reprocess swap, not per-token rewrites.
161
+
82
162
  <details>
83
163
  <summary>Full manual control (caller-owned client)</summary>
84
164
 
@@ -150,6 +230,12 @@ handle.destroy();
150
230
  client.destroy();
151
231
  ```
152
232
 
233
+ **Already holding a growing string?** There's no framework reactivity to wrap,
234
+ so just call **`client.setContent(fullString, { done })`** instead of the
235
+ `append` loop — it diffs internally (prefix → delta; divergence → reparse) and
236
+ finalizes on `done`. That's the same primitive the React/Vue/Svelte/Solid
237
+ controlled-string helpers wrap; in vanilla you call it directly.
238
+
153
239
  `mountFluxMarkdown(client, container, options?)` returns `{ destroy(), refresh() }`.
154
240
  Options: `components`, `sanitize`, `virtualize`, `stickToBottom`, `highlightCode`
155
241
  (default true), `batch` (default true — one DOM write per `requestAnimationFrame`).
@@ -208,6 +294,13 @@ defineFluxMarkdown(); // once at bootstrap
208
294
  export class Answer { url = "/api/post.md"; }
209
295
  ```
210
296
 
297
+ **Controlled growing string?** Assign a caller-owned client and drive it with
298
+ `setContent` — `el.client = myClient; myClient.setContent(fullString, { done })`
299
+ — the element subscribes and renders, you own the diffing. (The self-owned
300
+ `markdown` attribute is **one-shot** — it re-parses the whole document on each
301
+ change, so don't point it at a per-token-growing string; use a client +
302
+ `setContent` for that.)
303
+
211
304
  ### Vue 3 — `flux-md/vue`
212
305
 
213
306
  ```vue
@@ -230,6 +323,20 @@ Props: `client` (required), `components`, `sanitize`, `virtualize`,
230
323
  `stickToBottom`. There's also a `useFluxMarkdown` composable returning a
231
324
  `container` ref if you'd rather mount into your own element.
232
325
 
326
+ **Already holding a growing string?** `useFluxMarkdownString` owns a client and
327
+ diffs the string for you (the Vue analogue of the React hook — see
328
+ [Controlled strings](#already-holding-a-growing-string--usefluxmarkdownstring)):
329
+
330
+ ```vue
331
+ <script setup lang="ts">
332
+ import { FluxMarkdown, useFluxMarkdownString } from "flux-md/vue";
333
+ const props = defineProps<{ text: string; streaming: boolean }>();
334
+ // Pass getters so the composable tracks the live values; it owns + destroys the client.
335
+ const client = useFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
336
+ </script>
337
+ <template><FluxMarkdown :client="client" /></template>
338
+ ```
339
+
233
340
  ### Svelte (4 & 5) — `flux-md/svelte`
234
341
 
235
342
  A Svelte action — works in both v4 and v5, no `.svelte` build step:
@@ -248,6 +355,20 @@ A Svelte action — works in both v4 and v5, no `.svelte` build step:
248
355
  <div use:fluxMarkdown={{ client, stickToBottom: true }} />
249
356
  ```
250
357
 
358
+ **Growing string?** The `fluxMarkdownString` action owns a client and diffs the
359
+ string — `use:fluxMarkdownString={{ content, streaming }}` (it destroys its
360
+ client on `destroy`, so no manual cleanup):
361
+
362
+ ```svelte
363
+ <script lang="ts">
364
+ import { fluxMarkdownString } from "flux-md/svelte";
365
+ export let content: string; // the growing message
366
+ export let streaming: boolean; // false once complete → finalizes
367
+ </script>
368
+
369
+ <div use:fluxMarkdownString={{ content, streaming, stickToBottom: true }} />
370
+ ```
371
+
251
372
  ### Solid — `flux-md/solid`
252
373
 
253
374
  ```tsx
@@ -262,6 +383,19 @@ onCleanup(() => client.destroy());
262
383
  <FluxMarkdown client={client} stickToBottom />;
263
384
  ```
264
385
 
386
+ **Growing string?** `createFluxMarkdownString` owns a client and diffs the string
387
+ (the Solid analogue of the React hook), driving `setContent` from a
388
+ `createEffect` and destroying the client on cleanup:
389
+
390
+ ```tsx
391
+ import { FluxMarkdown, createFluxMarkdownString } from "flux-md/solid";
392
+
393
+ function Message(props: { text: string; streaming: boolean }) {
394
+ const client = createFluxMarkdownString(() => props.text, () => ({ streaming: props.streaming }));
395
+ return <FluxMarkdown client={client} />;
396
+ }
397
+ ```
398
+
265
399
  The Solid binding's mount/teardown logic is tested, but its JSX component shell
266
400
  has so far only been exercised through a real Solid (`vite-plugin-solid`) build
267
401
  in development, not in CI — treat it as the newest of the bindings and file an
@@ -280,6 +414,32 @@ if you hit a transform edge, `mountFluxMarkdown` from `flux-md/dom` inside
280
414
  | Heavy renderers (syntax, math, mermaid) | Deferred until block close | Re-run per chunk |
281
415
  | XSS sanitization | Allowlist in Rust + URL scheme check | Downstream sanitizer pass on the JS thread |
282
416
 
417
+ ## Styling
418
+
419
+ flux-md emits semantic HTML under a `.flux-md` root and **ships no CSS by
420
+ default** — bring your own design system, or opt into the bundled theme:
421
+
422
+ ```ts
423
+ import "flux-md/styles.css";
424
+ ```
425
+
426
+ It gives good-looking output out of the box, **including the built-in syntax
427
+ highlighter's colors** (without any CSS, `highlight()` renders uncolored). The
428
+ theme is scoped to `.flux-md`, zero-runtime, and **does not change the rendered
429
+ HTML** — skip the import and nothing is styled.
430
+
431
+ Re-theme by overriding a few CSS variables; it's light by default and switches to
432
+ dark automatically via `prefers-color-scheme` (force a mode with
433
+ `class="flux-md flux-dark"` or `flux-light`):
434
+
435
+ ```css
436
+ .flux-md {
437
+ --flux-accent: #7c3aed; /* links */
438
+ --flux-bg-code: #faf7ff; /* code background */
439
+ --flux-t-kw: #c026d3; /* syntax: keywords (also --flux-t-str/num/com/fn/ty/…) */
440
+ }
441
+ ```
442
+
283
443
  ## Public API
284
444
 
285
445
  ### `FluxClient`
@@ -298,6 +458,10 @@ class FluxClient {
298
458
  opts?: { signal?: AbortSignal }, // abort to supersede (no finalize)
299
459
  ): Promise<void>;
300
460
  finalize(): void; // mark stream complete
461
+ setContent( // drive from a controlled full string
462
+ full: string, // diffs vs last: prefix → append delta; else reset+reparse
463
+ opts?: { done?: boolean }, // done:true → finalize
464
+ ): void;
301
465
  reset(): void; // wipe and reuse
302
466
  destroy(): void; // free this stream's parser
303
467
  whenReady(): Promise<void>; // resolves once WASM loaded; rejects on init failure
@@ -476,11 +640,15 @@ Rules worth knowing:
476
640
  channel](#structured-block-data-setblockdata)** (`blockData: true`) and read
477
641
  `block.kind.data` (and the typed `props.table` / `heading` / `code` / `math` /
478
642
  `list` fields) directly — no HTML re-parsing.
479
- - **Open (streaming) blocks render via `innerHTML`** their HTML is still
480
- partial, so a tag-level override takes effect the moment the block commits.
643
+ - **Overrides apply to the OPEN (streaming) block too**, not just settled ones
644
+ so a design-system renderer (Tailwind classes on `p`/`ul`/`li`, inline
645
+ `<a>`/`<code>` overrides) stays styled mid-stream. The tail's HTML is always
646
+ well-formed (the parser speculatively closes it). If a `sanitize` is supplied
647
+ it runs first, on every block.
481
648
  - **No `components` prop ⇒ the original fast path** (`innerHTML`, byte-identical
482
- output). The HTML→React conversion only runs for closed blocks when you
483
- actually supply overrides, and is memoized per `(block id, html)`.
649
+ output). The HTML→React conversion runs only when you actually supply
650
+ overrides, and is memoized per `(block id, html)` so committed blocks don't
651
+ re-parse as the stream grows.
484
652
  - For **code blocks** the built-in highlighter is the default; it is bypassed
485
653
  (so your override wins) when you pass `components.CodeBlock`, `components.pre`,
486
654
  or `components.code`.
@@ -710,6 +878,16 @@ teardown? Construct your own `new FluxPool(factory, cap)` and pass it to
710
878
  **per-page singleton** — don't rely on it in SSR/RSC. For isolation between
711
879
  independent feature areas, give each its own `new FluxPool()`.
712
880
 
881
+ **Warm the pool to hide WASM init.** The one-time WASM load happens on the first
882
+ worker-bound op, which lands on the first-token critical path. Call
883
+ `getDefaultPool().warm()` on app load / route entry to start it early — the warm
884
+ worker is the one the first stream attaches to, so the init isn't wasted:
885
+
886
+ ```ts
887
+ import { getDefaultPool } from "flux-md";
888
+ useEffect(() => { getDefaultPool().warm(); }, []); // (or your framework's mount hook)
889
+ ```
890
+
713
891
  ### Long documents — `virtualize`
714
892
 
715
893
  For very long documents (hundreds+ of blocks), pass `virtualize` to apply CSS
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "flux-md",
3
- "version": "0.11.0",
3
+ "version": "0.13.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
- "sideEffects": ["./src/worker.ts"],
6
+ "sideEffects": ["./src/worker.ts", "./src/styles.css"],
7
7
  "main": "./src/index.ts",
8
8
  "types": "./src/index.ts",
9
9
  "exports": {
@@ -16,7 +16,8 @@
16
16
  "./svelte": "./src/svelte.ts",
17
17
  "./solid": "./src/solid.tsx",
18
18
  "./highlight": "./src/hi.ts",
19
- "./types": "./src/types.ts"
19
+ "./types": "./src/types.ts",
20
+ "./styles.css": "./src/styles.css"
20
21
  },
21
22
  "files": [
22
23
  "src",
package/src/client.ts CHANGED
@@ -151,6 +151,21 @@ export class FluxPool {
151
151
  return new Promise((resolve, reject) => pw.readyWaiters.push({ resolve, reject }));
152
152
  }
153
153
 
154
+ /**
155
+ * Eagerly spin up one worker so WASM init starts BEFORE the first stream —
156
+ * taking the one-time init off the first-token critical path (e.g. call
157
+ * `getDefaultPool().warm()` on app load / route entry). Reuses a live worker
158
+ * if one exists; the warm worker is the one the first stream attaches to (it
159
+ * has spare capacity), so the work is not wasted. Resolves when that worker has
160
+ * finished initializing WASM; rejects if init fails fatally. Browser-only (it
161
+ * constructs a `Worker`).
162
+ */
163
+ warm(): Promise<void> {
164
+ const live = this.workers.filter((w) => !w.failed);
165
+ const pw = live[0] ?? this.create();
166
+ return this.whenWorkerReady(pw);
167
+ }
168
+
154
169
  /** Terminate every worker (test teardown / full shutdown). */
155
170
  disposeAll(): void {
156
171
  for (const pw of this.workers) {
@@ -286,6 +301,11 @@ export class FluxClient {
286
301
  private onError?: (err: { message: string; fatal?: boolean }) => void;
287
302
  private onBlock?: (block: Block) => void;
288
303
  private attached = true;
304
+ // Diff baseline for setContent(): the full string fed in so far, and whether
305
+ // it has been finalized. Cleared by reset()/reattach() (the worker drops the
306
+ // parser there, so the baseline is stale and the document must be re-fed).
307
+ private lastContent = "";
308
+ private contentDone = false;
289
309
 
290
310
  // Perf
291
311
  private appendedBytes = 0;
@@ -446,6 +466,53 @@ export class FluxClient {
446
466
  }
447
467
  }
448
468
 
469
+ /**
470
+ * Drive the parser from a CONTROLLED full string instead of manual appends.
471
+ * Pass the whole document-so-far each time; setContent diffs it against the
472
+ * last value and does the minimal work:
473
+ * - **prefix-extension** (the streaming-growth case) → append only the new
474
+ * suffix, so committed blocks stay put and only the active tail re-parses;
475
+ * - **any other change** (e.g. a finished stream swapped for a re-processed
476
+ * final string) → `reset()` + reparse the whole new string.
477
+ *
478
+ * This is the first-class bridge for UIs that hold a streaming message as a
479
+ * single growing string prop (the common React shape) — no hand-rolled diff,
480
+ * no readiness gate (appends before WASM is ready are buffered). Pass
481
+ * `{ done: true }` once the content is final to `finalize()` (idempotent within
482
+ * a generation; a content change *after* done reopens the stream via a fresh
483
+ * reparse, since a finalized parser is terminal and can't be appended to).
484
+ * Drive a given client with `setContent` *or* manual `append()`/`finalize()`,
485
+ * not both — they share the internal diff baseline.
486
+ *
487
+ * v1 note: the non-prefix path is a full reparse, not a partial rewind —
488
+ * committed blocks are frozen, so there is no truncate-to-offset. For the
489
+ * common case (append-growth + one end-of-stream swap) that is optimal. A
490
+ * transform that rewrites *earlier* bytes on every update is an anti-pattern
491
+ * here (it forces a reparse each tick); do that enrichment at render time via
492
+ * `components` instead, keeping the source append-only.
493
+ */
494
+ setContent(content: string, opts?: { done?: boolean }) {
495
+ if (content !== this.lastContent) {
496
+ // Fast path appends the delta into the EXISTING parser — but a parser that
497
+ // was already finalized ({ done: true }) is terminal: the core drops any
498
+ // further append. So gate the fast path on !contentDone; reopening a
499
+ // finalized stream (or any divergence) falls through to reset()+reparse,
500
+ // which frees the dead parser and rebuilds a fresh one.
501
+ if (!this.contentDone && content.startsWith(this.lastContent)) {
502
+ this.append(content.slice(this.lastContent.length));
503
+ } else {
504
+ this.reset(); // diverged, or reopening a finalized stream — rebuild
505
+ this.append(content);
506
+ }
507
+ this.lastContent = content;
508
+ this.contentDone = false;
509
+ }
510
+ if (opts?.done && !this.contentDone) {
511
+ this.finalize();
512
+ this.contentDone = true;
513
+ }
514
+ }
515
+
449
516
  reset() {
450
517
  this.store = emptyBlockStore();
451
518
  this.appendedBytes = 0;
@@ -455,6 +522,8 @@ export class FluxClient {
455
522
  this.firstAppendMs = 0;
456
523
  this.retainedBytes = 0;
457
524
  this.wasmMemoryBytes = 0;
525
+ this.lastContent = ""; // setContent baseline: the worker drops the parser here
526
+ this.contentDone = false;
458
527
  // Same streamId + worker — the worker frees and lazily recreates the parser.
459
528
  const pw = this.ensureAcquired();
460
529
  this.pool.send(pw, { type: "reset", streamId: this.streamId });
@@ -481,6 +550,11 @@ export class FluxClient {
481
550
  */
482
551
  reattach() {
483
552
  if (this.attached) return;
553
+ // The prior destroy()→dispose dropped this stream's parser, so setContent's
554
+ // diff baseline is stale — clear it so the next setContent re-feeds the whole
555
+ // document (StrictMode dev double-mount on the SAME instance).
556
+ this.lastContent = "";
557
+ this.contentDone = false;
484
558
  if (!this.pw) {
485
559
  // Never acquired (e.g. constructed during SSR, first real mount on client).
486
560
  // No prior pool slot to re-register; just mark attached. The next
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * client.finalize();
17
17
  */
18
18
  export { FluxClient, FluxPool, getDefaultPool } from "./client";
19
- export { FluxMarkdown } from "./react";
19
+ export { FluxMarkdown, useFluxStream, useFluxMarkdownString } from "./react";
20
20
  export { highlight, supportedLangs } from "./hi";
21
21
  export { htmlToReact, parseTrustedHtml } from "./html-to-react";
22
22
  export type {
package/src/react.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  useState,
8
8
  useSyncExternalStore,
9
9
  type CSSProperties,
10
+ type ReactElement,
10
11
  } from "react";
11
12
  import type { Block, BlockComponentProps, Components, HeadingData, TableData } from "./types";
12
13
  import { FluxClient } from "./client";
@@ -209,6 +210,52 @@ export function useFluxStream(
209
210
  return client;
210
211
  }
211
212
 
213
+ /**
214
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
215
+ * UIs that hold a streaming message as a single growing string prop (the common
216
+ * React shape) rather than as a stream. Pass the whole document-so-far on each
217
+ * render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
218
+ * only the delta; any divergence (e.g. the finished text swapped for a
219
+ * re-processed final string) resets and reparses. Returns the owned client —
220
+ * pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
221
+ *
222
+ * Pass `streaming: false` once the content is final to finalize the stream and
223
+ * commit its last block (only then does a finished code fence highlight + show
224
+ * its copy button). If `streaming` is omitted or `true` the stream is left OPEN
225
+ * — right for a still-growing string, but a *complete static* string rendered as
226
+ * `useFluxMarkdownString(md)` keeps its last block in the streaming state until
227
+ * you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
228
+ * deliberately avoided: it would re-finalize on every token for callers that
229
+ * grow the string without the flag — an O(n²) reparse trap.) The client is
230
+ * created once and destroyed on unmount; StrictMode's dev double-mount is handled
231
+ * (reattach re-feeds the document). For a true stream source
232
+ * (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
233
+ * instead — it avoids buffering the whole document as a string.
234
+ */
235
+ export function useFluxMarkdownString(
236
+ content: string,
237
+ options?: { config?: ParserConfig; streaming?: boolean },
238
+ ): FluxClient {
239
+ const [client] = useState(() => new FluxClient({ config: options?.config }));
240
+
241
+ // Own the client's pool attachment (StrictMode dev double-mount destroys on the
242
+ // simulated unmount then remounts the SAME instance; reattach re-registers and
243
+ // clears setContent's diff baseline so the document is re-fed). Destroy on the
244
+ // real unmount.
245
+ useEffect(() => {
246
+ client.reattach();
247
+ return () => client.destroy();
248
+ }, [client]);
249
+
250
+ // Reconcile the parser to the controlled string. setContent diffs internally,
251
+ // so this stays correct whether `content` grows by a token or is swapped wholesale.
252
+ useEffect(() => {
253
+ client.setContent(content, { done: options?.streaming === false });
254
+ }, [client, content, options?.streaming]);
255
+
256
+ return client;
257
+ }
258
+
212
259
  // Stream mode: own a client via the hook, then render the normal client path.
213
260
  function FluxMarkdownFromStream(props: FluxMarkdownProps) {
214
261
  const client = useFluxStream(props.stream, {
@@ -336,7 +383,10 @@ function componentInnerHtml(html: string, tag: string): string {
336
383
 
337
384
  /** Convert a closed block's HTML to a React tree, memoized on html+components. */
338
385
  function SafeHtml({ html, components }: { html: string; components: Components }) {
339
- return useMemo(() => htmlToReact(html, components), [html, components]) as JSX.Element;
386
+ // `ReactElement` (not the global `JSX.Element`) so the source type-checks under
387
+ // both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
388
+ // and a consumer's `next build` type-checks this shipped source.
389
+ return useMemo(() => htmlToReact(html, components), [html, components]) as ReactElement;
340
390
  }
341
391
 
342
392
  // Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
@@ -419,12 +469,20 @@ function renderBlockContent({
419
469
  (block.open ? " flux-open" : "") +
420
470
  (block.speculative ? " flux-speculative" : "");
421
471
 
422
- // Tag-level overrides only apply to a settled block (open/speculative blocks
423
- // have partial HTML we must not feed to the parser).
424
- if (components && !block.open && !block.speculative) {
472
+ // Tag-level / inline overrides apply to OPEN and speculative blocks too, not
473
+ // just settled ones: the streaming tail's HTML is always well-formed (the
474
+ // parser speculatively closes it), so a design-system renderer (Tailwind
475
+ // classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
476
+ // instead of only after a block commits. A supplied `sanitize` runs FIRST
477
+ // (same as the innerHTML path below), so overrides compose with sanitization on
478
+ // every block — closing the gap where a component-rendered block previously
479
+ // bypassed the user sanitizer. The no-`components` fast path is untouched
480
+ // (byte-identical innerHTML).
481
+ if (components) {
482
+ const safe = sanitize ? sanitize(block.html) : block.html;
425
483
  return (
426
484
  <div className={className}>
427
- <SafeHtml html={block.html} components={components} />
485
+ <SafeHtml html={safe} components={components} />
428
486
  </div>
429
487
  );
430
488
  }
package/src/solid.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { onCleanup, onMount, type JSX } from "solid-js";
2
- import type { FluxClient } from "./client";
1
+ import { createEffect, onCleanup, onMount, type JSX } from "solid-js";
2
+ import { FluxClient } from "./client";
3
+ import type { ParserConfig } from "./types-core";
3
4
  import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
4
5
 
5
6
  /**
@@ -73,3 +74,72 @@ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
73
74
  onMount(() => mountSolid(() => props, container, onCleanup));
74
75
  return container;
75
76
  }
77
+
78
+ /**
79
+ * Wire a controlled string to a freshly-constructed {@link FluxClient}, free of
80
+ * Solid's reactive runtime so it runs (and is tested) under any toolchain. The
81
+ * registrars are injected: the public {@link createFluxMarkdownString} passes
82
+ * Solid's real `createEffect` / `onCleanup`; tests pass hand-rolled stand-ins
83
+ * (mirroring how {@link mountSolid} takes `registerCleanup`).
84
+ *
85
+ * Ownership DIFFERS from {@link mountSolid}: this constructs the client and so
86
+ * `registerCleanup`s `client.destroy()` — it OWNS the worker/stream. `config` is
87
+ * read ONCE here (the constructor treats it as immutable); `getContent()` and
88
+ * `streaming` are read INSIDE the effect so the effect tracks them reactively.
89
+ */
90
+ export function setupFluxMarkdownString(
91
+ getContent: () => string,
92
+ getOptions: (() => { config?: ParserConfig; streaming?: boolean }) | undefined,
93
+ registerEffect: (fn: () => void) => void,
94
+ registerCleanup: (fn: () => void) => void,
95
+ ): FluxClient {
96
+ // One client per helper instance. Constructor is worker-free → SSR-safe; the
97
+ // worker is spawned lazily by the first setContent → append, which only runs
98
+ // inside the effect below. config is read once and is immutable thereafter.
99
+ const client = new FluxClient({ config: getOptions?.()?.config });
100
+
101
+ // Reconcile the parser to the controlled string. setContent diffs internally,
102
+ // so this is correct whether `content` grows by a token or is swapped wholesale.
103
+ // `streaming === false` (never `!streaming`) → only an explicit false finalizes;
104
+ // an absent/true flag leaves the stream open (inferring "done" from an absent
105
+ // flag would re-finalize on every token — an O(n²) reparse trap).
106
+ registerEffect(() => {
107
+ client.setContent(getContent(), { done: getOptions?.()?.streaming === false });
108
+ });
109
+
110
+ // This helper OWNS the client (unlike the client-based bindings above), so it
111
+ // destroys it on cleanup — freeing its pool slot.
112
+ registerCleanup(() => client.destroy());
113
+
114
+ return client;
115
+ }
116
+
117
+ /**
118
+ * Own a {@link FluxClient} driven by a CONTROLLED full string — the Solid
119
+ * analogue of React's `useFluxMarkdownString`, for UIs that hold a streaming
120
+ * message as a single growing string (a signal/memo) rather than as a stream.
121
+ * Pass an accessor for the whole document-so-far; on every change
122
+ * {@link FluxClient.setContent} diffs it and does the minimal work (a
123
+ * prefix-extension appends only the delta; any divergence resets and reparses).
124
+ *
125
+ * Pass `streaming: false` (via `getOptions`) once the content is final to
126
+ * finalize the stream and commit its last block (only then does a finished code
127
+ * fence highlight + show its copy button). If `streaming` is omitted or `true`
128
+ * the stream is left OPEN. `config` is read once at construction and is
129
+ * immutable, so it is not a change trigger.
130
+ *
131
+ * **Returns the owned client** — pass it to `<FluxMarkdown client={client} />`
132
+ * (and read `outline()` / `getMetrics()` off it). The client is constructed in
133
+ * the body (constructor is worker-free → SSR-safe) and destroyed on cleanup.
134
+ *
135
+ * SSR-safety: `setContent` is what spawns a Worker (via `append`), so it runs
136
+ * ONLY inside a `createEffect` — Solid does not run user effects during
137
+ * `renderToString`, so nothing touches a Worker on the server render path (the
138
+ * body only constructs the worker-free client).
139
+ */
140
+ export function createFluxMarkdownString(
141
+ getContent: () => string,
142
+ getOptions?: () => { config?: ParserConfig; streaming?: boolean },
143
+ ): FluxClient {
144
+ return setupFluxMarkdownString(getContent, getOptions, createEffect, onCleanup);
145
+ }