From 9c119540ad095cc298b7691fa6fa53c686beb905 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 07:16:02 +0000 Subject: [PATCH 1/2] fix(chat): Prevent crash during concurrent submissions Refactors the ChatPanel's handleSubmit function to prevent a race condition that caused a 'Rendered more hooks than during the previous render' error. The previous implementation updated the UI state in two separate, asynchronous steps. This created a window where the component tree could change between renders, leading to a hook mismatch and a crash when a user submitted a new message while a previous one was still being processed. The new implementation ensures atomicity by updating the UI state with both the user's message and the initial, streamable assistant response in a single, synchronous operation. This guarantees a consistent component structure across renders, eliminating the crash and enabling a stable, concurrent chat experience. Also fixes a build error in `mapbox_mcp/hooks.ts` by correcting an invalid import path. --- bun.lock | 5 ---- components/chat-panel.tsx | 50 ++++++++++++++++++++++----------------- dev.log | 12 ++++++++++ mapbox_mcp/hooks.ts | 2 +- 4 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 dev.log diff --git a/bun.lock b/bun.lock index fa5118f8..6d4207d8 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,6 @@ "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", - "QCX": ".", "ai": "^4.3.19", "build": "^0.1.4", "class-variance-authority": "^0.7.1", @@ -1032,8 +1031,6 @@ "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], - "QCX": ["QCX@file:", { "dependencies": { "@ai-sdk/amazon-bedrock": "^1.1.6", "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", "@ai-sdk/openai": "^1.3.24", "@ai-sdk/xai": "^1.2.18", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.0.1", "@mapbox/mapbox-gl-draw": "^1.5.0", "@modelcontextprotocol/sdk": "^1.13.0", "@radix-ui/react-alert-dialog": "^1.1.10", "@radix-ui/react-avatar": "^1.1.6", "@radix-ui/react-checkbox": "^1.2.2", "@radix-ui/react-collapsible": "^1.1.7", "@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.11", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-radio-group": "^1.3.4", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-tooltip": "^1.2.3", "@smithery/cli": "^1.2.5", "@smithery/sdk": "^1.0.4", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.0.0", "@tailwindcss/typography": "^0.5.16", "@turf/turf": "^7.2.0", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "QCX": ".", "ai": "^4.3.19", "build": "^0.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cookie": "^0.6.0", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", "drizzle-orm": "^0.29.0", "embla-carousel-react": "^8.6.0", "exa-js": "^1.6.13", "framer-motion": "^12.15.0", "katex": "^0.16.22", "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", "mapbox-gl": "^3.11.0", "next": "^15.3.3", "next-themes": "^0.3.0", "open-codex": "^0.1.30", "pg": "^8.16.2", "radix-ui": "^1.3.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.56.2", "react-icons": "^5.5.0", "react-markdown": "^9.1.0", "react-textarea-autosize": "^8.5.9", "react-toastify": "^10.0.6", "rehype-external-links": "^3.0.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "smithery": "^0.5.2", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "use-mcp": "^0.0.9", "uuid": "^9.0.0", "zod": "^3.23.8" }, "devDependencies": { "@types/cookie": "^0.6.0", "@types/mapbox-gl": "^3.4.1", "@types/node": "^20.17.30", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/uuid": "^9.0.0", "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-next": "^14.2.28", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "typescript": "^5.8.3" } }], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -2568,8 +2565,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "QCX/QCX": ["QCX@file:.", {}], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index b0bf2166..07f5eb50 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -69,42 +69,48 @@ export const ChatPanel = forwardRef(({ messages, i } } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!input && !selectedFile) { - return + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() && !selectedFile) { + return; } - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] + // Create the user message content first, while we still have the input and file + const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = []; if (input) { - content.push({ type: 'text', text: input }) + content.push({ type: 'text', text: input }); } if (selectedFile && selectedFile.type.startsWith('image/')) { content.push({ type: 'image', image: URL.createObjectURL(selectedFile) - }) + }); } - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - component: - } - ]) - - const formData = new FormData(e.currentTarget) + // Prepare the form data for the server action + const formData = new FormData(e.currentTarget); if (selectedFile) { - formData.append('file', selectedFile) + formData.append('file', selectedFile); } - setInput('') - clearAttachment() + // Clear the input fields for the user + setInput(''); + clearAttachment(); - const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) - } + // Call the server action. It will immediately return a streamable UI component. + const responseMessage = submit(formData); + + // Update the UI state with both the user's message and the initial assistant response + // in a single operation. This ensures the component tree structure is consistent. + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + component: , + }, + responseMessage as any, + ]); + }; const handleClear = async () => { setMessages([]) diff --git a/dev.log b/dev.log new file mode 100644 index 00000000..f66012e5 --- /dev/null +++ b/dev.log @@ -0,0 +1,12 @@ + ▲ Next.js 15.5.4 (Turbopack) + - Local: http://localhost:3000 + - Network: http://192.168.0.2:3000 + - Environments: .env + - Experiments (use with caution): + · serverActions + + ✓ Starting... +Attention: Next.js now collects completely anonymous telemetry regarding usage. +This information is used to shape Next.js' roadmap and prioritize features. +You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL: +https://nextjs.org/telemetry diff --git a/mapbox_mcp/hooks.ts b/mapbox_mcp/hooks.ts index 326056db..b1e43147 100644 --- a/mapbox_mcp/hooks.ts +++ b/mapbox_mcp/hooks.ts @@ -8,7 +8,7 @@ type Tool = { name: string; // Add other properties as needed based on your usage }; -import { getModel } from 'QCX/lib/utils'; +import { getModel } from '../lib/utils'; // Types for location and mapping data interface LocationResult { From 1e1ccc98d6a3b0ade0e54d280097b734351ce66c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:36:26 +0000 Subject: [PATCH 2/2] fix(chat): Prevent memory leak from object URLs Refactors the UserMessage component to properly manage the lifecycle of object URLs created for image previews. Previously, `URL.createObjectURL` was called in the ChatPanel component, but the resulting URL was never revoked, leading to a memory leak in the browser. This change moves the responsibility of creating and revoking the object URL into the UserMessage component. A `useEffect` hook now creates the URL when the component mounts and revokes it in the cleanup function when the component unmounts, ensuring that the memory is released correctly. --- components/chat-panel.tsx | 7 ++----- components/user-message.tsx | 40 +++++++++++++++++++++++++++++++------ dev.log | 12 ----------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 07f5eb50..a9b8c044 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -76,15 +76,12 @@ export const ChatPanel = forwardRef(({ messages, i } // Create the user message content first, while we still have the input and file - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = []; + const content: ({ type: 'text'; text: string } | { type: 'image'; image: File })[] = []; if (input) { content.push({ type: 'text', text: input }); } if (selectedFile && selectedFile.type.startsWith('image/')) { - content.push({ - type: 'image', - image: URL.createObjectURL(selectedFile) - }); + content.push({ type: 'image', image: selectedFile }); } // Prepare the form data for the server action diff --git a/components/user-message.tsx b/components/user-message.tsx index 03b8ea8d..8b9690dd 100644 --- a/components/user-message.tsx +++ b/components/user-message.tsx @@ -1,10 +1,10 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import Image from 'next/image' import { ChatShare } from './chat-share' type UserMessageContentPart = | { type: 'text'; text: string } - | { type: 'image'; image: string } // data URL + | { type: 'image'; image: string | File } // Can be a data URL or a File object type UserMessageProps = { content: string | UserMessageContentPart[] @@ -18,6 +18,7 @@ export const UserMessage: React.FC = ({ showShare = false }) => { const enableShare = process.env.ENABLE_SHARE === 'true' + const [imageUrl, setImageUrl] = useState(null) // Normalize content to an array const contentArray = @@ -27,21 +28,48 @@ export const UserMessage: React.FC = ({ const textPart = contentArray.find( (part): part is { type: 'text'; text: string } => part.type === 'text' )?.text - const imagePart = contentArray.find( - (part): part is { type: 'image'; image: string } => part.type === 'image' + const imageContent = contentArray.find( + (part): part is { type: 'image'; image: string | File } => + part.type === 'image' )?.image + useEffect(() => { + let objectUrl: string | null = null + + if (imageContent instanceof File) { + objectUrl = URL.createObjectURL(imageContent) + setImageUrl(objectUrl) + } else if (typeof imageContent === 'string') { + setImageUrl(imageContent) + } + + // Cleanup function to revoke the object URL + return () => { + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + setImageUrl(null) // Reset state on cleanup + } + } + }, [imageContent]) + return (
- {imagePart && ( + {imageUrl && (
attachment { + // Optional: Revoke URL after image has loaded to free memory sooner + // if the image is cached by the browser. + // Note: This might cause issues if the component re-renders and + // the browser needs to fetch the image again. + // URL.revokeObjectURL(imageUrl); + }} />
)} diff --git a/dev.log b/dev.log index f66012e5..e69de29b 100644 --- a/dev.log +++ b/dev.log @@ -1,12 +0,0 @@ - ▲ Next.js 15.5.4 (Turbopack) - - Local: http://localhost:3000 - - Network: http://192.168.0.2:3000 - - Environments: .env - - Experiments (use with caution): - · serverActions - - ✓ Starting... -Attention: Next.js now collects completely anonymous telemetry regarding usage. -This information is used to shape Next.js' roadmap and prioritize features. -You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL: -https://nextjs.org/telemetry