Skip to content

Commit 0fbdc6a

Browse files
241 multiple issues in prompt inputtsx drag and drop memoization cleanup error handling (#260)
* Fix issue 1 * Fix issue 2 * Fix issue 3 * Fix issue 4 * Create wacky-coins-peel.md * Fix typos
1 parent d60d684 commit 0fbdc6a

File tree

2 files changed

+99
-75
lines changed

2 files changed

+99
-75
lines changed

.changeset/wacky-coins-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai-elements": patch
3+
---
4+
5+
Fix multiple memoization and perf issues with PromptInput

packages/elements/src/prompt-input.tsx

Lines changed: 94 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function PromptInputProvider({
150150
const clearInput = useCallback(() => setTextInput(""), []);
151151

152152
// ----- attachments state (global when wrapped)
153-
const [attachements, setAttachements] = useState<
153+
const [attachmentFiles, setAttachmentFiles] = useState<
154154
(FileUIPart & { id: string })[]
155155
>([]);
156156
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -162,7 +162,7 @@ export function PromptInputProvider({
162162
return;
163163
}
164164

165-
setAttachements((prev) =>
165+
setAttachmentFiles((prev) =>
166166
prev.concat(
167167
incoming.map((file) => ({
168168
id: nanoid(),
@@ -176,7 +176,7 @@ export function PromptInputProvider({
176176
}, []);
177177

178178
const remove = useCallback((id: string) => {
179-
setAttachements((prev) => {
179+
setAttachmentFiles((prev) => {
180180
const found = prev.find((f) => f.id === id);
181181
if (found?.url) {
182182
URL.revokeObjectURL(found.url);
@@ -186,7 +186,7 @@ export function PromptInputProvider({
186186
}, []);
187187

188188
const clear = useCallback(() => {
189-
setAttachements((prev) => {
189+
setAttachmentFiles((prev) => {
190190
for (const f of prev) {
191191
if (f.url) {
192192
URL.revokeObjectURL(f.url);
@@ -196,20 +196,35 @@ export function PromptInputProvider({
196196
});
197197
}, []);
198198

199+
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
200+
const attachmentsRef = useRef(attachmentFiles);
201+
attachmentsRef.current = attachmentFiles;
202+
203+
// Cleanup blob URLs on unmount to prevent memory leaks
204+
useEffect(() => {
205+
return () => {
206+
for (const f of attachmentsRef.current) {
207+
if (f.url) {
208+
URL.revokeObjectURL(f.url);
209+
}
210+
}
211+
};
212+
}, []);
213+
199214
const openFileDialog = useCallback(() => {
200215
openRef.current?.();
201216
}, []);
202217

203218
const attachments = useMemo<AttachmentsContext>(
204219
() => ({
205-
files: attachements,
220+
files: attachmentFiles,
206221
add,
207222
remove,
208223
clear,
209224
openFileDialog,
210225
fileInputRef,
211226
}),
212-
[attachements, add, remove, clear, openFileDialog]
227+
[attachmentFiles, add, remove, clear, openFileDialog]
213228
);
214229

215230
const __registerFileInput = useCallback(
@@ -459,17 +474,8 @@ export const PromptInput = ({
459474

460475
// Refs
461476
const inputRef = useRef<HTMLInputElement | null>(null);
462-
const anchorRef = useRef<HTMLSpanElement>(null);
463477
const formRef = useRef<HTMLFormElement | null>(null);
464478

465-
// Find nearest form to scope drag & drop
466-
useEffect(() => {
467-
const root = anchorRef.current?.closest("form");
468-
if (root instanceof HTMLFormElement) {
469-
formRef.current = root;
470-
}
471-
}, []);
472-
473479
// ----- Local attachments (only used when no provider)
474480
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
475481
const files = usingProvider ? controller.attachments.files : items;
@@ -547,35 +553,36 @@ export const PromptInput = ({
547553
[matchesAccept, maxFiles, maxFileSize, onError]
548554
);
549555

550-
const add = usingProvider
551-
? (files: File[] | FileList) => controller.attachments.add(files)
552-
: addLocal;
553-
554-
const remove = usingProvider
555-
? (id: string) => controller.attachments.remove(id)
556-
: (id: string) =>
557-
setItems((prev) => {
558-
const found = prev.find((file) => file.id === id);
559-
if (found?.url) {
560-
URL.revokeObjectURL(found.url);
561-
}
562-
return prev.filter((file) => file.id !== id);
563-
});
556+
const removeLocal = useCallback(
557+
(id: string) =>
558+
setItems((prev) => {
559+
const found = prev.find((file) => file.id === id);
560+
if (found?.url) {
561+
URL.revokeObjectURL(found.url);
562+
}
563+
return prev.filter((file) => file.id !== id);
564+
}),
565+
[]
566+
);
564567

565-
const clear = usingProvider
566-
? () => controller.attachments.clear()
567-
: () =>
568-
setItems((prev) => {
569-
for (const file of prev) {
570-
if (file.url) {
571-
URL.revokeObjectURL(file.url);
572-
}
568+
const clearLocal = useCallback(
569+
() =>
570+
setItems((prev) => {
571+
for (const file of prev) {
572+
if (file.url) {
573+
URL.revokeObjectURL(file.url);
573574
}
574-
return [];
575-
});
575+
}
576+
return [];
577+
}),
578+
[]
579+
);
576580

581+
const add = usingProvider ? controller.attachments.add : addLocal;
582+
const remove = usingProvider ? controller.attachments.remove : removeLocal;
583+
const clear = usingProvider ? controller.attachments.clear : clearLocal;
577584
const openFileDialog = usingProvider
578-
? () => controller.attachments.openFileDialog()
585+
? controller.attachments.openFileDialog
579586
: openFileDialogLocal;
580587

581588
// Let provider know about our hidden file input so external menus can call openFileDialog()
@@ -662,15 +669,21 @@ export const PromptInput = ({
662669
event.currentTarget.value = "";
663670
};
664671

665-
const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
666-
const response = await fetch(url);
667-
const blob = await response.blob();
668-
return new Promise((resolve, reject) => {
669-
const reader = new FileReader();
670-
reader.onloadend = () => resolve(reader.result as string);
671-
reader.onerror = reject;
672-
reader.readAsDataURL(blob);
673-
});
672+
const convertBlobUrlToDataUrl = async (
673+
url: string
674+
): Promise<string | null> => {
675+
try {
676+
const response = await fetch(url);
677+
const blob = await response.blob();
678+
return new Promise((resolve) => {
679+
const reader = new FileReader();
680+
reader.onloadend = () => resolve(reader.result as string);
681+
reader.onerror = () => resolve(null);
682+
reader.readAsDataURL(blob);
683+
});
684+
} catch {
685+
return null;
686+
}
674687
};
675688

676689
const ctx = useMemo<AttachmentsContext>(
@@ -706,46 +719,51 @@ export const PromptInput = ({
706719
Promise.all(
707720
files.map(async ({ id, ...item }) => {
708721
if (item.url && item.url.startsWith("blob:")) {
722+
const dataUrl = await convertBlobUrlToDataUrl(item.url);
723+
// If conversion failed, keep the original blob URL
709724
return {
710725
...item,
711-
url: await convertBlobUrlToDataUrl(item.url),
726+
url: dataUrl ?? item.url,
712727
};
713728
}
714729
return item;
715730
})
716-
).then((convertedFiles: FileUIPart[]) => {
717-
try {
718-
const result = onSubmit({ text, files: convertedFiles }, event);
719-
720-
// Handle both sync and async onSubmit
721-
if (result instanceof Promise) {
722-
result
723-
.then(() => {
724-
clear();
725-
if (usingProvider) {
726-
controller.textInput.clear();
727-
}
728-
})
729-
.catch(() => {
730-
// Don't clear on error - user may want to retry
731-
});
732-
} else {
733-
// Sync function completed without throwing, clear attachments
734-
clear();
735-
if (usingProvider) {
736-
controller.textInput.clear();
731+
)
732+
.then((convertedFiles: FileUIPart[]) => {
733+
try {
734+
const result = onSubmit({ text, files: convertedFiles }, event);
735+
736+
// Handle both sync and async onSubmit
737+
if (result instanceof Promise) {
738+
result
739+
.then(() => {
740+
clear();
741+
if (usingProvider) {
742+
controller.textInput.clear();
743+
}
744+
})
745+
.catch(() => {
746+
// Don't clear on error - user may want to retry
747+
});
748+
} else {
749+
// Sync function completed without throwing, clear attachments
750+
clear();
751+
if (usingProvider) {
752+
controller.textInput.clear();
753+
}
737754
}
755+
} catch {
756+
// Don't clear on error - user may want to retry
738757
}
739-
} catch (error) {
758+
})
759+
.catch(() => {
740760
// Don't clear on error - user may want to retry
741-
}
742-
});
761+
});
743762
};
744763

745764
// Render with or without local provider
746765
const inner = (
747766
<>
748-
<span aria-hidden="true" className="hidden" ref={anchorRef} />
749767
<input
750768
accept={accept}
751769
aria-label="Upload files"
@@ -759,6 +777,7 @@ export const PromptInput = ({
759777
<form
760778
className={cn("w-full", className)}
761779
onSubmit={handleSubmit}
780+
ref={formRef}
762781
{...props}
763782
>
764783
<InputGroup className="overflow-hidden">{children}</InputGroup>

0 commit comments

Comments
 (0)