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