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 +33 -0
- package/README.md +45 -14
- package/package.json +1 -1
- package/src/client.ts +80 -9
- package/src/react.tsx +120 -4
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/package.json +1 -1
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
|
-
|
|
68
|
-
(
|
|
69
|
-
|
|
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(
|
|
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.
|
|
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
|
|
344
|
-
*
|
|
345
|
-
* client.pipeFrom(await fetch("/api/chat"))
|
|
346
|
-
* `
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/wasm/package.json
CHANGED