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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// writes action results back through patch, which marks the form dirty and
|
||||
// clears that field's errors exactly like typing into it. A form without a
|
||||
// provider (settings pages) gets the defaults of the injecting control.
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { ref, type InjectionKey, type Ref } from 'vue'
|
||||
import type { FileRoutes } from '../../api/files'
|
||||
import type { AdminRecord } from '../../api/types'
|
||||
|
||||
@@ -46,6 +46,35 @@ export interface FormSession {
|
||||
pendingChanges: Readonly<Ref<number>>
|
||||
/** Goes up after every successful save, so fields can reload their files. */
|
||||
revision: Readonly<Ref<number>>
|
||||
/**
|
||||
* Registers an in-flight upload. Call the returned function in `finally`
|
||||
* when the request reaches a terminal state. The form will not submit
|
||||
* while any upload is still open.
|
||||
*/
|
||||
beginUpload(): () => void
|
||||
/** How many uploads have not yet reached a terminal state. */
|
||||
activeUploads: Readonly<Ref<number>>
|
||||
}
|
||||
|
||||
/** Counter and register/release pair used by record and child forms. */
|
||||
export function createUploadGate(): { activeUploads: Ref<number>; beginUpload: () => () => void } {
|
||||
const activeUploads = ref(0)
|
||||
return {
|
||||
activeUploads,
|
||||
beginUpload() {
|
||||
activeUploads.value++
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if (activeUploads.value > 0) {
|
||||
activeUploads.value--
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/s
|
||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||
import FormGrid from '../form/FormGrid.vue'
|
||||
import FormTabs from '../form/FormTabs.vue'
|
||||
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext'
|
||||
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
@@ -79,6 +79,7 @@ const activeTab = ref(DEFAULT_TAB)
|
||||
const childKey = ref(newSessionKey())
|
||||
const pendingChanges = ref(0)
|
||||
const revision = ref(0)
|
||||
const { activeUploads, beginUpload } = createUploadGate()
|
||||
const body = ref<HTMLElement | null>(null)
|
||||
const confirm = useConfirm()
|
||||
let generation = 0
|
||||
@@ -148,7 +149,9 @@ const dirty = computed(
|
||||
!preview.value &&
|
||||
!loading.value &&
|
||||
!failed.value &&
|
||||
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
|
||||
(pendingChanges.value > 0 ||
|
||||
activeUploads.value > 0 ||
|
||||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
|
||||
)
|
||||
|
||||
function headers() {
|
||||
@@ -189,6 +192,8 @@ const session: FormSession = {
|
||||
},
|
||||
pendingChanges: readonly(pendingChanges),
|
||||
revision: readonly(revision),
|
||||
beginUpload,
|
||||
activeUploads: readonly(activeUploads),
|
||||
}
|
||||
provide(FORM_SESSION, session)
|
||||
provide(FORM_VALUES, computed(() => values.value))
|
||||
@@ -319,7 +324,7 @@ async function showErrors(details: Record<string, unknown> | undefined): Promise
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || preview.value || loading.value || failed.value) {
|
||||
if (busy.value || preview.value || loading.value || failed.value || activeUploads.value > 0) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
@@ -506,7 +511,7 @@ function onCloseAutoFocus(event: Event): void {
|
||||
v-if="!failed"
|
||||
variant="primary"
|
||||
data-action="save-child"
|
||||
:disabled="busy || loading"
|
||||
:disabled="busy || loading || activeUploads > 0"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : submitLabel }}
|
||||
|
||||
Reference in New Issue
Block a user