@djangocfg/widget-media 0.1.1

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.
@@ -0,0 +1,537 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { create } from 'zustand';
5
+ import { devtools, persist } from 'zustand/middleware';
6
+ import { useShallow } from 'zustand/react/shallow';
7
+
8
+ import { createLogger } from '@djangocfg/ui-core/lib';
9
+
10
+ const cacheDebug = createLogger('MediaCache');
11
+
12
+ // Types
13
+ interface BlobUrlEntry {
14
+ url: string;
15
+ refCount: number;
16
+ createdAt: number;
17
+ }
18
+
19
+ interface ImageDimensions {
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ interface VideoMetadata {
25
+ duration: number;
26
+ width: number;
27
+ height: number;
28
+ codec?: string;
29
+ }
30
+
31
+ interface EffectConfig {
32
+ opacity: number;
33
+ scale: number;
34
+ blur: string;
35
+ }
36
+
37
+ /** Video player settings (persisted) */
38
+ interface VideoPlayerSettings {
39
+ volume: number;
40
+ isLooping: boolean;
41
+ }
42
+
43
+ // Stream URL TTL (30 seconds - shorter to avoid stale session/token issues)
44
+ const STREAM_URL_TTL = 30 * 1000;
45
+
46
+ interface MediaCacheState {
47
+ // Blob URL cache (shared by Image, Audio, Video)
48
+ blobUrls: Map<string, BlobUrlEntry>;
49
+
50
+ // Image-specific
51
+ imageDimensions: Map<string, ImageDimensions>;
52
+
53
+ // Audio-specific
54
+ audioPlaybackPositions: Map<string, number>;
55
+ audioEffectConfigs: Map<string, EffectConfig>;
56
+
57
+ // Video-specific
58
+ videoStreamUrls: Map<string, { url: string; timestamp: number }>;
59
+ videoPosterUrls: Map<string, string>;
60
+ videoPlaybackPositions: Map<string, number>;
61
+ videoMetadata: Map<string, VideoMetadata>;
62
+ videoPlayerSettings: VideoPlayerSettings;
63
+ }
64
+
65
+ interface MediaCacheActions {
66
+ // Blob URL management (shared)
67
+ getOrCreateBlobUrl: (
68
+ key: string,
69
+ content: ArrayBuffer,
70
+ mimeType: string
71
+ ) => string;
72
+ releaseBlobUrl: (key: string) => void;
73
+ hasBlobUrl: (key: string) => boolean;
74
+
75
+ // Image actions
76
+ cacheDimensions: (src: string, dims: ImageDimensions) => void;
77
+ getDimensions: (src: string) => ImageDimensions | null;
78
+
79
+ // Audio actions
80
+ saveAudioPosition: (uri: string, time: number) => void;
81
+ getAudioPosition: (uri: string) => number | null;
82
+ getEffectConfig: (key: string) => EffectConfig | null;
83
+ cacheEffectConfig: (key: string, config: EffectConfig) => void;
84
+
85
+ // Video actions
86
+ getOrCreateStreamUrl: (
87
+ sessionId: string,
88
+ path: string,
89
+ generator: (sessionId: string, path: string) => string
90
+ ) => string;
91
+ getPosterUrl: (title: string) => string | null;
92
+ cachePosterUrl: (title: string, url: string) => void;
93
+ saveVideoPosition: (key: string, time: number) => void;
94
+ getVideoPosition: (key: string) => number | null;
95
+ cacheVideoMetadata: (url: string, meta: VideoMetadata) => void;
96
+ getVideoMetadata: (url: string) => VideoMetadata | null;
97
+ invalidateSession: (sessionId: string) => void;
98
+ getVideoPlayerSettings: () => VideoPlayerSettings;
99
+ saveVideoPlayerSettings: (settings: Partial<VideoPlayerSettings>) => void;
100
+
101
+ // Global
102
+ clearCache: () => void;
103
+ getCacheStats: () => {
104
+ blobUrls: number;
105
+ dimensions: number;
106
+ audioPositions: number;
107
+ videoPositions: number;
108
+ };
109
+ }
110
+
111
+ type MediaCacheStore = MediaCacheState & MediaCacheActions;
112
+
113
+ // Initial state
114
+ const DEFAULT_VIDEO_SETTINGS: VideoPlayerSettings = {
115
+ volume: 1,
116
+ isLooping: false,
117
+ };
118
+
119
+ const initialState: MediaCacheState = {
120
+ blobUrls: new Map(),
121
+ imageDimensions: new Map(),
122
+ audioPlaybackPositions: new Map(),
123
+ audioEffectConfigs: new Map(),
124
+ videoStreamUrls: new Map(),
125
+ videoPosterUrls: new Map(),
126
+ videoPlaybackPositions: new Map(),
127
+ videoMetadata: new Map(),
128
+ videoPlayerSettings: DEFAULT_VIDEO_SETTINGS,
129
+ };
130
+
131
+ export const useMediaCacheStore = create<MediaCacheStore>()(
132
+ devtools(
133
+ persist(
134
+ (set, get) => ({
135
+ ...initialState,
136
+
137
+ // ========== Blob URL Management ==========
138
+
139
+ getOrCreateBlobUrl: (key, content, mimeType) => {
140
+ const existing = get().blobUrls.get(key);
141
+ if (existing) {
142
+ // Increment ref count
143
+ cacheDebug.debug(`Blob URL reused: ${key}`, { refCount: existing.refCount + 1 });
144
+ set(
145
+ (state) => ({
146
+ blobUrls: new Map(state.blobUrls).set(key, {
147
+ ...existing,
148
+ refCount: existing.refCount + 1,
149
+ }),
150
+ }),
151
+ false,
152
+ 'blobUrl/incrementRef'
153
+ );
154
+ return existing.url;
155
+ }
156
+
157
+ // Create new blob URL
158
+ const blob = new Blob([content], { type: mimeType });
159
+ const url = URL.createObjectURL(blob);
160
+ const sizeMB = (content.byteLength / 1024 / 1024).toFixed(2);
161
+ cacheDebug.debug(`Blob URL created: ${key}`, { mimeType, size: `${sizeMB}MB` });
162
+ set(
163
+ (state) => ({
164
+ blobUrls: new Map(state.blobUrls).set(key, {
165
+ url,
166
+ refCount: 1,
167
+ createdAt: Date.now(),
168
+ }),
169
+ }),
170
+ false,
171
+ 'blobUrl/create'
172
+ );
173
+ return url;
174
+ },
175
+
176
+ releaseBlobUrl: (key) => {
177
+ const entry = get().blobUrls.get(key);
178
+ if (!entry) return;
179
+
180
+ if (entry.refCount <= 1) {
181
+ // Last reference - revoke and remove
182
+ cacheDebug.debug(`Blob URL revoked: ${key}`);
183
+ URL.revokeObjectURL(entry.url);
184
+ set(
185
+ (state) => {
186
+ const newMap = new Map(state.blobUrls);
187
+ newMap.delete(key);
188
+ return { blobUrls: newMap };
189
+ },
190
+ false,
191
+ 'blobUrl/revoke'
192
+ );
193
+ } else {
194
+ // Decrement ref count
195
+ cacheDebug.debug(`Blob URL released: ${key}`, { refCount: entry.refCount - 1 });
196
+ set(
197
+ (state) => ({
198
+ blobUrls: new Map(state.blobUrls).set(key, {
199
+ ...entry,
200
+ refCount: entry.refCount - 1,
201
+ }),
202
+ }),
203
+ false,
204
+ 'blobUrl/decrementRef'
205
+ );
206
+ }
207
+ },
208
+
209
+ hasBlobUrl: (key) => get().blobUrls.has(key),
210
+
211
+ // ========== Image Cache ==========
212
+
213
+ cacheDimensions: (src, dims) => {
214
+ set(
215
+ (state) => ({
216
+ imageDimensions: new Map(state.imageDimensions).set(src, dims),
217
+ }),
218
+ false,
219
+ 'image/cacheDimensions'
220
+ );
221
+ },
222
+
223
+ getDimensions: (src) => get().imageDimensions.get(src) ?? null,
224
+
225
+ // ========== Audio Cache ==========
226
+
227
+ saveAudioPosition: (uri, time) => {
228
+ set(
229
+ (state) => ({
230
+ audioPlaybackPositions: new Map(state.audioPlaybackPositions).set(
231
+ uri,
232
+ time
233
+ ),
234
+ }),
235
+ false,
236
+ 'audio/savePosition'
237
+ );
238
+ },
239
+
240
+ getAudioPosition: (uri) =>
241
+ get().audioPlaybackPositions.get(uri) ?? null,
242
+
243
+ getEffectConfig: (key) => get().audioEffectConfigs.get(key) ?? null,
244
+
245
+ cacheEffectConfig: (key, config) => {
246
+ set(
247
+ (state) => ({
248
+ audioEffectConfigs: new Map(state.audioEffectConfigs).set(
249
+ key,
250
+ config
251
+ ),
252
+ }),
253
+ false,
254
+ 'audio/cacheEffectConfig'
255
+ );
256
+ },
257
+
258
+ // ========== Video Cache ==========
259
+
260
+ getOrCreateStreamUrl: (sessionId, path, generator) => {
261
+ const key = `${sessionId}:${path}`;
262
+ const cached = get().videoStreamUrls.get(key);
263
+
264
+ // Return if fresh
265
+ if (cached && Date.now() - cached.timestamp < STREAM_URL_TTL) {
266
+ return cached.url;
267
+ }
268
+
269
+ // Generate and cache
270
+ const url = generator(sessionId, path);
271
+ set(
272
+ (state) => ({
273
+ videoStreamUrls: new Map(state.videoStreamUrls).set(key, {
274
+ url,
275
+ timestamp: Date.now(),
276
+ }),
277
+ }),
278
+ false,
279
+ 'video/cacheStreamUrl'
280
+ );
281
+ return url;
282
+ },
283
+
284
+ getPosterUrl: (title) => get().videoPosterUrls.get(title) ?? null,
285
+
286
+ cachePosterUrl: (title, url) => {
287
+ set(
288
+ (state) => ({
289
+ videoPosterUrls: new Map(state.videoPosterUrls).set(title, url),
290
+ }),
291
+ false,
292
+ 'video/cachePosterUrl'
293
+ );
294
+ },
295
+
296
+ saveVideoPosition: (key, time) => {
297
+ set(
298
+ (state) => ({
299
+ videoPlaybackPositions: new Map(state.videoPlaybackPositions).set(
300
+ key,
301
+ time
302
+ ),
303
+ }),
304
+ false,
305
+ 'video/savePosition'
306
+ );
307
+ },
308
+
309
+ getVideoPosition: (key) =>
310
+ get().videoPlaybackPositions.get(key) ?? null,
311
+
312
+ cacheVideoMetadata: (url, meta) => {
313
+ set(
314
+ (state) => ({
315
+ videoMetadata: new Map(state.videoMetadata).set(url, meta),
316
+ }),
317
+ false,
318
+ 'video/cacheMetadata'
319
+ );
320
+ },
321
+
322
+ getVideoMetadata: (url) => get().videoMetadata.get(url) ?? null,
323
+
324
+ invalidateSession: (sessionId) => {
325
+ set(
326
+ (state) => {
327
+ const newUrls = new Map(state.videoStreamUrls);
328
+ for (const [key] of newUrls) {
329
+ if (key.startsWith(`${sessionId}:`)) {
330
+ newUrls.delete(key);
331
+ }
332
+ }
333
+ return { videoStreamUrls: newUrls };
334
+ },
335
+ false,
336
+ 'video/invalidateSession'
337
+ );
338
+ },
339
+
340
+ getVideoPlayerSettings: () => get().videoPlayerSettings,
341
+
342
+ saveVideoPlayerSettings: (settings) => {
343
+ set(
344
+ (state) => ({
345
+ videoPlayerSettings: { ...state.videoPlayerSettings, ...settings },
346
+ }),
347
+ false,
348
+ 'video/savePlayerSettings'
349
+ );
350
+ },
351
+
352
+ // ========== Global ==========
353
+
354
+ clearCache: () => {
355
+ const stats = get().getCacheStats();
356
+ cacheDebug.info('Clearing cache', stats);
357
+ // Revoke all blob URLs before clearing
358
+ get().blobUrls.forEach(({ url }) => URL.revokeObjectURL(url));
359
+ set(initialState, false, 'clearCache');
360
+ },
361
+
362
+ getCacheStats: () => ({
363
+ blobUrls: get().blobUrls.size,
364
+ dimensions: get().imageDimensions.size,
365
+ audioPositions: get().audioPlaybackPositions.size,
366
+ videoPositions: get().videoPlaybackPositions.size,
367
+ }),
368
+ }),
369
+ {
370
+ name: 'media-cache-storage',
371
+ // Only persist playback positions, poster URLs, and player settings
372
+ partialize: (state) => ({
373
+ audioPlaybackPositions: Array.from(
374
+ state.audioPlaybackPositions.entries()
375
+ ),
376
+ videoPlaybackPositions: Array.from(
377
+ state.videoPlaybackPositions.entries()
378
+ ),
379
+ videoPosterUrls: Array.from(state.videoPosterUrls.entries()),
380
+ videoPlayerSettings: state.videoPlayerSettings,
381
+ }),
382
+ // Rehydrate Maps from arrays
383
+ onRehydrateStorage: () => (state) => {
384
+ if (state) {
385
+ // Type assertion for persisted data
386
+ const persistedAudio = state.audioPlaybackPositions as unknown;
387
+ const persistedVideo = state.videoPlaybackPositions as unknown;
388
+ const persistedPosters = state.videoPosterUrls as unknown;
389
+ const persistedSettings = state.videoPlayerSettings as unknown;
390
+
391
+ state.audioPlaybackPositions = new Map(
392
+ Array.isArray(persistedAudio)
393
+ ? (persistedAudio as [string, number][])
394
+ : []
395
+ );
396
+ state.videoPlaybackPositions = new Map(
397
+ Array.isArray(persistedVideo)
398
+ ? (persistedVideo as [string, number][])
399
+ : []
400
+ );
401
+ state.videoPosterUrls = new Map(
402
+ Array.isArray(persistedPosters)
403
+ ? (persistedPosters as [string, string][])
404
+ : []
405
+ );
406
+ // Merge persisted settings with defaults (handles missing fields)
407
+ state.videoPlayerSettings = {
408
+ ...DEFAULT_VIDEO_SETTINGS,
409
+ ...(persistedSettings && typeof persistedSettings === 'object'
410
+ ? (persistedSettings as Partial<VideoPlayerSettings>)
411
+ : {}),
412
+ };
413
+ }
414
+ },
415
+ }
416
+ ),
417
+ { name: 'MediaCacheStore' }
418
+ )
419
+ );
420
+
421
+ // ========== Selective Hooks ==========
422
+
423
+ /**
424
+ * Hook for image-related cache operations
425
+ * Uses useShallow to prevent infinite re-renders
426
+ */
427
+ export const useImageCache = () =>
428
+ useMediaCacheStore(
429
+ useShallow((state) => ({
430
+ getOrCreateBlobUrl: state.getOrCreateBlobUrl,
431
+ releaseBlobUrl: state.releaseBlobUrl,
432
+ hasBlobUrl: state.hasBlobUrl,
433
+ cacheDimensions: state.cacheDimensions,
434
+ getDimensions: state.getDimensions,
435
+ }))
436
+ );
437
+
438
+ /**
439
+ * Hook for audio-related cache operations
440
+ * Uses useShallow to prevent infinite re-renders
441
+ */
442
+ export const useAudioCache = () =>
443
+ useMediaCacheStore(
444
+ useShallow((state) => ({
445
+ getOrCreateBlobUrl: state.getOrCreateBlobUrl,
446
+ releaseBlobUrl: state.releaseBlobUrl,
447
+ hasBlobUrl: state.hasBlobUrl,
448
+ saveAudioPosition: state.saveAudioPosition,
449
+ getAudioPosition: state.getAudioPosition,
450
+ getEffectConfig: state.getEffectConfig,
451
+ cacheEffectConfig: state.cacheEffectConfig,
452
+ }))
453
+ );
454
+
455
+ /**
456
+ * Hook for video-related cache operations
457
+ * Uses useShallow to prevent infinite re-renders
458
+ */
459
+ export const useVideoCache = () =>
460
+ useMediaCacheStore(
461
+ useShallow((state) => ({
462
+ getOrCreateBlobUrl: state.getOrCreateBlobUrl,
463
+ releaseBlobUrl: state.releaseBlobUrl,
464
+ hasBlobUrl: state.hasBlobUrl,
465
+ getOrCreateStreamUrl: state.getOrCreateStreamUrl,
466
+ getPosterUrl: state.getPosterUrl,
467
+ cachePosterUrl: state.cachePosterUrl,
468
+ saveVideoPosition: state.saveVideoPosition,
469
+ getVideoPosition: state.getVideoPosition,
470
+ cacheVideoMetadata: state.cacheVideoMetadata,
471
+ getVideoMetadata: state.getVideoMetadata,
472
+ invalidateSession: state.invalidateSession,
473
+ getVideoPlayerSettings: state.getVideoPlayerSettings,
474
+ saveVideoPlayerSettings: state.saveVideoPlayerSettings,
475
+ }))
476
+ );
477
+
478
+ /**
479
+ * Hook for video player settings only
480
+ * Returns current settings and save function
481
+ */
482
+ export const useVideoPlayerSettings = () =>
483
+ useMediaCacheStore(
484
+ useShallow((state) => ({
485
+ settings: state.videoPlayerSettings,
486
+ saveSettings: state.saveVideoPlayerSettings,
487
+ }))
488
+ );
489
+
490
+ /**
491
+ * Hook for blob URL cleanup on unmount
492
+ */
493
+ export function useBlobUrlCleanup(key: string | null) {
494
+ const releaseBlobUrl = useMediaCacheStore((s) => s.releaseBlobUrl);
495
+
496
+ // useEffect must run unconditionally — the previous `if (typeof window ...)`
497
+ // guard plus a render-time `require('react')` meant the hook ran on the
498
+ // client but not on the server, so the hook count differed between the SSR
499
+ // and hydration renders (rules-of-hooks / #310). The effect is a no-op on the
500
+ // server anyway (effects never run there), and the `key` null-check below
501
+ // already handles the "nothing to release" case.
502
+ useEffect(() => {
503
+ return () => {
504
+ if (key) {
505
+ releaseBlobUrl(key);
506
+ }
507
+ };
508
+ }, [key, releaseBlobUrl]);
509
+ }
510
+
511
+ // ========== Utilities ==========
512
+
513
+ /**
514
+ * Generate a cache key from ArrayBuffer content
515
+ * Uses first and last 1KB + length for fast hashing
516
+ */
517
+ export function generateContentKey(content: ArrayBuffer): string {
518
+ const arr = new Uint8Array(content);
519
+ const len = arr.length;
520
+
521
+ // Take first 1KB
522
+ const start = arr.slice(0, Math.min(1024, len));
523
+ // Take last 1KB
524
+ const end = arr.slice(Math.max(0, len - 1024));
525
+
526
+ // Simple hash from bytes. `for..of` over a Uint8Array yields `number`;
527
+ // indexing it yields `number | undefined` under noUncheckedIndexedAccess.
528
+ let hash = len;
529
+ for (const byte of start) {
530
+ hash = ((hash << 5) - hash + byte) | 0;
531
+ }
532
+ for (const byte of end) {
533
+ hash = ((hash << 5) - hash + byte) | 0;
534
+ }
535
+
536
+ return `blob-${len}-${hash.toString(16)}`;
537
+ }
@@ -0,0 +1,6 @@
1
+ // Markdown for a reader, outside any conversation: a news post, a file preview.
2
+ //
3
+ // Sibling to `chat`'s `Response`, which renders the user's own agent and
4
+ // relaxes link safety accordingly. This one does not — see `markdown.tsx`.
5
+
6
+ export { Markdown, type MarkdownProps, type MarkdownSize } from './markdown';
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import { code } from '@streamdown/code';
4
+ import { harden } from 'rehype-harden';
5
+ import { memo } from 'react';
6
+ import { Streamdown } from 'streamdown';
7
+
8
+ import { DiagramSource, diagramPlugin } from '../diagram';
9
+
10
+ /**
11
+ * Markdown a READER is shown, outside any conversation.
12
+ *
13
+ * Sibling to `chat`'s `Response`, deliberately not the same component. Both
14
+ * wrap `streamdown`; they differ on the question that decides a markdown
15
+ * renderer's configuration — WHO WROTE THIS.
16
+ *
17
+ * `Response` renders the user's own agent, on the user's own machine. What
18
+ * reaches this component was written somewhere else: a news post from the
19
+ * platform, a file the reader opened. The difference shows in the PROTOCOL
20
+ * list below — this one grants no app scheme — and it is why there are two
21
+ * components rather than one with a `trusted` prop, a boolean that silently
22
+ * relaxes link handling being the prop most likely to be passed wrong.
23
+ *
24
+ * It does NOT show in `linkSafety`, which both switch off. That default turns
25
+ * every link into a `<button>` with no `href` and an "are you sure" modal, and
26
+ * measuring it here is what settled the question: the protocols it would guard
27
+ * are already refused by `harden` one layer up, so on this surface it adds a
28
+ * second click to every link in a news post and no protection at all.
29
+ *
30
+ * No streaming, either: this text arrives whole. `Response` carries the
31
+ * incomplete-markdown machinery because a token-by-token fence must not throw;
32
+ * a document has no such state to be in.
33
+ */
34
+
35
+ /**
36
+ * Link hardening, restated because `rehype-harden` defaults `allowedProtocols`
37
+ * to EMPTY — an unrecognised scheme loses its `href` and the anchor renders as
38
+ * grey `<label> [blocked]` text mid-sentence.
39
+ *
40
+ * The list is SHORTER than the chat's on purpose: an app scheme is absent,
41
+ * because it is meaningful only where a host assigns it meaning, and nothing
42
+ * rendering a news post or a file preview does.
43
+ */
44
+ const HARDEN_OPTIONS = {
45
+ allowedLinkPrefixes: ['*'],
46
+ allowedImagePrefixes: ['*'],
47
+ allowedProtocols: ['http:', 'https:', 'mailto:', 'tel:'],
48
+ allowDataImages: true,
49
+ } as const;
50
+
51
+ const REHYPE_PLUGINS = [[harden, HARDEN_OPTIONS]] as const;
52
+
53
+ /** See the note above: the modal guards protocols `harden` has already refused. */
54
+ const LINK_SAFETY = { enabled: false } as const;
55
+
56
+ /**
57
+ * The same renderers the transcript uses.
58
+ *
59
+ * A document is where a diagram most often LIVES — an agent asked to draw one
60
+ * writes a `.md` file, and the preview is the only place that file is read. So
61
+ * the surface that renders it needs the plugin as much as the chat does;
62
+ * shipping the diagram on one of the two would leave a fenced diagram in a file
63
+ * rendering as a code block, which is precisely the defect this package exists
64
+ * to fix, reproduced one surface over.
65
+ */
66
+ const PLUGINS = { code, mermaid: diagramPlugin };
67
+
68
+ /** A rejected diagram shows its source, never streamdown's red error box. */
69
+ const MERMAID_OPTIONS = { errorComponent: DiagramSource } as const;
70
+
71
+ /** Body scale. Headings track it a notch above, through the stylesheet. */
72
+ export type MarkdownSize = 'xs' | 'sm' | 'base';
73
+
74
+ const SIZE_CLASS: Record<MarkdownSize, string> = {
75
+ xs: 'text-xs',
76
+ sm: 'text-sm',
77
+ base: 'text-base',
78
+ };
79
+
80
+ export interface MarkdownProps {
81
+ /** The markdown source. Arrives whole — this surface does not stream. */
82
+ readonly children: string;
83
+ readonly size?: MarkdownSize;
84
+ readonly className?: string;
85
+ }
86
+
87
+ export const Markdown = memo(
88
+ ({ children, size = 'sm', className }: MarkdownProps) => (
89
+ <div className={`djc-markdown ${SIZE_CLASS[size]} ${className ?? ''}`} data-slot="markdown">
90
+ <Streamdown
91
+ // Static: the source is complete, so the incomplete-markdown
92
+ // completion that a chat needs would only be guessing at text that is
93
+ // not going to change.
94
+ mode="static"
95
+ parseIncompleteMarkdown={false}
96
+ plugins={PLUGINS}
97
+ mermaid={MERMAID_OPTIONS}
98
+ linkSafety={LINK_SAFETY}
99
+ rehypePlugins={REHYPE_PLUGINS as never}
100
+ >
101
+ {children}
102
+ </Streamdown>
103
+ </div>
104
+ ),
105
+ // Same comparator reasoning as the chat's: parsing markdown is the expensive
106
+ // step, and a parent re-rendering for an unrelated reason must not repeat it.
107
+ (prev, next) =>
108
+ prev.children === next.children &&
109
+ prev.size === next.size &&
110
+ prev.className === next.className,
111
+ );
112
+
113
+ Markdown.displayName = 'Markdown';