feat(upload): refactor upload management to synchronize state with a single writer
ZhenYi committed
Modified web/src/features/chat/model/use-composer.ts web/src/features/chat/model/use-composer.ts +71 −48 View web/src/features/chat/model/use-composer.ts
@@ -95,6 +95,17 @@
9595 const draftScope = `${roomId}\u0000${threadId ?? ''}`
9696 const scopeKey = `${draftScope}\u0000edit:${editingId ?? ''}`
9797 const urls = useRef<Set<string>>(new Set())
98 // Mirror of `uploads` for callbacks that must reason about the queue without
99 // writing new state from inside a state updater. React may invoke an updater
100 // more than once (StrictMode) and drops all but one result, so ids collected
101 // inside it can desync from the committed state.
102 const uploadsRef = useRef<UploadItem[]>([])
103 /** The single writer of the queue: keeps the ref and the state in lockstep. */
104 const commitUploads = useCallback((update: (current: UploadItem[]) => UploadItem[]) => {
105 const next = update(uploadsRef.current)
106 uploadsRef.current = next
107 setUploads(next)
108 }, [])
98109
99110 // A new scope starts with an empty box; the previous room's text belongs to
100111 // that room's draft, never to this one.
@@ -118,10 +129,11 @@
118129 touched.current = false
119130 setTextState('')
120131 setLoadedDraft(false)
121 setUploads(current => {
132 commitUploads(current => {
122133 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
123134 return []
124135 })
136 urls.current.clear()
125137 }, [draftScope, editingId, roomId, scopeKey, text, threadId])
126138
127139 /* --------------------------------------------------------------- draft */
@@ -196,20 +208,24 @@
196208 const reset = useCallback(() => {
197209 touched.current = false
198210 setTextState('')
199 setUploads(current => {
211 commitUploads(current => {
200212 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
201213 return []
202214 })
215 urls.current.clear()
203216 if (enabled && !editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined)
204 }, [editingId, enabled, roomId, threadId])
217 }, [commitUploads, editingId, enabled, roomId, threadId])
205218
206219 /* ----------------------------------------------------------- attachments */
207220
208 const patch = useCallback((localId: string, changes: Partial<UploadItem>) => {
209 setUploads(current =>
210 current.map(item => (item.localId === localId ? { ...item, ...changes } : item)),
211 )
212 }, [])
221 const patch = useCallback(
222 (localId: string, changes: Partial<UploadItem>) => {
223 commitUploads(current =>
224 current.map(item => (item.localId === localId ? { ...item, ...changes } : item)),
225 )
226 },
227 [commitUploads],
228 )
213229
214230 const runUpload = useCallback(
215231 async (item: UploadItem) => {
@@ -242,52 +258,59 @@
242258 const addFiles = useCallback(
243259 (files: File[]) => {
244260 if (!enabled || files.length === 0) return
261 // The batch is decided before state is touched: the ids handed to
262 // `runUpload` must be exactly the ids that reach the queue, otherwise
263 // every progress patch misses and the row spins on「上传中…」forever.
245264 const accepted: UploadItem[] = []
246 setUploads(current => {
247 let next = [...current]
248 for (const file of files) {
249 const localId = `upload:${Date.now().toString(36)}:${(uploadSeq += 1)}`
250 if (next.length >= MAX_ATTACHMENTS) {
251 setDraftNotice(`一条消息最多 ${MAX_ATTACHMENTS} 个附件,多出的没有添加`)
252 break
253 }
254 if (file.size > MAX_UPLOAD_BYTES) {
255 next.push({
256 localId,
257 file,
258 progress: 0,
259 state: 'too_big',
260 error: `超过 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB 上限`,
261 })
262 continue
263 }
264 const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined
265 if (previewUrl) urls.current.add(previewUrl)
265 const queued = uploadsRef.current.length
266 let overflow = false
267 for (const file of files) {
268 if (queued + accepted.length >= MAX_ATTACHMENTS) {
269 overflow = true
270 break
271 }
272 const localId = `upload:${Date.now().toString(36)}:${(uploadSeq += 1)}`
273 if (file.size > MAX_UPLOAD_BYTES) {
266274 accepted.push({
267275 localId,
268276 file,
269 previewUrl,
270 progress: 0.05,
271 state: 'checking',
277 progress: 0,
278 state: 'too_big',
279 error: `超过 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB 上限`,
272280 })
281 continue
273282 }
274 return [...next, ...accepted]
275 })
283 const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined
284 if (previewUrl) urls.current.add(previewUrl)
285 accepted.push({
286 localId,
287 file,
288 previewUrl,
289 progress: 0.05,
290 state: 'checking',
291 })
292 }
293 if (overflow) setDraftNotice(`一条消息最多 ${MAX_ATTACHMENTS} 个附件,多出的没有添加`)
294 if (accepted.length === 0) return
295 commitUploads(current => [...current, ...accepted])
276296 for (const item of accepted) void runUpload(item)
277297 },
278 [enabled, runUpload],
298 [commitUploads, enabled, runUpload],
279299 )
280300
281 const removeUpload = useCallback((localId: string) => {
282 setUploads(current => {
283 const item = current.find(candidate => candidate.localId === localId)
284 if (item?.previewUrl) {
285 URL.revokeObjectURL(item.previewUrl)
286 urls.current.delete(item.previewUrl)
287 }
288 return current.filter(candidate => candidate.localId !== localId)
289 })
290 }, [])
301 const removeUpload = useCallback(
302 (localId: string) => {
303 commitUploads(current => {
304 const item = current.find(candidate => candidate.localId === localId)
305 if (item?.previewUrl) {
306 URL.revokeObjectURL(item.previewUrl)
307 urls.current.delete(item.previewUrl)
308 }
309 return current.filter(candidate => candidate.localId !== localId)
310 })
311 },
312 [commitUploads],
313 )
291314
292315 const retryUpload = useCallback(
293316 (localId: string) => {
@@ -298,11 +321,11 @@
298321 )
299322
300323 const clearUploads = useCallback(() => {
301 setUploads(current => {
324 commitUploads(current => {
302325 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
303326 return []
304327 })
305 }, [])
328 }, [commitUploads])
306329
307330 /* ----------------------------------------------------------------- send */
308331
@@ -339,7 +362,7 @@
339362 // server-side draft is dropped by the same rule the eraser uses.
340363 touched.current = false
341364 setTextState('')
342 setUploads(current => {
365 commitUploads(current => {
343366 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
344367 return []
345368 })
@@ -353,7 +376,7 @@
353376 return false
354377 }
355378 return true
356 }, [canSend, editingId, onSubmitted, readyAttachments, roomId, text, threadId])
379 }, [canSend, commitUploads, editingId, onSubmitted, readyAttachments, roomId, text, threadId])
357380
358381 return {
359382 text,