From 08a1931e32a4892c66a7bb2cf873634daf9b2678 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Sun, 9 Aug 2026 08:22:12 +0000 Subject: [PATCH] fix(elements): keep attachment references stable during text input The local attachments context cloned every attachment item on each recompute (files.map((item) => ({ ...item, id: item.id }))), a pure identity clone. Because the context memo also recomputes on every keystroke in provider-backed mode, each text change allocated a new object per attachment and defeated memoized attachment rows that compare by reference, such as the memo'd AttachmentItem in the shipped example. Pass the files array through by reference instead. Unchanged items now keep their identity while typing, so memoized chips skip re-renders on text-only updates and still re-render when files are added or removed. Two new tests cover provider-backed mode: one asserts item references stay strictly equal across typing, and one asserts a React.memo chip does not re-render on text input but does when a file is added. Both fail before this change. --- .../elements/__tests__/prompt-input.test.tsx | 108 ++++++++++++++++++ packages/elements/src/prompt-input.tsx | 2 +- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/elements/__tests__/prompt-input.test.tsx b/packages/elements/__tests__/prompt-input.test.tsx index d961a3ba..035e4203 100644 --- a/packages/elements/__tests__/prompt-input.test.tsx +++ b/packages/elements/__tests__/prompt-input.test.tsx @@ -1054,6 +1054,114 @@ describe("promptInputProvider", () => { expect(screen.getByTestId("count")).toHaveTextContent("1"); }); + + it("keeps attachment references stable while typing", async () => { + setupPromptInputTests(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const file = new File(["test"], "test.txt", { type: "text/plain" }); + + const fileRefs: AttachmentData[] = []; + const AttachmentConsumer = () => { + const attachments = usePromptInputAttachments(); + if (attachments.files.length > 0) { + fileRefs.push(attachments.files[0]); + } + return ( + + ); + }; + + render( + + + + + + + + + ); + + await user.click(screen.getByTestId("add-file")); + expect(fileRefs.length).toBeGreaterThan(0); + + await user.type(screen.getByRole("textbox"), "hello"); + + expect(new Set(fileRefs).size).toBe(1); + }); + + it("does not re-render memoized attachment rows on text input", async () => { + setupPromptInputTests(); + const { PromptInputProvider } = await import("../src/prompt-input"); + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const file = new File(["test"], "test.txt", { type: "text/plain" }); + const otherFile = new File(["other"], "other.txt", { + type: "text/plain", + }); + const chipRenders: string[] = []; + + const Chip = React.memo(({ data }: { data: AttachmentData }) => { + chipRenders.push(data.id); + return {data.filename}; + }); + Chip.displayName = "Chip"; + + const ChipList = () => { + const attachments = usePromptInputAttachments(); + return ( + <> + + + {attachments.files.map((f) => ( + + ))} + + ); + }; + + render( + + + + + + + + + ); + + await user.click(screen.getByTestId("add-file")); + const rendersAfterAdd = chipRenders.length; + expect(rendersAfterAdd).toBeGreaterThan(0); + + await user.type(screen.getByRole("textbox"), "hello"); + expect(chipRenders.length).toBe(rendersAfterAdd); + + await user.click(screen.getByTestId("add-other-file")); + expect(chipRenders.length).toBeGreaterThan(rendersAfterAdd); + expect(screen.getAllByTestId(/^chip-/)).toHaveLength(2); + }); }); describe("file validation", () => { diff --git a/packages/elements/src/prompt-input.tsx b/packages/elements/src/prompt-input.tsx index 412c846d..0c384e83 100644 --- a/packages/elements/src/prompt-input.tsx +++ b/packages/elements/src/prompt-input.tsx @@ -816,7 +816,7 @@ export const PromptInput = ({ add, clear: clearAttachments, fileInputRef: inputRef, - files: files.map((item) => ({ ...item, id: item.id })), + files, openFileDialog, remove, }),