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( () => () => {