Skip to content

Commit 91d3515

Browse files
committed
Resolves #381
1 parent 899f089 commit 91d3515

3 files changed

Lines changed: 103 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai-elements": patch
3+
---
4+
5+
Fix JSXPreview flashing between rendered content and parse errors during streaming. Strip incomplete tags cut off mid-attribute, and fall back to last successfully rendered JSX when parse errors occur during streaming.

packages/elements/__tests__/jsx-preview.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,37 @@ describe("jSXPreview streaming mode", () => {
249249
expect(screen.getByText("Complete")).toBeInTheDocument();
250250
expect(screen.getByText("Incomplete")).toBeInTheDocument();
251251
});
252+
253+
it("strips incomplete opening tag with partial attribute", () => {
254+
render(
255+
<JSXPreview
256+
isStreaming
257+
jsx='<div><p>Done</p><span className="incomp'
258+
>
259+
<JSXPreviewContent />
260+
</JSXPreview>
261+
);
262+
expect(screen.getByText("Done")).toBeInTheDocument();
263+
});
264+
265+
it("strips incomplete tag at start of stream", () => {
266+
render(
267+
<JSXPreview isStreaming jsx='<div className="foo'>
268+
<JSXPreviewContent />
269+
</JSXPreview>
270+
);
271+
// Should render without error since the incomplete tag is stripped
272+
expect(true).toBeTruthy();
273+
});
274+
275+
it("handles tag cut off mid-name", () => {
276+
render(
277+
<JSXPreview isStreaming jsx="<div><p>Text</p><sp">
278+
<JSXPreviewContent />
279+
</JSXPreview>
280+
);
281+
expect(screen.getByText("Text")).toBeInTheDocument();
282+
});
252283
});
253284

254285
describe("jSXPreview integration", () => {

packages/elements/src/jsx-preview.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import JsxParser from "react-jsx-parser";
1919
interface JSXPreviewContextValue {
2020
jsx: string;
2121
processedJsx: string;
22+
isStreaming: boolean;
2223
error: Error | null;
2324
setError: (error: Error | null) => void;
25+
setLastGoodJsx: (jsx: string) => void;
2426
components: JsxParserProps["components"];
2527
bindings: JsxParserProps["bindings"];
2628
onErrorProp?: (error: Error) => void;
@@ -70,6 +72,22 @@ const matchJsxTag = (code: string) => {
7072
};
7173
};
7274

75+
const stripIncompleteTag = (text: string) => {
76+
// Find the last '<' that isn't part of a complete tag
77+
const lastOpen = text.lastIndexOf("<");
78+
if (lastOpen === -1) {
79+
return text;
80+
}
81+
82+
const afterOpen = text.slice(lastOpen);
83+
// If there's no closing '>' after the last '<', it's an incomplete tag
84+
if (!afterOpen.includes(">")) {
85+
return text.slice(0, lastOpen);
86+
}
87+
88+
return text;
89+
};
90+
7391
const completeJsxTag = (code: string) => {
7492
const stack: string[] = [];
7593
let result = "";
@@ -78,8 +96,8 @@ const completeJsxTag = (code: string) => {
7896
while (currentPosition < code.length) {
7997
const match = matchJsxTag(code.slice(currentPosition));
8098
if (!match) {
81-
// No more tags found, append remaining content
82-
result += code.slice(currentPosition);
99+
// No more tags found, strip any trailing incomplete tag
100+
result += stripIncompleteTag(code.slice(currentPosition));
83101
break;
84102
}
85103
const { tagName, type, endIndex } = match;
@@ -126,6 +144,7 @@ export const JSXPreview = memo(
126144
}: JSXPreviewProps) => {
127145
const [prevJsx, setPrevJsx] = useState(jsx);
128146
const [error, setError] = useState<Error | null>(null);
147+
const [lastGoodJsx, setLastGoodJsx] = useState("");
129148

130149
// Clear error when jsx changes (derived state pattern)
131150
if (jsx !== prevJsx) {
@@ -143,12 +162,23 @@ export const JSXPreview = memo(
143162
bindings,
144163
components,
145164
error,
165+
isStreaming,
146166
jsx,
147167
onErrorProp: onError,
148168
processedJsx,
149169
setError,
170+
setLastGoodJsx,
150171
}),
151-
[bindings, components, error, jsx, onError, processedJsx, setError]
172+
[
173+
bindings,
174+
components,
175+
error,
176+
isStreaming,
177+
jsx,
178+
onError,
179+
processedJsx,
180+
setError,
181+
]
152182
);
153183

154184
return (
@@ -167,14 +197,24 @@ export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
167197

168198
export const JSXPreviewContent = memo(
169199
({ className, ...props }: JSXPreviewContentProps) => {
170-
const { processedJsx, components, bindings, setError, onErrorProp } =
171-
useJSXPreview();
200+
const {
201+
processedJsx,
202+
isStreaming,
203+
components,
204+
bindings,
205+
setError,
206+
setLastGoodJsx,
207+
onErrorProp,
208+
} = useJSXPreview();
172209
const errorReportedRef = useRef<string | null>(null);
210+
const lastGoodJsxRef = useRef("");
211+
const [hadError, setHadError] = useState(false);
173212

174213
// Reset error tracking when jsx changes
175214
// biome-ignore lint/correctness/useExhaustiveDependencies: processedJsx change should reset tracking
176215
useEffect(() => {
177216
errorReportedRef.current = null;
217+
setHadError(false);
178218
}, [processedJsx]);
179219

180220
const handleError = useCallback(
@@ -184,18 +224,38 @@ export const JSXPreviewContent = memo(
184224
return;
185225
}
186226
errorReportedRef.current = processedJsx;
227+
228+
// During streaming, suppress errors and fall back to last good JSX
229+
if (isStreaming) {
230+
setHadError(true);
231+
return;
232+
}
233+
187234
setError(err);
188235
onErrorProp?.(err);
189236
},
190-
[processedJsx, onErrorProp, setError]
237+
[processedJsx, isStreaming, onErrorProp, setError]
191238
);
192239

240+
// Track the last JSX that rendered without error
241+
// biome-ignore lint/correctness/useExhaustiveDependencies: update when processedJsx changes without error
242+
useEffect(() => {
243+
if (!errorReportedRef.current) {
244+
lastGoodJsxRef.current = processedJsx;
245+
setLastGoodJsx(processedJsx);
246+
}
247+
}, [processedJsx, setLastGoodJsx]);
248+
249+
// During streaming, if the current JSX errored, re-render with last good version
250+
const displayJsx =
251+
isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
252+
193253
return (
194254
<div className={cn("jsx-preview-content", className)} {...props}>
195255
<JsxParser
196256
bindings={bindings}
197257
components={components}
198-
jsx={processedJsx}
258+
jsx={displayJsx}
199259
onError={handleError}
200260
renderInWrapper={false}
201261
/>

0 commit comments

Comments
 (0)