From 7a94428d9dd6461367973b079143159ee39420f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 15:22:17 +0000 Subject: [PATCH] fix(chat): Validate streamed analysis breakdown output Persisted and resumed chat state can surface a breakdown tool part before\nits payload is safe to render. The previous renderer assumed any truthy\noutput had an iterable points array, which crashed the page when the\nstore synced an incomplete breakdown object.\n\nValidate the breakdown shape before hiding the tool state or rendering\nthe breakdown card, and skip the breakdown UI when the paired\nperformance result reports hasData false.\n\nFixes WEBVITALS-3C\nCo-Authored-By: Claude Co-authored-by: Sergiy Dybskiy --- components/MessageRenderer.tsx | 61 +++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/components/MessageRenderer.tsx b/components/MessageRenderer.tsx index 99056f2..74ff882 100644 --- a/components/MessageRenderer.tsx +++ b/components/MessageRenderer.tsx @@ -15,7 +15,10 @@ import { import { useWebVitalsScore } from "@/contexts/WebVitalsScoreContext"; import { calculateLighthouseScore } from "@/lib/web-vitals"; -import type { AnalysisBreakdown } from "@/types/analysis-breakdown"; +import { + AnalysisBreakdownSchema, + type AnalysisBreakdown, +} from "@/types/analysis-breakdown"; import type { LighthouseScoreData, RealWorldPerformanceOutput, @@ -51,6 +54,23 @@ interface MessageRendererProps { message: UIMessage; } +function isAnalysisBreakdownOutput( + output: unknown, +): output is AnalysisBreakdown { + return AnalysisBreakdownSchema.safeParse(output).success; +} + +function isRealWorldPerformanceOutput( + output: unknown, +): output is RealWorldPerformanceOutput { + return ( + typeof output === "object" && + output !== null && + "hasData" in output && + typeof output.hasData === "boolean" + ); +} + const MessageRenderer = memo(function MessageRenderer({ message, }: MessageRendererProps) { @@ -135,6 +155,27 @@ const MessageRenderer = memo(function MessageRenderer({ messages.findIndex((m) => m.role === "user") === messages.findIndex((m) => m.id === message.id); + const hasCompletedPerformanceResult = useMemo( + () => + performanceParts.some( + (part) => + part.state === "output-available" && + isRealWorldPerformanceOutput(part.output), + ), + [performanceParts], + ); + + const hasRenderablePerformanceData = useMemo( + () => + performanceParts.some( + (part) => + part.state === "output-available" && + isRealWorldPerformanceOutput(part.output) && + part.output.hasData, + ), + [performanceParts], + ); + // Extract domain from first user message const getDomainFromFirstMessage = () => { if (!isFirstUserMessage) return null; @@ -155,9 +196,17 @@ const MessageRenderer = memo(function MessageRenderer({ // Check if all tools have completed successfully const allToolsComplete = useMemo(() => { if (toolParts.length < 3) return false; // Wait for all 3 tools before hiding - return toolParts.every( - (tool) => tool.state === "output-available" && !tool.errorText, - ); + return toolParts.every((tool) => { + if (tool.state !== "output-available" || tool.errorText) { + return false; + } + + if (tool.type === "tool-generateAnalysisBreakdown") { + return isAnalysisBreakdownOutput(tool.output); + } + + return true; + }); }, [toolParts]); // Show tools during loading or if there are errors @@ -268,7 +317,9 @@ const MessageRenderer = memo(function MessageRenderer({ {analysisBreakdownParts.map((breakdownTool, i) => { if ( breakdownTool.state === "output-available" && - breakdownTool.output + breakdownTool.output && + isAnalysisBreakdownOutput(breakdownTool.output) && + (!hasCompletedPerformanceResult || hasRenderablePerformanceData) ) { return (