@@ -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