From bca2f3c6021fc741754c309fd6d523de39570d48 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Fri, 10 Apr 2026 14:18:53 +0000 Subject: [PATCH 1/2] feat: render loading placeholder for incomplete streaming images Incomplete images during streaming (e.g. `![alt](https://exampl`) now render an animated skeleton placeholder instead of being silently removed. Changes: - remend: emit `![alt](streamdown:incomplete-image)` for incomplete images instead of stripping them entirely, in both handleIncompleteUrl() and handleIncompleteText() - remend: add earlyReturn check for `streamdown:incomplete-image` marker - streamdown: ImageComponent renders animate-pulse skeleton div when src === 'streamdown:incomplete-image' - Tests updated to reflect new placeholder behavior Mirrors the existing incomplete link handling (streamdown:incomplete-link). Closes #503 --- .changeset/incomplete-image-placeholder.md | 6 ++ packages/remend/__tests__/images.test.ts | 30 +++++++--- packages/remend/__tests__/links.test.ts | 13 +++-- packages/remend/src/index.ts | 6 +- packages/remend/src/link-image-handler.ts | 21 ++++--- .../streamdown/__tests__/components.test.tsx | 21 +++++++ packages/streamdown/__tests__/image.test.tsx | 55 +++++++++++++++++++ packages/streamdown/lib/image.tsx | 19 +++++++ 8 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 .changeset/incomplete-image-placeholder.md diff --git a/.changeset/incomplete-image-placeholder.md b/.changeset/incomplete-image-placeholder.md new file mode 100644 index 00000000..2fdaff41 --- /dev/null +++ b/.changeset/incomplete-image-placeholder.md @@ -0,0 +1,6 @@ +--- +"remend": patch +"streamdown": patch +--- + +Incomplete images during streaming now render a loading placeholder instead of being removed entirely. Incomplete images (e.g. `![alt](https://exampl`) are replaced with `![alt](streamdown:incomplete-image)` by remend, and the streamdown `ImageComponent` renders an animated skeleton for this special URL. This mirrors the existing behavior for incomplete links (`streamdown:incomplete-link`). diff --git a/packages/remend/__tests__/images.test.ts b/packages/remend/__tests__/images.test.ts index 0d4cdf02..70eba916 100644 --- a/packages/remend/__tests__/images.test.ts +++ b/packages/remend/__tests__/images.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import remend from "../src"; describe("image handling", () => { - it("should remove incomplete images", () => { - expect(remend("Text with ![incomplete image")).toBe("Text with "); - expect(remend("![partial")).toBe(""); + it("should replace incomplete images with placeholder", () => { + expect(remend("Text with ![incomplete image")).toBe( + "Text with ![incomplete image](streamdown:incomplete-image)" + ); + expect(remend("![partial")).toBe("![partial](streamdown:incomplete-image)"); }); it("should keep complete images unchanged", () => { @@ -13,17 +15,27 @@ describe("image handling", () => { }); it("should handle partial image at chunk boundary", () => { - expect(remend("See ![the diag")).toBe("See "); - // Images with partial URLs should be removed (images can't show skeleton) - expect(remend("![logo](./assets/log")).toBe(""); + expect(remend("See ![the diag")).toBe( + "See ![the diag](streamdown:incomplete-image)" + ); + // Images with partial URLs should use placeholder (not removed) + expect(remend("![logo](./assets/log")).toBe( + "![logo](streamdown:incomplete-image)" + ); }); it("should handle nested brackets in incomplete images", () => { // When findMatchingClosingBracket returns -1 for an image (lines 74-79) // For this to happen, we need an opening bracket with a ] but no proper matching - expect(remend("Text ![outer [inner]")).toBe("Text "); - expect(remend("![nested [brackets] text")).toBe(""); - expect(remend("Start ![foo [bar] baz")).toBe("Start "); + expect(remend("Text ![outer [inner]")).toBe( + "Text ![outer [inner]](streamdown:incomplete-image)" + ); + expect(remend("![nested [brackets] text")).toBe( + "![nested [brackets] text](streamdown:incomplete-image)" + ); + expect(remend("Start ![foo [bar] baz")).toBe( + "Start ![foo [bar] baz](streamdown:incomplete-image)" + ); }); it("should not add trailing underscore for images with underscores in URL (#284)", () => { diff --git a/packages/remend/__tests__/links.test.ts b/packages/remend/__tests__/links.test.ts index bc0bb04e..78399d28 100644 --- a/packages/remend/__tests__/links.test.ts +++ b/packages/remend/__tests__/links.test.ts @@ -127,10 +127,13 @@ describe("link handling with linkMode: text-only", () => { ); }); - it("should still remove incomplete images", () => { - // Images should still be removed entirely, regardless of linkMode - // Note: the space before the image is preserved - expect(remend("Text ![incomplete image", textOnlyOptions)).toBe("Text "); - expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe("Text "); + it("should still use placeholder for incomplete images regardless of linkMode", () => { + // Images use placeholder even in text-only mode (images can't show text-only) + expect(remend("Text ![incomplete image", textOnlyOptions)).toBe( + "Text ![incomplete image](streamdown:incomplete-image)" + ); + expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe( + "Text ![alt](streamdown:incomplete-image)" + ); }); }); diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts index 005c9fea..7a71109f 100644 --- a/packages/remend/src/index.ts +++ b/packages/remend/src/index.ts @@ -57,7 +57,7 @@ export interface RemendOptions { handlers?: RemendHandler[]; /** Strip incomplete HTML tags at end of streaming text (e.g., `text result.endsWith("](streamdown:incomplete-link)"), + earlyReturn: (result) => + result.endsWith("](streamdown:incomplete-link)") || + result.endsWith("](streamdown:incomplete-image)"), }, { handler: { diff --git a/packages/remend/src/link-image-handler.ts b/packages/remend/src/link-image-handler.ts index 067fd45d..b760b9b6 100644 --- a/packages/remend/src/link-image-handler.ts +++ b/packages/remend/src/link-image-handler.ts @@ -32,17 +32,19 @@ const handleIncompleteUrl = ( // Extract everything before this link/image const beforeLink = text.substring(0, startIndex); + // Extract the text between [ and ] (alt text for images, link text for links) + const altOrLinkText = text.substring(openBracketIndex + 1, lastParenIndex); + if (isImage) { - // For images with incomplete URLs, remove them entirely - return beforeLink; + // For images with incomplete URLs, replace with placeholder marker + return `${beforeLink}![${altOrLinkText}](streamdown:incomplete-image)`; } // For links with incomplete URLs, handle based on linkMode - const linkText = text.substring(openBracketIndex + 1, lastParenIndex); if (linkMode === "text-only") { - return `${beforeLink}${linkText}`; + return `${beforeLink}${altOrLinkText}`; } - return `${beforeLink}[${linkText}](streamdown:incomplete-link)`; + return `${beforeLink}[${altOrLinkText}](streamdown:incomplete-link)`; }; // Helper to find the first incomplete [ (for text-only mode) @@ -91,8 +93,9 @@ const handleIncompleteText = ( const beforeLink = text.substring(0, openIndex); if (isImage) { - // For images, we remove them as they can't show skeleton - return beforeLink; + // For images with incomplete alt text, replace with placeholder marker + const altText = text.substring(i + 1); + return `${beforeLink}![${altText}](streamdown:incomplete-image)`; } // For links, handle based on linkMode @@ -115,7 +118,9 @@ const handleIncompleteText = ( const beforeLink = text.substring(0, openIndex); if (isImage) { - return beforeLink; + // For images with no matching closing bracket, replace with placeholder marker + const altText = text.substring(i + 1); + return `${beforeLink}![${altText}](streamdown:incomplete-image)`; } if (linkMode === "text-only") { diff --git a/packages/streamdown/__tests__/components.test.tsx b/packages/streamdown/__tests__/components.test.tsx index 9c62b6d8..4c6c0efd 100644 --- a/packages/streamdown/__tests__/components.test.tsx +++ b/packages/streamdown/__tests__/components.test.tsx @@ -199,6 +199,27 @@ describe("Markdown Components", () => { expect(link?.textContent).toBe("Incomplete link text"); }); + it("should render incomplete image placeholder when src is streamdown:incomplete-image", () => { + const Img = components.img; + if (!Img) { + throw new Error("Img component not found"); + } + const { container } = render( + loading + ); + const placeholder = container.querySelector( + '[data-streamdown="image-placeholder"]' + ); + expect(placeholder).toBeTruthy(); + + const wrapper = container.querySelector('[data-streamdown="image-wrapper"]'); + expect(wrapper?.getAttribute("data-incomplete")).toBe("true"); + }); + it("should render blockquote with correct classes", () => { const Blockquote = components.blockquote; if (!Blockquote) { diff --git a/packages/streamdown/__tests__/image.test.tsx b/packages/streamdown/__tests__/image.test.tsx index 88931292..15735f41 100644 --- a/packages/streamdown/__tests__/image.test.tsx +++ b/packages/streamdown/__tests__/image.test.tsx @@ -414,3 +414,58 @@ describe("ImageComponent", () => { expect(img?.getAttribute("data-testid")).toBe("custom-image"); }); }); + +describe("incomplete image placeholder", () => { + it("should render a placeholder when src is streamdown:incomplete-image", () => { + const { container } = render( + + ); + + // Should NOT render an img tag + const img = container.querySelector('img[data-streamdown="image"]'); + expect(img).toBeNull(); + + // Should render the placeholder div + const placeholder = container.querySelector( + '[data-streamdown="image-placeholder"]' + ); + expect(placeholder).toBeTruthy(); + + // Wrapper should have data-incomplete="true" + const wrapper = container.querySelector('[data-streamdown="image-wrapper"]'); + expect(wrapper?.getAttribute("data-incomplete")).toBe("true"); + }); + + it("should not render download button for incomplete images", () => { + const { container } = render( + + ); + + const downloadButton = container.querySelector("button"); + expect(downloadButton).toBeNull(); + }); + + it("should render placeholder with correct CSS classes for animation", () => { + const { container } = render( + + ); + + const placeholder = container.querySelector( + '[data-streamdown="image-placeholder"]' + ); + expect(placeholder?.className).toContain("animate-pulse"); + expect(placeholder?.className).toContain("rounded-lg"); + }); +}); diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx index b6290772..c05c82cf 100644 --- a/packages/streamdown/lib/image.tsx +++ b/packages/streamdown/lib/image.tsx @@ -118,6 +118,25 @@ export const ImageComponent = ({ return null; } + const isIncomplete = src === "streamdown:incomplete-image"; + + if (isIncomplete) { + return ( +
+
+
+ ); + } + return (
Date: Sun, 30 Aug 2026 17:02:37 +0000 Subject: [PATCH 2/2] fix: use data URI placeholder for incomplete images Replace the streamdown:incomplete-image custom-scheme marker with a 1x1 transparent PNG data: URI. Non-http(s)/data URL schemes can be stripped by strict URL-sanitizing rehype/remark plugins (e.g. rehype-harden), which would prevent the placeholder from ever reaching the ImageComponent's src check. Addresses review feedback from @farnabaz on #504. --- packages/remend/__tests__/images.test.ts | 16 +++++++++------- packages/remend/__tests__/links.test.ts | 4 ++-- packages/remend/src/index.ts | 8 ++++++-- packages/remend/src/link-image-handler.ts | 13 ++++++++++--- .../streamdown/__tests__/components.test.tsx | 8 +++++--- packages/streamdown/__tests__/image.test.tsx | 12 +++++++----- packages/streamdown/lib/image.tsx | 7 +++---- 7 files changed, 42 insertions(+), 26 deletions(-) diff --git a/packages/remend/__tests__/images.test.ts b/packages/remend/__tests__/images.test.ts index 70eba916..e66cfd38 100644 --- a/packages/remend/__tests__/images.test.ts +++ b/packages/remend/__tests__/images.test.ts @@ -4,9 +4,11 @@ import remend from "../src"; describe("image handling", () => { it("should replace incomplete images with placeholder", () => { expect(remend("Text with ![incomplete image")).toBe( - "Text with ![incomplete image](streamdown:incomplete-image)" + "Text with ![incomplete image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" + ); + expect(remend("![partial")).toBe( + "![partial](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); - expect(remend("![partial")).toBe("![partial](streamdown:incomplete-image)"); }); it("should keep complete images unchanged", () => { @@ -16,11 +18,11 @@ describe("image handling", () => { it("should handle partial image at chunk boundary", () => { expect(remend("See ![the diag")).toBe( - "See ![the diag](streamdown:incomplete-image)" + "See ![the diag](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); // Images with partial URLs should use placeholder (not removed) expect(remend("![logo](./assets/log")).toBe( - "![logo](streamdown:incomplete-image)" + "![logo](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); }); @@ -28,13 +30,13 @@ describe("image handling", () => { // When findMatchingClosingBracket returns -1 for an image (lines 74-79) // For this to happen, we need an opening bracket with a ] but no proper matching expect(remend("Text ![outer [inner]")).toBe( - "Text ![outer [inner]](streamdown:incomplete-image)" + "Text ![outer [inner]](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); expect(remend("![nested [brackets] text")).toBe( - "![nested [brackets] text](streamdown:incomplete-image)" + "![nested [brackets] text](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); expect(remend("Start ![foo [bar] baz")).toBe( - "Start ![foo [bar] baz](streamdown:incomplete-image)" + "Start ![foo [bar] baz](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); }); diff --git a/packages/remend/__tests__/links.test.ts b/packages/remend/__tests__/links.test.ts index 78399d28..b4c487d1 100644 --- a/packages/remend/__tests__/links.test.ts +++ b/packages/remend/__tests__/links.test.ts @@ -130,10 +130,10 @@ describe("link handling with linkMode: text-only", () => { it("should still use placeholder for incomplete images regardless of linkMode", () => { // Images use placeholder even in text-only mode (images can't show text-only) expect(remend("Text ![incomplete image", textOnlyOptions)).toBe( - "Text ![incomplete image](streamdown:incomplete-image)" + "Text ![incomplete image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); expect(remend("Text ![alt](http://partial", textOnlyOptions)).toBe( - "Text ![alt](streamdown:incomplete-image)" + "Text ![alt](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=)" ); }); }); diff --git a/packages/remend/src/index.ts b/packages/remend/src/index.ts index 7a71109f..8c2d4932 100644 --- a/packages/remend/src/index.ts +++ b/packages/remend/src/index.ts @@ -14,14 +14,18 @@ import { } from "./katex-handler"; import { handleIncompleteLinksAndImages, + INCOMPLETE_IMAGE_PLACEHOLDER, type LinkMode, } from "./link-image-handler"; import { handleIncompleteSetextHeading } from "./setext-heading-handler"; import { handleSingleTildeEscape } from "./single-tilde-handler"; import { handleIncompleteStrikethrough } from "./strikethrough-handler"; -export type { LinkMode } from "./link-image-handler"; // biome-ignore lint/performance/noBarrelFile: "Re-exports utility functions for public API convenience" +export { + INCOMPLETE_IMAGE_PLACEHOLDER, + type LinkMode, +} from "./link-image-handler"; export { isWithinCodeBlock, isWithinLinkOrImageUrl, @@ -158,7 +162,7 @@ const builtInHandlers: Array<{ optionKey: "links", earlyReturn: (result) => result.endsWith("](streamdown:incomplete-link)") || - result.endsWith("](streamdown:incomplete-image)"), + result.endsWith(`](${INCOMPLETE_IMAGE_PLACEHOLDER})`), }, { handler: { diff --git a/packages/remend/src/link-image-handler.ts b/packages/remend/src/link-image-handler.ts index b760b9b6..c457202f 100644 --- a/packages/remend/src/link-image-handler.ts +++ b/packages/remend/src/link-image-handler.ts @@ -6,6 +6,13 @@ import { export type LinkMode = "protocol" | "text-only"; +// 1x1 transparent PNG data URI used as a placeholder src for incomplete images. +// Using a data: URI (instead of a custom streamdown: scheme) ensures the +// placeholder survives strict URL-sanitizing rehype/remark plugins (e.g. +// rehype-harden) that only allow http(s)/data schemes for image src. +export const INCOMPLETE_IMAGE_PLACEHOLDER = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + // Helper function to handle incomplete URLs in links/images const handleIncompleteUrl = ( text: string, @@ -37,7 +44,7 @@ const handleIncompleteUrl = ( if (isImage) { // For images with incomplete URLs, replace with placeholder marker - return `${beforeLink}![${altOrLinkText}](streamdown:incomplete-image)`; + return `${beforeLink}![${altOrLinkText}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } // For links with incomplete URLs, handle based on linkMode @@ -95,7 +102,7 @@ const handleIncompleteText = ( if (isImage) { // For images with incomplete alt text, replace with placeholder marker const altText = text.substring(i + 1); - return `${beforeLink}![${altText}](streamdown:incomplete-image)`; + return `${beforeLink}![${altText}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } // For links, handle based on linkMode @@ -120,7 +127,7 @@ const handleIncompleteText = ( if (isImage) { // For images with no matching closing bracket, replace with placeholder marker const altText = text.substring(i + 1); - return `${beforeLink}![${altText}](streamdown:incomplete-image)`; + return `${beforeLink}![${altText}](${INCOMPLETE_IMAGE_PLACEHOLDER})`; } if (linkMode === "text-only") { diff --git a/packages/streamdown/__tests__/components.test.tsx b/packages/streamdown/__tests__/components.test.tsx index 4c6c0efd..45981d73 100644 --- a/packages/streamdown/__tests__/components.test.tsx +++ b/packages/streamdown/__tests__/components.test.tsx @@ -199,7 +199,7 @@ describe("Markdown Components", () => { expect(link?.textContent).toBe("Incomplete link text"); }); - it("should render incomplete image placeholder when src is streamdown:incomplete-image", () => { + it("should render incomplete image placeholder when src is the incomplete-image placeholder", () => { const Img = components.img; if (!Img) { throw new Error("Img component not found"); @@ -208,7 +208,7 @@ describe("Markdown Components", () => { loading ); const placeholder = container.querySelector( @@ -216,7 +216,9 @@ describe("Markdown Components", () => { ); expect(placeholder).toBeTruthy(); - const wrapper = container.querySelector('[data-streamdown="image-wrapper"]'); + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); expect(wrapper?.getAttribute("data-incomplete")).toBe("true"); }); diff --git a/packages/streamdown/__tests__/image.test.tsx b/packages/streamdown/__tests__/image.test.tsx index 15735f41..238c2a69 100644 --- a/packages/streamdown/__tests__/image.test.tsx +++ b/packages/streamdown/__tests__/image.test.tsx @@ -416,12 +416,12 @@ describe("ImageComponent", () => { }); describe("incomplete image placeholder", () => { - it("should render a placeholder when src is streamdown:incomplete-image", () => { + it("should render a placeholder when src is the incomplete-image placeholder", () => { const { container } = render( ); @@ -436,7 +436,9 @@ describe("incomplete image placeholder", () => { expect(placeholder).toBeTruthy(); // Wrapper should have data-incomplete="true" - const wrapper = container.querySelector('[data-streamdown="image-wrapper"]'); + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); expect(wrapper?.getAttribute("data-incomplete")).toBe("true"); }); @@ -445,7 +447,7 @@ describe("incomplete image placeholder", () => { ); @@ -458,7 +460,7 @@ describe("incomplete image placeholder", () => { ); diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx index c05c82cf..5d265158 100644 --- a/packages/streamdown/lib/image.tsx +++ b/packages/streamdown/lib/image.tsx @@ -1,5 +1,6 @@ import type { DetailedHTMLProps, ImgHTMLAttributes } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { INCOMPLETE_IMAGE_PLACEHOLDER } from "remend"; import { useIcons } from "./icon-context"; import type { ExtraProps } from "./markdown"; import { useCn } from "./prefix-context"; @@ -118,7 +119,7 @@ export const ImageComponent = ({ return null; } - const isIncomplete = src === "streamdown:incomplete-image"; + const isIncomplete = src === INCOMPLETE_IMAGE_PLACEHOLDER; if (isIncomplete) { return ( @@ -128,9 +129,7 @@ export const ImageComponent = ({ data-streamdown="image-wrapper" >