fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -55,6 +55,8 @@ interface Item {
file: FileItem | null
source: File | null
handle: UploadHandle | null
/** Stable client id sent on every attempt of this queued file. */
uploadId: string
}
let nextUid = 1
@@ -78,6 +80,9 @@ const transient: string[] = []
const routes: FileRoutes | null = session ? session.routes(props.field.name) : null
let reorderTimer: ReturnType<typeof setTimeout> | null = null
let orderBefore: number[] | null = null
let orderInFlight = false
let pendingOrder: number[] | null = null
let orderGen = 0
let unmounted = false
const readOnly = computed(() => {
@@ -240,9 +245,14 @@ function itemFor(file: FileItem): Item {
file,
source: null,
handle: null,
uploadId: '',
}) as Item
}
function newUploadId(): string {
return globalThis.crypto?.randomUUID?.() ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function localItem(source: File, error: string): Item {
return reactive<Item>({
uid: nextUid++,
@@ -255,6 +265,7 @@ function localItem(source: File, error: string): Item {
file: null,
source: markRaw(source),
handle: null,
uploadId: newUploadId(),
}) as Item
}
@@ -380,48 +391,86 @@ async function upload(item: Item): Promise<void> {
item.progress = 0
item.error = ''
item.retryable = false
const handle = routes.upload(item.source, (loaded, total) => {
item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
})
item.handle = markRaw(handle)
const result = await handle.promise
item.handle = null
const kept = !!find(item.uid)
if (result.ok) {
if (!kept || unmounted) {
// Cancelled after the server stored it: drop the pending upload.
void routes.remove(result.item.id)
} else {
item.state = 'done'
item.progress = 100
item.file = result.item
item.name = result.item.file_name
item.size = result.item.file_size
session?.markDirty()
}
} else if (kept && result.reason !== 'aborted') {
item.state = 'failed'
switch (result.reason) {
case 'too_large':
item.error = maxSizeLabel.value
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value })
: result.message || t('backend::lang.fileupload.upload_failed')
break
case 'invalid':
item.error = result.message || t('backend::lang.fileupload.upload_failed')
break
case 'network':
const known = new Set(items.value.filter((entry) => entry.file).map((entry) => entry.file!.id))
const release = session?.beginUpload()
try {
const handle = routes.upload(
item.source,
(loaded, total) => {
item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
},
item.uploadId || undefined,
)
item.handle = markRaw(handle)
const result = await handle.promise
item.handle = null
const kept = !!find(item.uid)
if (result.ok) {
if (!kept || unmounted) {
// Cancelled after the server stored it: drop the pending upload.
void routes.remove(result.item.id)
} else {
item.state = 'done'
item.progress = 100
item.file = result.item
item.name = result.item.file_name
item.size = result.item.file_size
session?.markDirty()
}
} else if (result.reason === 'aborted' || result.reason === 'network') {
const extras = await lostUploads(known)
if (result.reason === 'aborted') {
for (const extra of extras) {
void routes.remove(extra.id)
}
items.value = items.value.filter((entry) => entry.uid !== item.uid)
forgetThumb(item.uid)
} else if (kept && extras[0] && extras.length === 1) {
item.state = 'done'
item.progress = 100
item.file = extras[0]
item.name = extras[0].file_name
item.size = extras[0].file_size
session?.markDirty()
} else {
item.state = 'failed'
item.error = t('backend::lang.fileupload.upload_failed')
item.retryable = true
break
default:
item.error = result.message || t('backend::lang.fileupload.upload_failed')
item.retryable = result.status >= 500
}
} else if (kept) {
item.state = 'failed'
switch (result.reason) {
case 'too_large':
item.error = maxSizeLabel.value
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value })
: result.message || t('backend::lang.fileupload.upload_failed')
break
case 'invalid':
item.error = result.message || t('backend::lang.fileupload.upload_failed')
break
default:
item.error = result.message || t('backend::lang.fileupload.upload_failed')
item.retryable = result.status >= 500
}
}
} finally {
release?.()
if (!unmounted) {
pump()
}
}
if (!unmounted) {
pump()
}
/** Pending files that appeared after this upload started (response lost). */
async function lostUploads(known: Set<number>): Promise<FileItem[]> {
if (!routes) {
return []
}
const listed = await routes.list()
if (!listed.data) {
return []
}
return listed.data.filter((file) => file.pending && !known.has(file.id))
}
function retry(item: Item): void {
@@ -488,19 +537,40 @@ function scheduleReorder(): void {
}, REORDER_DEBOUNCE)
}
function currentFileIds(): number[] {
return items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id)
}
async function sendOrder(): Promise<void> {
const before = orderBefore
orderBefore = null
if (!routes) {
return
}
const ids = items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id)
const result = await routes.reorder(ids)
if (!result.data && !unmounted) {
if (before) {
restore(before)
const ids = currentFileIds()
if (orderInFlight) {
pendingOrder = ids
return
}
const before = orderBefore
orderBefore = null
const gen = ++orderGen
orderInFlight = true
try {
const result = await routes.reorder(ids)
if (pendingOrder) {
return
}
if (!result.data && !unmounted && gen === orderGen) {
if (before) {
restore(before)
}
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
}
} finally {
orderInFlight = false
if (pendingOrder) {
pendingOrder = null
void sendOrder()
}
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
}
}