flux-md 0.16.2 → 0.17.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.
Files changed (66) hide show
  1. package/README.md +70 -39
  2. package/dist/block-props.d.ts +18 -0
  3. package/dist/block-props.js +75 -0
  4. package/dist/client.d.ts +310 -0
  5. package/{src/client.ts → dist/client.js} +123 -319
  6. package/dist/dom.d.ts +110 -0
  7. package/dist/dom.js +576 -0
  8. package/dist/element.d.ts +20 -0
  9. package/dist/element.js +287 -0
  10. package/dist/hi.d.ts +12 -0
  11. package/{src/hi.ts → dist/hi.js} +42 -74
  12. package/dist/html-to-react.d.ts +40 -0
  13. package/dist/html-to-react.js +344 -0
  14. package/{src/index.ts → dist/index.d.ts} +5 -25
  15. package/dist/index.js +16 -0
  16. package/dist/morph.d.ts +28 -0
  17. package/dist/morph.js +166 -0
  18. package/dist/react.d.ts +204 -0
  19. package/dist/react.js +490 -0
  20. package/dist/renderers/CodeBlock.d.ts +7 -0
  21. package/dist/renderers/CodeBlock.js +75 -0
  22. package/dist/renderers/Math.d.ts +14 -0
  23. package/dist/renderers/Math.js +15 -0
  24. package/dist/renderers/Mermaid.d.ts +13 -0
  25. package/dist/renderers/Mermaid.js +15 -0
  26. package/dist/server.d.ts +61 -0
  27. package/dist/server.js +126 -0
  28. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  29. package/dist/solid.js +54 -0
  30. package/dist/svelte.d.ts +80 -0
  31. package/dist/svelte.js +59 -0
  32. package/dist/types-core.d.ts +377 -0
  33. package/dist/types-core.js +0 -0
  34. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  35. package/dist/types-react.js +0 -0
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.js +2 -0
  38. package/dist/vue.d.ts +94 -0
  39. package/dist/vue.js +79 -0
  40. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  41. package/{src → dist}/wasm/flux_md_core.js +50 -55
  42. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  43. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  44. package/dist/worker-core.d.ts +60 -0
  45. package/dist/worker-core.js +121 -0
  46. package/dist/worker.d.ts +1 -0
  47. package/dist/worker.js +48 -0
  48. package/package.json +22 -18
  49. package/src/block-props.ts +0 -141
  50. package/src/dom.ts +0 -946
  51. package/src/element.ts +0 -400
  52. package/src/html-to-react.ts +0 -455
  53. package/src/morph.ts +0 -253
  54. package/src/react.tsx +0 -1020
  55. package/src/renderers/CodeBlock.tsx +0 -116
  56. package/src/renderers/Math.tsx +0 -28
  57. package/src/renderers/Mermaid.tsx +0 -27
  58. package/src/server.tsx +0 -221
  59. package/src/svelte.ts +0 -179
  60. package/src/types-core.ts +0 -398
  61. package/src/types.ts +0 -7
  62. package/src/vue.ts +0 -184
  63. package/src/wasm/flux_md_core_bg.wasm +0 -0
  64. package/src/worker-core.ts +0 -174
  65. package/src/worker.ts +0 -72
  66. /package/{src → dist}/styles.css +0 -0
package/src/react.tsx DELETED
@@ -1,1020 +0,0 @@
1
- import {
2
- createElement,
3
- memo,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- useSyncExternalStore,
9
- useDeferredValue,
10
- type CSSProperties,
11
- type ReactElement,
12
- type ReactNode,
13
- } from "react";
14
- import type { Align, Block, BlockComponentProps, Components, HeadingData, ListData, ListItemData, NestedBlock, TableData } from "./types";
15
- import { FluxClient } from "./client";
16
- import type { ParserConfig, RenderMetricsHook } from "./types-core";
17
- import { CodeBlock } from "./renderers/CodeBlock";
18
- import { MathBlock } from "./renderers/Math";
19
- import { Mermaid } from "./renderers/Mermaid";
20
- import { htmlToReact } from "./html-to-react";
21
-
22
- /**
23
- * Render a streaming markdown document from a FluxClient. Each block is its
24
- * own memoized React node keyed by its stable parser-assigned ID, so React
25
- * only reconciles the blocks whose HTML actually changed since the last
26
- * patch. Heavy renderers (Shiki, KaTeX, Mermaid) defer work until a block
27
- * is closed.
28
- *
29
- * ## Custom components
30
- *
31
- * Pass `components` to override rendering (see {@link Components}):
32
- *
33
- * ```tsx
34
- * <FluxMarkdown
35
- * client={client}
36
- * components={{
37
- * table: (p) => <table className="my-table" {...p} />, // tag-level
38
- * a: (p) => <a target="_blank" rel="noreferrer" {...p} />,
39
- * CodeBlock: (p) => <MyCodeBlock {...p} />, // block-kind
40
- * }}
41
- * />
42
- * ```
43
- *
44
- * Rules:
45
- * - **Tag-level** keys (`table`, `a`, `code`, `h1`…) replace that element
46
- * wherever it appears inside a block. Applied by converting the block's
47
- * trusted HTML to a React tree.
48
- * - **Block-kind** keys ({@link BlockKindTag}: `CodeBlock`, `Mermaid`,
49
- * `Table`…) replace the whole block; the component gets
50
- * {@link BlockComponentProps}.
51
- * - **Open / speculative** blocks always render via `innerHTML` (their HTML
52
- * is partial); a tag-level override takes effect once the block commits.
53
- * - With no `components` prop the renderer takes the original fast
54
- * `innerHTML` path — output is byte-identical to before.
55
- * - **Memoize `components`** (or hoist it) if you define it inside a
56
- * component — a fresh object identity each render busts the block memo and
57
- * forces every block to re-parse on every patch.
58
- * - For code blocks the built-in highlighter is the default; it is bypassed
59
- * (so your override wins) when you provide `components.CodeBlock`,
60
- * `components.pre`, or `components.code`.
61
- */
62
-
63
- interface FluxMarkdownProps {
64
- /**
65
- * A caller-owned client (you drive `append`/`finalize` and own its lifecycle —
66
- * the component never destroys it). Mutually exclusive with `stream`; if both
67
- * are given, `client` wins (a dev warning fires).
68
- */
69
- client?: FluxClient;
70
- /**
71
- * A stream to render directly — the 1-line common case. Pass a `Response`, a
72
- * `ReadableStream<Uint8Array>`, or an `AsyncIterable<string>` (e.g. SSE
73
- * deltas) and the component owns an internal client, pipes the stream, and
74
- * destroys it on unmount. A new `stream` identity supersedes the old.
75
- */
76
- stream?: AsyncIterable<string> | ReadableStream<Uint8Array> | Response;
77
- /** Parser config for the internally-created client (stream mode only). */
78
- streamConfig?: ParserConfig;
79
- /** Called if piping the `stream` rejects (the source errored). Not the worker error channel. */
80
- onStreamError?: (err: Error) => void;
81
- components?: Components;
82
- /**
83
- * Skip layout/paint for off-screen blocks via CSS `content-visibility: auto`
84
- * — for very long documents (hundreds+ of blocks). Off by default. Applies
85
- * only to *closed* blocks (the streaming tail always renders fully). Keeps
86
- * nodes in the DOM; it cuts rendering cost, not node count.
87
- */
88
- virtualize?: boolean;
89
- /**
90
- * Render a bottom snap target so the view follows the streaming tail. This is
91
- * CSS-only: it emits a sentinel with `scroll-snap-align: end`; **you** add
92
- * `scroll-snap-type: y proximity` to your scroll container. The view then
93
- * follows the bottom as content streams in and releases when the user scrolls
94
- * up (and re-locks when they scroll back near the bottom). Off by default.
95
- */
96
- stickToBottom?: boolean;
97
- /**
98
- * Optional HTML sanitizer applied to every block's HTML before it is injected
99
- * via `innerHTML` — **including the streaming (open/speculative) tail**, the
100
- * path that raw `innerHTML` would otherwise expose. Pass a real sanitizer
101
- * (e.g. DOMPurify's `sanitize`) when rendering untrusted / LLM HTML with
102
- * `unsafeHtml` on. flux-md stays zero-dep — you bring the sanitizer. The
103
- * built-in code/math renderers operate on already-escaped content and are not
104
- * run through it. When omitted, rendering is byte-identical and zero-cost.
105
- *
106
- * **Memoize / hoist this** (same trap as `components`): a fresh closure each
107
- * render busts the per-block memo, so every block re-sanitizes and re-parses
108
- * on every patch instead of only the streaming tail.
109
- */
110
- sanitize?: (html: string) => string;
111
- /**
112
- * Opt-in: when an OPEN (streaming) block re-renders each patch, reuse the
113
- * React nodes of its top-level children whose HTML is unchanged and re-parse
114
- * only the new trailing content, instead of re-parsing the block's whole HTML
115
- * every tick. Only takes effect when `components` (or `sanitize`) routes a
116
- * block through the parser; the no-`components` fast path (`innerHTML`) is
117
- * untouched. Off by default and byte-identical to default rendering — enable
118
- * it for documents with a long, slowly-growing streamed block under a custom
119
- * `components` map. Closed blocks are already wholesale memo-skipped, so this
120
- * applies only to the streaming tail.
121
- */
122
- childMemo?: boolean;
123
- /** Appended to the root's `className` (the `flux-md` class is always present). */
124
- className?: string;
125
- /** Set on the root element. */
126
- id?: string;
127
- /** Set on the root element (e.g. `"article"`, `"log"`). */
128
- role?: string;
129
- /**
130
- * Make the root a live region so screen readers announce streamed content.
131
- * `"polite"` (recommended) coalesces rapid updates and announces when the
132
- * reader is idle — it does **not** read every token. Off by default.
133
- */
134
- "aria-live"?: "off" | "polite" | "assertive";
135
- /** Live-region atomicity; pair with `aria-live`. Off by default. */
136
- "aria-atomic"?: boolean;
137
- /**
138
- * Optional render-churn probe. Fires once per ACTUAL render of a block —
139
- * never for a committed block that memo-skips on a tail-only patch. The
140
- * callback gets the block id and a {@link RenderMetrics} sample (per-block
141
- * `renderCount`, `speculativeToggleCount`, `lastRenderMs`, `kind`). Zero
142
- * overhead when omitted. **Memoize / hoist this** (same trap as `components`):
143
- * a fresh closure each render busts the per-block memo, forcing every block to
144
- * re-render on every patch.
145
- */
146
- onRenderMetrics?: RenderMetricsHook;
147
- /**
148
- * **Opt-in, off by default.** Render the block list through React's
149
- * `useDeferredValue`, so a burst of patches can yield to higher-priority
150
- * updates and the streaming tail commits at a lower priority. When a deferred
151
- * render is in flight the root carries an extra `flux-deferred` class (style it
152
- * however you like). This is a *no-op on a single patch*, has no effect during
153
- * SSR (`useDeferredValue` is a client-only concern), and **does not change
154
- * output** — only commit timing. Leaving it unset is recommended; rAF
155
- * coalescing (the DOM adapter's batched path) is the preferred way to absorb
156
- * high-frequency patches. Provided for callers who specifically want React's
157
- * concurrent deferral of the visible tail.
158
- */
159
- deferTail?: boolean;
160
- }
161
-
162
- // The original render path: subscribe to a (required, caller- or hook-owned)
163
- // client and render its blocks. NEVER creates or destroys a client.
164
- function FluxMarkdownFromClient({
165
- client,
166
- components,
167
- virtualize,
168
- stickToBottom,
169
- sanitize,
170
- childMemo,
171
- className,
172
- id,
173
- role,
174
- "aria-live": ariaLive,
175
- "aria-atomic": ariaAtomic,
176
- onRenderMetrics,
177
- deferTail,
178
- }: FluxMarkdownProps & { client: FluxClient }) {
179
- const blocks = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
180
- // Opt-in (off by default): defer the tail through React's concurrent priority
181
- // so a burst of patches can yield. `useDeferredValue` is a no-op on the server
182
- // (it returns its argument during SSR) and a no-op on a single patch, so the
183
- // default (`deferTail` unset) path is byte- and timing-identical to before.
184
- const deferred = useDeferredValue(blocks);
185
- const rendered = deferTail ? deferred : blocks;
186
- const isDeferring = deferTail ? rendered !== blocks : false;
187
- // Normalize "no overrides" to a stable `undefined` so memo comparisons and
188
- // the fast path don't churn on an empty object identity. Memoized on
189
- // [components] so the Object.keys scan runs only when the prop changes, not
190
- // on every tail patch (identity is unchanged, so blocksEqual still holds).
191
- const comps = useMemo(
192
- () => (components && Object.keys(components).length > 0 ? components : undefined),
193
- [components],
194
- );
195
- // Wrap the user hook so each fire also advances the client's aggregate
196
- // renderCount. Memoized on (client, hook) so its identity stays stable across
197
- // tail patches — a fresh closure would bust every block's memo. When no hook
198
- // is supplied this stays `undefined` and the whole probe path is skipped.
199
- const onMetrics = useMemo<RenderMetricsHook | undefined>(
200
- () =>
201
- onRenderMetrics
202
- ? (id, m) => {
203
- client.__noteRender();
204
- onRenderMetrics(id, m);
205
- }
206
- : undefined,
207
- [client, onRenderMetrics],
208
- );
209
- const rootClass = isDeferring
210
- ? className
211
- ? `flux-md flux-deferred ${className}`
212
- : "flux-md flux-deferred"
213
- : className
214
- ? `flux-md ${className}`
215
- : "flux-md";
216
- return (
217
- <div
218
- className={rootClass}
219
- id={id}
220
- role={role}
221
- aria-live={ariaLive}
222
- aria-atomic={ariaAtomic}
223
- >
224
- {rendered.map((b) => (
225
- <BlockView
226
- key={b.id}
227
- block={b}
228
- components={comps}
229
- virtualize={virtualize}
230
- sanitize={sanitize}
231
- childMemo={childMemo}
232
- onRenderMetrics={onMetrics}
233
- />
234
- ))}
235
- {stickToBottom && <div aria-hidden="true" style={{ scrollSnapAlign: "end" }} className="flux-bottom-anchor" />}
236
- </div>
237
- );
238
- }
239
-
240
- /**
241
- * Own a {@link FluxClient} for the lifetime of a component and drive it from a
242
- * `stream` (a `Response`, `ReadableStream<Uint8Array>`, or
243
- * `AsyncIterable<string>`). Returns the client (read `outline()` / `getMetrics()`
244
- * off it, or pass it to `<FluxMarkdown client={…} />`). The client is created
245
- * once and destroyed on unmount; a new `stream` identity supersedes the old
246
- * (the prior pipe is aborted, the parser is reset, the new stream is piped).
247
- *
248
- * Caveat (matches the manual `useEffect` form): a single-use stream — a
249
- * `Response`/`ReadableStream`, or an async generator — can only be consumed
250
- * once, so React **StrictMode**'s dev-only double-mount may truncate it in
251
- * development. Production mounts once and is unaffected. If you need dev-exact
252
- * streaming, drive a caller-owned client manually.
253
- */
254
- export function useFluxStream(
255
- stream: AsyncIterable<string> | ReadableStream<Uint8Array> | Response | null | undefined,
256
- options?: { config?: ParserConfig; onError?: (err: Error) => void },
257
- ): FluxClient {
258
- // One client per hook instance. (React StrictMode double-invokes this
259
- // initializer in DEV, constructing a throwaway second client whose worker
260
- // slot isn't reclaimed — a minor dev-only artifact; production runs it once.
261
- // The committed client is what's used, and its lifecycle below is correct.)
262
- const [client] = useState(() => new FluxClient({ config: options?.config }));
263
- // Read onError through a ref so its identity never re-subscribes the stream.
264
- const onErrorRef = useRef(options?.onError);
265
- onErrorRef.current = options?.onError;
266
- // Track the last stream so we reset() only on a genuine source change — never
267
- // on a StrictMode replay of the same stream (which would discard its head).
268
- const prevStream = useRef<typeof stream>(undefined);
269
-
270
- // Own the client's pool attachment. On (re)mount, reattach (StrictMode's
271
- // dev double-mount destroys on the simulated unmount, then remounts the SAME
272
- // instance — without reattach its patches would be dropped and it'd render
273
- // blank); destroy on real unmount.
274
- useEffect(() => {
275
- client.reattach();
276
- return () => client.destroy();
277
- }, [client]);
278
-
279
- // Consume the current stream; supersede (abort, no finalize) on change/unmount.
280
- useEffect(() => {
281
- if (stream == null) return;
282
- const ac = new AbortController();
283
- if (prevStream.current !== undefined && prevStream.current !== stream) {
284
- client.reset(); // a different stream replaced a prior one
285
- }
286
- prevStream.current = stream;
287
- client.pipeFrom(stream, { signal: ac.signal }).catch((e) => {
288
- if (!ac.signal.aborted) {
289
- onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
290
- }
291
- });
292
- return () => ac.abort();
293
- }, [stream, client]);
294
-
295
- return client;
296
- }
297
-
298
- /**
299
- * Own a {@link FluxClient} driven by a CONTROLLED full string — the bridge for
300
- * UIs that hold a streaming message as a single growing string prop (the common
301
- * React shape) rather than as a stream. Pass the whole document-so-far on each
302
- * render and {@link FluxClient.setContent} diffs it: a prefix-extension appends
303
- * only the delta; any divergence (e.g. the finished text swapped for a
304
- * re-processed final string) resets and reparses. Returns the owned client —
305
- * pass it to `<FluxMarkdown client={…} />` (and read `outline()` etc.).
306
- *
307
- * Pass `streaming: false` once the content is final to finalize the stream and
308
- * commit its last block (only then does a finished code fence highlight + show
309
- * its copy button). If `streaming` is omitted or `true` the stream is left OPEN
310
- * — right for a still-growing string, but a *complete static* string rendered as
311
- * `useFluxMarkdownString(md)` keeps its last block in the streaming state until
312
- * you pass `{ streaming: false }`. (Inferring "done" from an absent flag is
313
- * deliberately avoided: it would re-finalize on every token for callers that
314
- * grow the string without the flag — an O(n²) reparse trap.) The client is
315
- * created once and destroyed on unmount; StrictMode's dev double-mount is handled
316
- * (reattach re-feeds the document). For a true stream source
317
- * (`Response` / `ReadableStream` / SSE generator) use {@link useFluxStream}
318
- * instead — it avoids buffering the whole document as a string.
319
- */
320
- export function useFluxMarkdownString(
321
- content: string,
322
- options?: { config?: ParserConfig; streaming?: boolean },
323
- ): FluxClient {
324
- const [client] = useState(() => new FluxClient({ config: options?.config }));
325
-
326
- // Own the client's pool attachment (StrictMode dev double-mount destroys on the
327
- // simulated unmount then remounts the SAME instance; reattach re-registers and
328
- // clears setContent's diff baseline so the document is re-fed). Destroy on the
329
- // real unmount.
330
- useEffect(() => {
331
- client.reattach();
332
- return () => client.destroy();
333
- }, [client]);
334
-
335
- // Reconcile the parser to the controlled string. setContent diffs internally,
336
- // so this stays correct whether `content` grows by a token or is swapped wholesale.
337
- const streaming = options?.streaming;
338
- useEffect(() => {
339
- client.setContent(content, { done: streaming === false });
340
- }, [client, content, streaming]);
341
-
342
- return client;
343
- }
344
-
345
- // Stream mode: own a client via the hook, then render the normal client path.
346
- function FluxMarkdownFromStream(props: FluxMarkdownProps) {
347
- const client = useFluxStream(props.stream, {
348
- config: props.streamConfig,
349
- onError: props.onStreamError,
350
- });
351
- return <FluxMarkdownFromClient {...props} client={client} />;
352
- }
353
-
354
- // Dispatch by rendering one of two SIBLING components (never a hook in a branch,
355
- // which would violate the Rules of Hooks): `stream` mode owns a client, `client`
356
- // mode uses the caller's. `memo` skips re-render when props are unchanged. If
357
- // both are given `client` wins (it owns the lifecycle); passing neither is a
358
- // usage error and throws (rather than crashing cryptically downstream).
359
- function FluxMarkdownImpl(props: FluxMarkdownProps) {
360
- if (props.stream != null && props.client == null) {
361
- return <FluxMarkdownFromStream {...props} />;
362
- }
363
- if (props.client == null) {
364
- throw new Error("<FluxMarkdown>: pass either a `client` or a `stream` prop.");
365
- }
366
- return <FluxMarkdownFromClient {...(props as FluxMarkdownProps & { client: FluxClient })} />;
367
- }
368
-
369
- export const FluxMarkdown = memo(FluxMarkdownImpl);
370
-
371
- function decodeEntities(s: string): string {
372
- return s
373
- .replace(/&lt;/g, "<")
374
- .replace(/&gt;/g, ">")
375
- .replace(/&quot;/g, '"')
376
- .replace(/&#39;/g, "'")
377
- .replace(/&amp;/g, "&");
378
- }
379
-
380
- function decodeCodeText(html: string): string {
381
- const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
382
- return m ? decodeEntities(m[1]) : "";
383
- }
384
-
385
- /**
386
- * The LaTeX source for a MathBlock. Display math (`$$…$$` / `\[…\]`) renders as
387
- * `<div class="math math-display">…</div>`; a fenced ```math block renders as
388
- * `<pre><code>…</code></pre>`. Either way the body is the HTML-escaped LaTeX —
389
- * decode it back so a `components.MathBlock` override gets the raw source.
390
- */
391
- function decodeMathText(html: string): string {
392
- const d = html.match(/<div class="math math-display">([\s\S]*?)<\/div>/);
393
- if (d) return decodeEntities(d[1]);
394
- return decodeCodeText(html);
395
- }
396
-
397
- export function blockKindProps(block: Block, components?: Components): BlockComponentProps {
398
- const props: BlockComponentProps = {
399
- block,
400
- html: block.html,
401
- open: block.open,
402
- speculative: block.speculative,
403
- };
404
- const data = block.kind.data as
405
- | { lang?: string | null; code?: string; latex?: string; start?: number; ordered?: boolean; items?: { html: string }[]; tag?: string; attrs?: [string, string][] }
406
- | undefined;
407
- if (block.kind.type === "CodeBlock") {
408
- // Prefer the structured `code` (present when blockData is on) over the HTML
409
- // regex — the lossless decoded source. Fall back to the regex when off.
410
- props.text = data?.code ?? decodeCodeText(block.html);
411
- props.language = data?.lang ?? "";
412
- if (typeof data?.code === "string") {
413
- props.code = { lang: data.lang ?? null, code: data.code };
414
- }
415
- } else if (block.kind.type === "MathBlock") {
416
- props.text = data?.latex ?? decodeMathText(block.html);
417
- if (typeof data?.latex === "string") {
418
- props.math = { latex: data.latex };
419
- }
420
- } else if (block.kind.type === "List") {
421
- if (data && typeof data.start === "number") {
422
- props.list = { ordered: !!data.ordered, start: data.start, items: data.items };
423
- }
424
- } else if (block.kind.type === "Component") {
425
- props.tag = data?.tag ?? "";
426
- // React-form attribute names, so `{...attrs}` spreads cleanly onto an element
427
- // (HTML `class`/`for` → React `className`/`htmlFor`).
428
- props.attrs = reactAttrs(data?.attrs ?? []);
429
- // An override replaces the `<tag>` wrapper, so it gets the *inner* HTML
430
- // (markdown already rendered) rather than the full wrapped block.
431
- props.html = componentInnerHtml(block.html, props.tag);
432
- // Convenience: the inner markdown pre-parsed to a React tree (with nested
433
- // tag/inline-component overrides applied). Render `{children}` directly
434
- // instead of dangerouslySetInnerHTML-ing `html` — the easy, correct path.
435
- props.children = htmlToReact(props.html, components ?? {});
436
- } else if (block.kind.type === "Table") {
437
- // Pure structured data (present only when `blockData` is on) — unlike
438
- // `attrs` there is no React/DOM name-form divergence, so this is the same
439
- // line as block-props.ts's branch.
440
- props.table = block.kind.data as TableData | undefined;
441
- } else if (block.kind.type === "Heading") {
442
- // When `blockData` is on, `kind.data` is `{ level, text, id }`; off, it is the
443
- // bare level `number`. Surface the rich object only (mirrors block-props.ts).
444
- if (typeof block.kind.data === "object" && block.kind.data !== null) {
445
- props.heading = block.kind.data as HeadingData;
446
- }
447
- } else if (block.kind.type === "Blockquote" || block.kind.type === "Alert") {
448
- // When `blockData` is on, a Blockquote's `kind.data` is `{ nested }` and an
449
- // Alert's is `{ kind, nested }`. Surface the keyed `nested` sub-blocks (the
450
- // array, not the bare html) only when present.
451
- const cd = block.kind.data as { nested?: NestedBlock[] } | undefined;
452
- if (cd && Array.isArray(cd.nested)) {
453
- props.container = { nested: cd.nested };
454
- }
455
- }
456
- return props;
457
- }
458
-
459
- // Prototype-free so a key like `constructor`/`hasOwnProperty` returns undefined
460
- // (and the `?? k` fallback fires) instead of an inherited Object.prototype member.
461
- const REACT_ATTR_NAME: Record<string, string> = Object.assign(Object.create(null), {
462
- class: "className",
463
- for: "htmlFor",
464
- });
465
-
466
- // React-meaningful prop names that must never survive into a user override's
467
- // attrs object (dangerouslySetInnerHTML crashes the render tree; ref/key/etc.
468
- // inject internals). Mirrors html-to-react's PROP_DENY.
469
- const ATTR_DENY = new Set([
470
- "dangerouslysetinnerhtml", "ref", "key", "defaultvalue", "defaultchecked",
471
- "suppresshydrationwarning", "suppresscontenteditablewarning",
472
- ]);
473
-
474
- // Forward only plain HTML attribute identifiers (the REACT_ATTR_NAME renames
475
- // pass too), so weird casings / `__proto__` / `constructor` never reach a prop.
476
- const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
477
-
478
- /** Convert sanitized HTML attribute pairs into a React-spreadable object,
479
- * renaming the two names React requires (`class`→`className`, `for`→`htmlFor`).
480
- * Other names (including `data-*` / `aria-*`) pass through unchanged. Drops
481
- * inline event handlers and React-meaningful/unsafe names as defense-in-depth
482
- * (the Rust `sanitize_attrs` is the primary gate; this keeps the React layer
483
- * safe on its own when attrs are handed to user override components). */
484
- function reactAttrs(pairs: [string, string][]): Record<string, string> {
485
- const out: Record<string, string> = {};
486
- for (const [k, v] of pairs) {
487
- const lower = k.toLowerCase();
488
- if (lower.startsWith("on")) continue;
489
- if (ATTR_DENY.has(lower)) continue;
490
- if (!(lower in REACT_ATTR_NAME) && !SAFE_ATTR_NAME.test(k)) continue;
491
- out[REACT_ATTR_NAME[lower] ?? k] = v;
492
- }
493
- return out;
494
- }
495
-
496
- /** Strip the `<tag …>` open and trailing `</tag>` from a component block's HTML,
497
- * leaving the inner (already-rendered markdown) HTML. Handles open (unclosed)
498
- * blocks, where there is no close tag yet. */
499
- function componentInnerHtml(html: string, tag: string): string {
500
- const gt = html.indexOf(">");
501
- if (gt < 0) return "";
502
- let inner = html.slice(gt + 1);
503
- const close = `</${tag}>`;
504
- if (inner.endsWith(close)) inner = inner.slice(0, -close.length);
505
- return inner.replace(/^\n/, "").replace(/\n$/, "");
506
- }
507
-
508
- // Cap a single open block's child-memo map so a pathologically long streaming
509
- // block can't grow it without bound. Each entry keys one top-level node; a few
510
- // thousand covers any realistic block, and clearing on overflow just costs one
511
- // full re-parse tick (correctness is unaffected).
512
- const CHILD_MEMO_CAP = 4096;
513
-
514
- /** Convert a closed block's HTML to a React tree, memoized on html+components.
515
- *
516
- * When `childMemo` is true (OPEN blocks only — closed blocks are already
517
- * memo-skipped wholesale by `blocksEqual`), this additionally reuses the React
518
- * nodes of top-level children whose HTML is unchanged across ticks, so a
519
- * growing streaming block re-parses only its new trailing content instead of
520
- * its whole html every patch. Opt-in via the `childMemo` prop; with it off the
521
- * output and parse path are byte-identical to before. */
522
- function SafeHtml({
523
- html,
524
- components,
525
- childMemo,
526
- }: {
527
- html: string;
528
- components: Components;
529
- childMemo?: boolean;
530
- }) {
531
- // One map per mounted SafeHtml instance (i.e. per block.id, since blocks are
532
- // keyed in the list). Cleared when `components` changes — a cached node was
533
- // built under the old map and must not leak across the swap.
534
- const memoRef = useRef<Map<string, ReactNode> | null>(null);
535
- const compRef = useRef<Components | null>(null);
536
- // `ReactElement` (not the global `JSX.Element`) so the source type-checks under
537
- // both @types/react 18 and 19 — React 19 removed the global `JSX` namespace,
538
- // and a consumer's `next build` type-checks this shipped source.
539
- return useMemo(() => {
540
- if (!childMemo) {
541
- // Free the map once a block closes (childMemo flips off) so a long doc
542
- // doesn't retain per-block caches after streaming finishes.
543
- memoRef.current = null;
544
- return htmlToReact(html, components) as ReactElement;
545
- }
546
- if (memoRef.current === null || compRef.current !== components) {
547
- memoRef.current = new Map();
548
- compRef.current = components;
549
- }
550
- const map = memoRef.current;
551
- if (map.size > CHILD_MEMO_CAP) map.clear();
552
- return htmlToReact(html, components, map) as ReactElement;
553
- }, [html, components, childMemo]) as ReactElement;
554
- }
555
-
556
- // One `<li>` of the keyed list renderer. Memoized on its inner `html` (+ the
557
- // sanitize/components identity) so React skips the item entirely when an earlier
558
- // item's HTML is unchanged across a streaming patch — the whole point of the
559
- // keyed path. Routes inner HTML through the SAME components-and-sanitize-aware
560
- // path the whole-block renderer uses (never a raw innerHTML hole): `SafeHtml`
561
- // (-> memoized `htmlToReact(html, components)`) when overrides are present, else
562
- // `dangerouslySetInnerHTML` with the supplied sanitizer (matches the no-overrides
563
- // fast path's byte-faithful innerHTML).
564
- function KeyedListItemImpl({
565
- html,
566
- components,
567
- sanitize,
568
- }: {
569
- html: string;
570
- components?: Components;
571
- sanitize?: (html: string) => string;
572
- }) {
573
- const safe = sanitize ? sanitize(html) : html;
574
- if (components) {
575
- return (
576
- <li>
577
- <SafeHtml html={safe} components={components} />
578
- </li>
579
- );
580
- }
581
- return <li dangerouslySetInnerHTML={{ __html: safe }} />;
582
- }
583
- const KeyedListItem = memo(KeyedListItemImpl);
584
-
585
- // Keyed `<ul>`/`<ol>` renderer for an OPEN list when `blockData` is on. Stamps one
586
- // memoized `<li key={i}>` per `items[i].html` so React reconciles only the items
587
- // that changed since the last patch (typically just the growing tail), instead of
588
- // re-parsing the entire list's HTML every tick via the whole-block path.
589
- function KeyedList({
590
- className,
591
- ordered,
592
- start,
593
- items,
594
- components,
595
- sanitize,
596
- }: {
597
- className: string;
598
- ordered: boolean;
599
- start?: number;
600
- items: ListItemData[];
601
- components?: Components;
602
- sanitize?: (html: string) => string;
603
- }) {
604
- const children = items.map((it, i) => (
605
- <KeyedListItem key={i} html={it.html} components={components} sanitize={sanitize} />
606
- ));
607
- // `start` mirrors the `<ol start="N">` attribute the whole-block HTML carries
608
- // (omitted for 1, matching render_list); `<ul>` ignores it.
609
- const inner = ordered
610
- ? createElement("ol", start !== undefined && start !== 1 ? { start } : null, children)
611
- : createElement("ul", null, children);
612
- return <div className={className}>{inner}</div>;
613
- }
614
-
615
- /**
616
- * Streaming-tail fast path for an OPEN Blockquote / Alert when `blockData` is on.
617
- * The container's wrapper is rendered once from `block.html`'s opening tag (its
618
- * `dir`/`class`/`data-alert`/`role` attrs are preserved exactly — these are the
619
- * only attributes the Rust renderer emits on these wrappers); the inner
620
- * sub-blocks come from `container.nested` and render KEYED, each through the same
621
- * `htmlToReact(html, components)` memo path as the rest of the renderer (so
622
- * component overrides and the sanitizer-hardened conversion still apply — NOT a
623
- * raw `dangerouslySetInnerHTML` hole). Because committed inner blocks have stable
624
- * HTML, only the open last child re-parses each tick instead of the whole wrapper.
625
- */
626
- function KeyedContainer({
627
- block,
628
- nested,
629
- components,
630
- }: {
631
- block: Block;
632
- nested: NestedBlock[];
633
- components: Components;
634
- }) {
635
- // The wrapper element (`blockquote` / `div`) and its attributes come straight
636
- // off the rendered opening tag, so the streamed wrapper is byte-faithful.
637
- const tagName = block.kind.type === "Alert" ? "div" : "blockquote";
638
- const attrs = useMemo(() => parseOpenTagAttrs(block.html), [block.html]);
639
- const children: ReactElement[] = [];
640
- // Alerts keep their title `<p class="markdown-alert-title">…</p>` as the first
641
- // child (it is the wrapper, not a body sub-block, so it is not in `nested`).
642
- if (block.kind.type === "Alert") {
643
- const title = alertTitleHtml(block.html);
644
- if (title) {
645
- children.push(<SafeHtml key="title" html={title} components={components} />);
646
- }
647
- }
648
- for (let i = 0; i < nested.length; i++) {
649
- children.push(<SafeHtml key={i} html={nested[i].html} components={components} />);
650
- }
651
- return createElement(tagName, attrs, children) as ReactElement;
652
- }
653
-
654
- // Attributes the Rust renderer emits on a blockquote / alert wrapper open tag.
655
- // Whitelisted (not a generic HTML parser): only these names are forwarded, in
656
- // their React prop form. `class`→`className`, everything else (`dir`,
657
- // `data-alert`, `role`) passes through.
658
- const CONTAINER_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
659
- function parseOpenTagAttrs(html: string): Record<string, string> {
660
- const gt = html.indexOf(">");
661
- const open = gt < 0 ? html : html.slice(0, gt);
662
- const out: Record<string, string> = {};
663
- let m: RegExpExecArray | null;
664
- CONTAINER_ATTR_RE.lastIndex = 0;
665
- while ((m = CONTAINER_ATTR_RE.exec(open))) {
666
- const name = m[1].toLowerCase();
667
- if (name === "class") out.className = m[2];
668
- else if (name === "dir" || name === "role" || name.startsWith("data-")) out[name] = m[2];
669
- }
670
- return out;
671
- }
672
-
673
- // Extract an alert's title `<p class="markdown-alert-title"…>Title</p>` from the
674
- // wrapper HTML so the keyed path keeps it as the first child (it is never in
675
- // `nested`). Returns "" when not found (defensive — then only `nested` renders).
676
- function alertTitleHtml(html: string): string {
677
- const m = html.match(/<p class="markdown-alert-title"[^>]*>.*?<\/p>/s);
678
- return m ? m[0] : "";
679
- }
680
-
681
- // A stable empty components map so the keyed-table cell path can call the
682
- // memoized htmlToReact even when the caller passes no overrides (the table
683
- // itself is still routed through the components-aware tokenizer — never raw
684
- // innerHTML — so the 0.15.0 security hardening always applies).
685
- const NO_COMPONENTS: Components = Object.freeze(Object.create(null));
686
-
687
- // One table cell. `memo` + `useMemo` keep its parsed React tree stable across
688
- // patches when its `html`/`components` are unchanged — so a committed row's
689
- // cells never re-tokenize. Only the OPEN trailing row (whose cell html grows
690
- // each tick) re-parses. Cells are routed through `htmlToReact` (NOT raw
691
- // innerHTML) to preserve component overrides + the security hardening, and
692
- // through `sanitize` first when supplied (parity with the innerHTML path).
693
- const TableCellView = memo(function TableCellView({
694
- tag,
695
- html,
696
- align,
697
- scope,
698
- components,
699
- sanitize,
700
- }: {
701
- tag: "th" | "td";
702
- html: string;
703
- align: Align;
704
- scope: boolean;
705
- components: Components;
706
- sanitize?: (html: string) => string;
707
- }) {
708
- const tree = useMemo(
709
- () => htmlToReact(sanitize ? sanitize(html) : html, components),
710
- [html, components, sanitize],
711
- );
712
- return createElement(
713
- tag,
714
- {
715
- scope: tag === "th" && scope ? "col" : undefined,
716
- style: align ? { textAlign: align } : undefined,
717
- },
718
- tree,
719
- );
720
- });
721
-
722
- /**
723
- * Keyed table renderer for an OPEN table when `blockData` is on. Rows are keyed
724
- * by index, so React reconciles only the growing trailing row each patch — a
725
- * committed row keeps its identity (its `cell.html` is byte-stable) and its
726
- * cells skip re-tokenizing. Closed tables stay on the memo/fingerprint full-HTML
727
- * path (committed blocks are already free); this only buys the streaming tail.
728
- * Alignment comes from `data.aligns`; `dir="auto"` / `scope="col"` are recovered
729
- * from the block HTML so the streamed markup stays faithful to the closed form.
730
- */
731
- function KeyedTable({
732
- data,
733
- html,
734
- components,
735
- sanitize,
736
- }: {
737
- data: TableData;
738
- html: string;
739
- components?: Components;
740
- sanitize?: (html: string) => string;
741
- }) {
742
- const comps = components ?? NO_COMPONENTS;
743
- // Faithful wrapper attrs not carried in the data channel, sniffed from the
744
- // (trusted, core-emitted) HTML prefix so the open form matches the closed one.
745
- // Scope the sniffs to the stable header prefix: while a table streams, `html`
746
- // grows every patch, but everything up to </thead> is byte-stable once the
747
- // header commits — so these memos skip on every row-append tick instead of
748
- // re-scanning the whole growing table.
749
- const headerPrefix = useMemo(() => {
750
- const i = html.indexOf("</thead>");
751
- return i === -1 ? html : html.slice(0, i);
752
- }, [html]);
753
- const dir = useMemo(
754
- () => (headerPrefix.startsWith("<table dir=\"auto\"") ? "auto" : undefined),
755
- [headerPrefix],
756
- );
757
- const scope = useMemo(() => headerPrefix.includes("<th scope=\"col\""), [headerPrefix]);
758
- const aligns = data.aligns;
759
- return (
760
- <table dir={dir as "auto" | undefined}>
761
- <thead>
762
- <tr>
763
- {data.headers.map((c, j) => (
764
- <TableCellView
765
- key={j}
766
- tag="th"
767
- html={c.html}
768
- align={aligns[j] ?? null}
769
- scope={scope}
770
- components={comps}
771
- sanitize={sanitize}
772
- />
773
- ))}
774
- </tr>
775
- </thead>
776
- {data.rows.length > 0 && (
777
- <tbody>
778
- {data.rows.map((row, i) => (
779
- <tr key={i}>
780
- {row.map((c, j) => (
781
- <TableCellView
782
- key={j}
783
- tag="td"
784
- html={c.html}
785
- align={aligns[j] ?? null}
786
- scope={scope}
787
- components={comps}
788
- sanitize={sanitize}
789
- />
790
- ))}
791
- </tr>
792
- ))}
793
- </tbody>
794
- )}
795
- </table>
796
- );
797
- }
798
-
799
- // Per-kind off-screen size estimate for `contain-intrinsic-size` — keeps the
800
- // scrollbar stable while a block is layout-skipped. Wrong by 2× is fine; the
801
- // `auto` keyword makes the browser remember the real size once rendered.
802
- const INTRINSIC_PX: Record<string, number> = {
803
- Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
804
- List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
805
- Component: 120,
806
- };
807
-
808
- function BlockViewImpl(props: {
809
- block: Block;
810
- components?: Components;
811
- virtualize?: boolean;
812
- sanitize?: (html: string) => string;
813
- childMemo?: boolean;
814
- onRenderMetrics?: RenderMetricsHook;
815
- }) {
816
- const { block, virtualize, onRenderMetrics } = props;
817
- // Render-churn probe (only when a hook is wired — refs are cheap, but the
818
- // measurement + hook call below are guarded so the no-hook path is untouched).
819
- // Reaching this body at all means React did NOT memo-skip, so a committed
820
- // block fires exactly once and the streaming tail fires per patch — by design.
821
- const metricsRef = useRef<{ renderCount: number; toggle: number; speculative: boolean } | null>(
822
- onRenderMetrics ? { renderCount: 0, toggle: 0, speculative: block.speculative } : null,
823
- );
824
- const hasPerf = typeof performance !== "undefined";
825
- const t0 = onRenderMetrics && hasPerf ? performance.now() : 0;
826
-
827
- const content = renderBlockContent(props);
828
-
829
- if (onRenderMetrics) {
830
- // Lazily init if the hook was added after mount (initial useRef value only
831
- // applies on first render).
832
- const m = (metricsRef.current ??= { renderCount: 0, toggle: 0, speculative: block.speculative });
833
- m.renderCount++;
834
- if (m.speculative !== block.speculative) {
835
- m.toggle++;
836
- m.speculative = block.speculative;
837
- }
838
- onRenderMetrics(block.id, {
839
- renderCount: m.renderCount,
840
- speculativeToggleCount: m.toggle,
841
- lastRenderMs: hasPerf ? performance.now() - t0 : 0,
842
- kind: block.kind.type,
843
- });
844
- }
845
- // Virtualize only *closed* blocks: the streaming tail (open/speculative) is
846
- // where the user looks and where heights change fastest — deferring it there
847
- // causes flicker. A uniform wrapper covers every kind, including dedicated
848
- // renderers and block-kind overrides.
849
- if (virtualize && !block.open && !block.speculative) {
850
- const px = INTRINSIC_PX[block.kind.type] ?? 120;
851
- return (
852
- <div style={{ contentVisibility: "auto", containIntrinsicSize: `auto ${px}px` } as CSSProperties}>
853
- {content}
854
- </div>
855
- );
856
- }
857
- return content;
858
- }
859
-
860
- function renderBlockContent({
861
- block,
862
- components,
863
- sanitize,
864
- childMemo,
865
- }: {
866
- block: Block;
867
- components?: Components;
868
- sanitize?: (html: string) => string;
869
- childMemo?: boolean;
870
- }) {
871
- const kind = block.kind.type;
872
-
873
- // Block-kind override replaces the entire renderer for this block. A
874
- // `Component` block also dispatches on its tag name, so `components.Thinking`
875
- // (the specific tag) wins over `components.Component` (the generic fallback).
876
- if (components) {
877
- if (kind === "Component") {
878
- const tag = (block.kind.data as { tag?: string } | undefined)?.tag;
879
- const override = (tag && components[tag]) || components.Component;
880
- if (override) {
881
- return createElement(override, blockKindProps(block, components));
882
- }
883
- }
884
- const blockOverride = components[kind];
885
- if (blockOverride) {
886
- return createElement(blockOverride, blockKindProps(block, components));
887
- }
888
- }
889
-
890
- // Dedicated renderers for code / math / mermaid. Code blocks fall through to
891
- // the generic (override-aware) path if the user supplied a pre/code override.
892
- switch (kind) {
893
- case "CodeBlock": {
894
- const wantsCodeOverride = !!components && (!!components.pre || !!components.code);
895
- if (!wantsCodeOverride) return <CodeBlock html={block.html} open={block.open} />;
896
- break; // fall through to generic override-aware rendering
897
- }
898
- case "MathBlock":
899
- return <MathBlock html={block.html} open={block.open} />;
900
- case "Mermaid":
901
- return <Mermaid html={block.html} open={block.open} />;
902
- }
903
-
904
- const className =
905
- "flux-block flux-block-" +
906
- kind.toLowerCase() +
907
- (block.open ? " flux-open" : "") +
908
- (block.speculative ? " flux-speculative" : "");
909
-
910
- // Keyed table for the streaming tail: when `blockData` is on and a Table block
911
- // is OPEN, render keyed rows so React reconciles only the growing trailing row
912
- // — a committed row's `cell.html` is byte-stable, so its memoized cells skip
913
- // re-tokenizing instead of the whole table re-parsing every patch. Closed
914
- // tables stay on the memo/full-HTML path below (committed blocks are already
915
- // free). Falls through when the data channel is absent (blockData off).
916
- if (kind === "Table" && block.open) {
917
- const data = block.kind.data as TableData | undefined;
918
- if (data && Array.isArray(data.rows)) {
919
- return (
920
- <div className={className}>
921
- <KeyedTable data={data} html={block.html} components={components} sanitize={sanitize} />
922
- </div>
923
- );
924
- }
925
- }
926
-
927
- // Keyed list renderer (opt-in: only fires when `blockData` is on, so
928
- // `kind.data.items` carries the per-item inner HTML). While a list is OPEN it
929
- // gets a fresh block ref every patch, so the whole-block path below re-parses
930
- // the entire `<ul>`/`<ol>` HTML every tick. Stamping one memoized `<li key={i}>`
931
- // per item instead lets React reuse the unchanged items and re-render only the
932
- // tail item that actually grew. Closed lists fall through (committed = free).
933
- // Skipped when a tag-level `ul`/`ol`/`li` override is present so those keep
934
- // controlling the wrappers via the whole-block path.
935
- if (block.open && kind === "List") {
936
- const ld = block.kind.data as ListData | undefined;
937
- const items = ld?.items;
938
- const tagOverride =
939
- !!components && (!!components.ul || !!components.ol || !!components.li);
940
- if (Array.isArray(items) && items.length > 0 && !tagOverride) {
941
- return (
942
- <KeyedList
943
- className={className}
944
- ordered={!!ld?.ordered}
945
- start={ld?.start}
946
- items={items}
947
- components={components}
948
- sanitize={sanitize}
949
- />
950
- );
951
- }
952
- }
953
-
954
- // Tag-level / inline overrides apply to OPEN and speculative blocks too, not
955
- // just settled ones: the streaming tail's HTML is always well-formed (the
956
- // parser speculatively closes it), so a design-system renderer (Tailwind
957
- // classes on p/ul/li, inline <a>/<code> overrides) stays styled mid-stream
958
- // instead of only after a block commits. A supplied `sanitize` runs FIRST
959
- // (same as the innerHTML path below), so overrides compose with sanitization on
960
- // every block — closing the gap where a component-rendered block previously
961
- // bypassed the user sanitizer. The no-`components` fast path is untouched
962
- // (byte-identical innerHTML).
963
- if (components) {
964
- // Streaming-tail fast path: an OPEN Blockquote / Alert with structured
965
- // `nested` data (blockData on) renders its inner sub-blocks KEYED, so only
966
- // the open last child re-parses each tick instead of the whole wrapper. A
967
- // `sanitize` hook disables it (it must run over the full wrapper string) and
968
- // it falls through to the opaque-html path below. Closed blocks also fall
969
- // through — their HTML is stable, so the whole-wrapper memo already holds.
970
- if (block.open && !sanitize && (kind === "Blockquote" || kind === "Alert")) {
971
- const nested = (block.kind.data as { nested?: NestedBlock[] } | undefined)?.nested;
972
- if (Array.isArray(nested)) {
973
- return (
974
- <div className={className}>
975
- <KeyedContainer block={block} nested={nested} components={components} />
976
- </div>
977
- );
978
- }
979
- }
980
- const safe = sanitize ? sanitize(block.html) : block.html;
981
- return (
982
- <div className={className}>
983
- {/* Child-memo only pays off on the OPEN tail (closed blocks are already
984
- wholesale memo-skipped by blocksEqual); gate it on block.open so a
985
- closed block frees its per-block map and parses in one pass. */}
986
- <SafeHtml html={safe} components={components} childMemo={childMemo && block.open} />
987
- </div>
988
- );
989
- }
990
-
991
- return (
992
- <div
993
- className={className}
994
- dangerouslySetInnerHTML={{ __html: sanitize ? sanitize(block.html) : block.html }}
995
- />
996
- );
997
- }
998
-
999
- // A block is the same render when its identity, HTML, open-state, and the
1000
- // active components map are all unchanged. Exported for tests: this predicate
1001
- // is what stops a committed block from re-rendering (and thus re-parsing) on
1002
- // every streaming patch.
1003
- export function blocksEqual(
1004
- prev: { block: Block; components?: Components; virtualize?: boolean; sanitize?: (html: string) => string; childMemo?: boolean; onRenderMetrics?: RenderMetricsHook },
1005
- next: { block: Block; components?: Components; virtualize?: boolean; sanitize?: (html: string) => string; childMemo?: boolean; onRenderMetrics?: RenderMetricsHook },
1006
- ): boolean {
1007
- return (
1008
- prev.block.id === next.block.id &&
1009
- prev.block.html === next.block.html &&
1010
- prev.block.open === next.block.open &&
1011
- prev.block.speculative === next.block.speculative &&
1012
- prev.components === next.components &&
1013
- prev.virtualize === next.virtualize &&
1014
- prev.sanitize === next.sanitize &&
1015
- prev.childMemo === next.childMemo &&
1016
- prev.onRenderMetrics === next.onRenderMetrics
1017
- );
1018
- }
1019
-
1020
- const BlockView = memo(BlockViewImpl, blocksEqual);