Skip to content

Commit 04c0a98

Browse files
committed
fix(markdown): stabilize streaming images
Keep Markdown renderer identities stable as streamed content grows. Existing images and surrounding blocks now keep their React identity. Failed local and remote images now use an alt-text fallback. This avoids native broken-image glyphs and repeated reads. Regression tests cover DOM identity, image failures, and current links. Remote image routing remains covered.
1 parent 1bc4bf7 commit 04c0a98

4 files changed

Lines changed: 267 additions & 42 deletions

File tree

src/web-ui/src/component-library/components/Markdown/Markdown.scss

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,22 @@
475475
opacity: 0.75;
476476
}
477477

478-
.markdown-renderer .markdown-image--error {
479-
outline: 1px dashed color-mix(in srgb, var(--bf-appearance-token-color-error) 35%, transparent);
478+
.markdown-renderer .markdown-image-fallback {
479+
display: inline-flex;
480+
align-items: center;
481+
min-width: 1.25em;
482+
min-height: 1.25em;
483+
max-width: 100%;
484+
box-sizing: border-box;
485+
margin: 0.35rem 0.25rem;
486+
padding: 0 0.3em;
487+
border: 1px dashed color-mix(in srgb, var(--bf-appearance-token-color-text-muted) 35%, transparent);
488+
border-radius: var(--bf-appearance-token-flowchat-card-radius);
489+
color: var(--bf-appearance-token-color-text-muted);
490+
font-size: var(--markdown-font-size-sm);
491+
line-height: var(--markdown-support-line-height);
492+
overflow-wrap: anywhere;
493+
vertical-align: middle;
480494
}
481495

482496
.markdown-renderer .katex-display {

src/web-ui/src/component-library/components/Markdown/Markdown.test.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,144 @@ describe('Markdown file links', () => {
357357
expect(mocks.getCurrentWorkspacePath).not.toHaveBeenCalled();
358358
});
359359

360+
it('preserves existing markdown nodes while streaming content is appended', async () => {
361+
const initialContent = [
362+
'Before image',
363+
'',
364+
'![Stable diagram](stream-stable.png)',
365+
'',
366+
'After image',
367+
].join('\n');
368+
369+
await act(async () => {
370+
root.render(
371+
<Markdown
372+
content={initialContent}
373+
basePath={EXAMPLE_WORKSPACE}
374+
isStreaming
375+
onFileViewRequest={onFileViewRequest}
376+
/>,
377+
);
378+
await Promise.resolve();
379+
await Promise.resolve();
380+
});
381+
382+
const imageBefore = container.querySelector<HTMLImageElement>('img[alt="Stable diagram"]');
383+
const paragraphsBefore = Array.from(container.querySelectorAll('p'));
384+
expect(imageBefore).not.toBeNull();
385+
expect(paragraphsBefore).toHaveLength(3);
386+
expect(mocks.readFileContent).toHaveBeenCalledTimes(1);
387+
388+
await act(async () => {
389+
root.render(
390+
<Markdown
391+
content={`${initialContent}\n\nNew streamed paragraph`}
392+
basePath={EXAMPLE_WORKSPACE}
393+
isStreaming
394+
onFileViewRequest={onFileViewRequest}
395+
/>,
396+
);
397+
await Promise.resolve();
398+
});
399+
400+
const imageAfter = container.querySelector<HTMLImageElement>('img[alt="Stable diagram"]');
401+
const paragraphsAfter = Array.from(container.querySelectorAll('p'));
402+
expect(imageAfter).toBe(imageBefore);
403+
expect(paragraphsAfter).toHaveLength(4);
404+
expect(paragraphsAfter.slice(0, 3)).toEqual(paragraphsBefore);
405+
expect(mocks.readFileContent).toHaveBeenCalledTimes(1);
406+
});
407+
408+
it('renders alt text instead of a broken image when a local image read fails', async () => {
409+
mocks.readFileContent.mockRejectedValueOnce(new Error('missing image'));
410+
411+
await act(async () => {
412+
root.render(
413+
<Markdown
414+
content={'![Missing diagram](missing-stream-image.png)'}
415+
basePath={EXAMPLE_WORKSPACE}
416+
isStreaming
417+
/>,
418+
);
419+
await Promise.resolve();
420+
await Promise.resolve();
421+
await Promise.resolve();
422+
});
423+
424+
expect(container.querySelector('img[alt="Missing diagram"]')).toBeNull();
425+
const fallback = container.querySelector('[data-bf-part="imageFallback"]');
426+
expect(fallback?.textContent).toBe('Missing diagram');
427+
428+
await act(async () => {
429+
root.render(
430+
<Markdown
431+
content={'![Missing diagram](missing-stream-image.png)\n\nLater streamed text'}
432+
basePath={EXAMPLE_WORKSPACE}
433+
isStreaming
434+
/>,
435+
);
436+
await Promise.resolve();
437+
});
438+
439+
expect(container.querySelector('[data-bf-part="imageFallback"]')).toBe(fallback);
440+
expect(mocks.readFileContent).toHaveBeenCalledTimes(1);
441+
});
442+
443+
it('replaces an externally hosted image after the browser reports an error', async () => {
444+
await act(async () => {
445+
root.render(<Markdown content={'![Unavailable chart](https://example.invalid/chart.png)'} />);
446+
await Promise.resolve();
447+
});
448+
449+
const image = container.querySelector<HTMLImageElement>('img[alt="Unavailable chart"]');
450+
expect(image).not.toBeNull();
451+
452+
act(() => {
453+
image?.dispatchEvent(new Event('error'));
454+
});
455+
456+
expect(container.querySelector('img[alt="Unavailable chart"]')).toBeNull();
457+
expect(container.querySelector('[data-bf-part="imageFallback"]')?.textContent)
458+
.toBe('Unavailable chart');
459+
});
460+
461+
it('uses the latest source text and callback without remounting a file link', async () => {
462+
const firstHandler = vi.fn();
463+
const secondHandler = vi.fn();
464+
const firstPath = 'D:\\First\\README.md';
465+
const secondPath = 'E:\\Second\\README.md';
466+
467+
await act(async () => {
468+
root.render(
469+
<Markdown
470+
content={`[README.md](${firstPath})`}
471+
onFileViewRequest={firstHandler}
472+
/>,
473+
);
474+
await Promise.resolve();
475+
await Promise.resolve();
476+
});
477+
478+
const linkBefore = container.querySelector<HTMLButtonElement>('button.file-link');
479+
expect(linkBefore).not.toBeNull();
480+
481+
await act(async () => {
482+
root.render(
483+
<Markdown
484+
content={`[README.md](${secondPath})`}
485+
onFileViewRequest={secondHandler}
486+
/>,
487+
);
488+
await Promise.resolve();
489+
});
490+
491+
const linkAfter = container.querySelector<HTMLButtonElement>('button.file-link');
492+
expect(linkAfter).toBe(linkBefore);
493+
act(() => linkAfter?.click());
494+
expect(firstHandler).not.toHaveBeenCalled();
495+
expect(secondHandler).toHaveBeenCalledWith(secondPath, 'README.md', undefined);
496+
});
497+
360498
it('routes remote markdown image reads through the session connection', async () => {
361499
await act(async () => {
362500
root.render(

0 commit comments

Comments
 (0)