From 9c3d8e178eb94b76895517e6f26be623311fa073 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 6 Aug 2026 02:54:00 +0000 Subject: [PATCH] fix(elements): keep drop listeners stable during text input With PromptInputProvider, each keystroke made a new controller identity. The drop effects had a dependency on the add callback through that identity. Thus each keystroke removed the dragover and drop listeners and installed them again. The file-input registration effect also ran again on each keystroke. This change uses useEffectEvent for the drop path. After this change, the listeners attach one time for each mount. The effect event calls the latest add closure. The validation behavior for accept, maxFiles, and maxFileSize does not change. The registration effect has a dependency only on the stable __registerFileInput callback. Four new tests show stable listener counts during text input and successful drops after prop changes. These tests are not successful on the code before this change. --- .../elements/__tests__/prompt-input.test.tsx | 260 ++++++++++++++++++ packages/elements/src/prompt-input.tsx | 52 ++-- 2 files changed, 291 insertions(+), 21 deletions(-) diff --git a/packages/elements/__tests__/prompt-input.test.tsx b/packages/elements/__tests__/prompt-input.test.tsx index d961a3ba..1b48913e 100644 --- a/packages/elements/__tests__/prompt-input.test.tsx +++ b/packages/elements/__tests__/prompt-input.test.tsx @@ -4,6 +4,7 @@ import { userEvent } from "@testing-library/user-event"; import React from "react"; import type { AttachmentData } from "../src/attachments"; + import { Attachment, AttachmentInfo, @@ -36,6 +37,11 @@ import { const DATA_PREFIX_REGEX = /^data:/; const BLOB_PREFIX_REGEX = /^blob:/; const SUBMIT_REGEX = /submit/i; +const makeDropEvent = (files: File[]) => + Object.assign(new Event("drop", { bubbles: true, cancelable: true }), { + dataTransfer: { files, types: ["Files"] }, + }); +const DND_EVENT_TYPES = new Set(["dragover", "drop"]); // Backwards-compatibility aliases for tests (these components were moved to attachment.tsx) const PromptInputAttachment = ({ @@ -1960,6 +1966,260 @@ describe("drag and drop", () => { expect(container.querySelector("form")).toBeInTheDocument(); }); + + it("keeps document drop listeners stable while typing with a provider", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const user = userEvent.setup(); + + render( + + + + + + + + ); + + const addSpy = vi.spyOn(document, "addEventListener"); + const removeSpy = vi.spyOn(document, "removeEventListener"); + + await user.type( + screen.getByPlaceholderText("What would you like to know?"), + "hello" + ); + + expect( + addSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + expect( + removeSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("adds files dropped on the document after typing with a provider", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const user = userEvent.setup(); + + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return
{attachments.files.length}
; + }; + + render( + + + + + + + + + ); + + await user.type( + screen.getByPlaceholderText("What would you like to know?"), + "hello" + ); + + const file = new File(["image"], "test.png", { type: "image/png" }); + const dropEvent = Object.assign( + new Event("drop", { bubbles: true, cancelable: true }), + { dataTransfer: { files: [file], types: ["Files"] } } + ); + + await act(() => { + document.dispatchEvent(dropEvent); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + }); + + it("adds files dropped on the form after typing with a provider", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const user = userEvent.setup(); + + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return
{attachments.files.length}
; + }; + + const { container } = render( + + + + + + + + + ); + + // The form is always rendered by PromptInput; asserted below + const form = container.querySelector("form") as HTMLFormElement; + expect(form).toBeInTheDocument(); + + const addSpy = vi.spyOn(form, "addEventListener"); + const removeSpy = vi.spyOn(form, "removeEventListener"); + + await user.type( + screen.getByPlaceholderText("What would you like to know?"), + "hello" + ); + + expect( + addSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + expect( + removeSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + + const file = new File(["image"], "test.png", { type: "image/png" }); + const dropEvent = Object.assign( + new Event("drop", { bubbles: true, cancelable: true }), + { dataTransfer: { files: [file], types: ["Files"] } } + ); + + await act(() => { + form.dispatchEvent(dropEvent); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + }); + + it("applies the latest accept prop to drops without re-attaching listeners", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onError = vi.fn(); + const { PromptInputProvider } = await import("../src/prompt-input"); + + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return
{attachments.files.length}
; + }; + + const ui = (accept: string) => ( + + + + + + + + + ); + + const { rerender } = render(ui("image/*")); + + const addSpy = vi.spyOn(document, "addEventListener"); + + const file = new File(["text"], "notes.txt", { type: "text/plain" }); + + await act(() => { + document.dispatchEvent(makeDropEvent([file])); + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "accept" }) + ); + expect(screen.getByTestId("count")).toHaveTextContent("0"); + + rerender(ui("text/plain")); + + await act(() => { + document.dispatchEvent(makeDropEvent([file])); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + + // Listeners were never re-attached across the accept change + expect( + addSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + addSpy.mockRestore(); + }); + + it("enforces maxFiles against the current attachment count on stable listeners", async () => { + setupPromptInputTests(); + const onSubmit = vi.fn(); + const onError = vi.fn(); + const { PromptInputProvider } = await import("../src/prompt-input"); + + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + return
{attachments.files.length}
; + }; + + render( + + + + + + + + + ); + + const addSpy = vi.spyOn(document, "addEventListener"); + + const first = new File(["a"], "a.png", { type: "image/png" }); + await act(() => { + document.dispatchEvent(makeDropEvent([first])); + }); + + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + + const second = new File(["b"], "b.png", { type: "image/png" }); + const third = new File(["c"], "c.png", { type: "image/png" }); + await act(() => { + document.dispatchEvent(makeDropEvent([second, third])); + }); + + // Capacity was 1 after the first drop: one file added, overflow reported + await vi.waitFor(() => { + expect(screen.getByTestId("count")).toHaveTextContent("2"); + }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "max_files" }) + ); + + // Both drops went through listeners attached once at mount + expect( + addSpy.mock.calls.filter(([type]) => DND_EVENT_TYPES.has(type)) + ).toHaveLength(0); + addSpy.mockRestore(); + }); }); describe("paste functionality", () => { diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..2e4ff6c5 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -1,5 +1,20 @@ "use client"; +import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; +import type { + ChangeEvent, + ChangeEventHandler, + ClipboardEventHandler, + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, + PropsWithChildren, + ReactNode, + RefObject, +} from "react"; + import { Command, CommandEmpty, @@ -40,7 +55,6 @@ import { TooltipTrigger, } from "@repo/shadcn-ui/components/ui/tooltip"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; import { CornerDownLeftIcon, ImageIcon, @@ -50,25 +64,13 @@ import { XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; -import type { - ChangeEvent, - ChangeEventHandler, - ClipboardEventHandler, - ComponentProps, - FormEvent, - FormEventHandler, - HTMLAttributes, - KeyboardEventHandler, - PropsWithChildren, - ReactNode, - RefObject, -} from "react"; import { Children, createContext, useCallback, useContext, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -703,6 +705,12 @@ export const PromptInput = ({ ); const add = usingProvider ? addWithProviderValidation : addLocal; + + // Effect event so drop listeners stay stable while always calling the + // latest add (the provider controller changes identity with the text value) + const dropFiles = useEffectEvent((fileList: FileList) => { + add(fileList); + }); const remove = usingProvider ? controller.attachments.remove : removeLocal; const openFileDialog = usingProvider ? controller.attachments.openFileDialog @@ -714,12 +722,14 @@ export const PromptInput = ({ }, [clearAttachments, clearReferencedSources]); // Let provider know about our hidden file input so external menus can call openFileDialog() + const registerFileInput = controller?.__registerFileInput; + useEffect(() => { - if (!usingProvider) { + if (!registerFileInput) { return; } - controller.__registerFileInput(inputRef, () => inputRef.current?.click()); - }, [usingProvider, controller]); + registerFileInput(inputRef, () => inputRef.current?.click()); + }, [registerFileInput]); // Note: File input cannot be programmatically set for security reasons // The syncHiddenInput prop is no longer functional @@ -750,7 +760,7 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + dropFiles(e.dataTransfer.files); } }; form.addEventListener("dragover", onDragOver); @@ -759,7 +769,7 @@ export const PromptInput = ({ form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [globalDrop]); useEffect(() => { if (!globalDrop) { @@ -776,7 +786,7 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + dropFiles(e.dataTransfer.files); } }; document.addEventListener("dragover", onDragOver); @@ -785,7 +795,7 @@ export const PromptInput = ({ document.removeEventListener("dragover", onDragOver); document.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [globalDrop]); useEffect( () => () => {