Merge pull request #7320 from ouchihao/codex/bug-image-upload-read-order

fix(upload): cancel superseded image reads
This commit is contained in:
Wesley Liddick
2026-09-22 21:55:18 +08:00
committed by GitHub
2 changed files with 88 additions and 3 deletions
+12 -3
View File
@@ -56,7 +56,7 @@
v-if="modelValue"
type="button"
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
@click="$emit('update:modelValue', '')"
@click="removeImage"
>
<Icon name="trash" size="sm" class="mr-1.5" :stroke-width="2" />
{{ resolvedRemoveLabel }}
@@ -69,7 +69,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeSvg } from '@/utils/sanitize'
@@ -98,6 +98,14 @@ const emit = defineEmits<{
}>()
const error = ref('')
let reader: FileReader | null = null
onBeforeUnmount(() => reader?.abort())
function removeImage() {
reader?.abort()
emit('update:modelValue', '')
}
const resolvedUploadLabel = computed(() => props.uploadLabel || t('common.upload'))
const resolvedRemoveLabel = computed(() => props.removeLabel || t('common.remove'))
@@ -118,6 +126,7 @@ function handleUpload(event: Event) {
error.value = ''
if (!file) return
reader?.abort()
if (props.maxSize && file.size > props.maxSize) {
error.value = t('common.fileTooLargeKb', {
@@ -128,7 +137,7 @@ function handleUpload(event: Event) {
return
}
const reader = new FileReader()
reader = new FileReader()
if (props.mode === 'svg') {
reader.onload = (e) => {
const text = e.target?.result as string
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import ImageUpload from '../ImageUpload.vue'
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
enableAutoUnmount(afterEach)
class ControlledReader {
static instances: ControlledReader[] = []
onload: ((event: { target: { result: string } }) => void) | null = null
onerror: (() => void) | null = null
aborted = false
constructor() { ControlledReader.instances.push(this) }
readAsText = vi.fn()
readAsDataURL = vi.fn()
abort() { this.aborted = true }
complete(result: string) {
if (!this.aborted) this.onload?.({ target: { result } })
}
}
beforeEach(() => {
ControlledReader.instances = []
vi.stubGlobal('FileReader', ControlledReader)
})
afterEach(() => vi.unstubAllGlobals())
function uploader(mode: 'image' | 'svg' = 'image') {
const wrapper = mount(ImageUpload, { props: { modelValue: 'existing', mode } })
const input = wrapper.get('input')
return {
wrapper,
async choose(type = 'image/png') {
Object.defineProperty(input.element, 'files', {
configurable: true, value: [new File(['image'], 'upload', { type })]
})
await input.trigger('change')
}
}
}
describe('image upload read lifecycle', () => {
it.each(['image', 'svg'] as const)('keeps the newest %s selection when reads finish out of order', async mode => {
const { wrapper, choose } = uploader(mode)
await choose()
await choose()
ControlledReader.instances[1].complete('new image')
ControlledReader.instances[0].complete('old image')
expect(wrapper.emitted('update:modelValue')).toEqual([['new image']])
})
it('does not restore an image after the remove button is clicked', async () => {
const { wrapper, choose } = uploader()
await choose()
await wrapper.get('button').trigger('click')
ControlledReader.instances[0].complete('removed image')
expect(wrapper.emitted('update:modelValue')).toEqual([['']])
})
it('does not apply a pending image after a later invalid selection', async () => {
const { wrapper, choose } = uploader()
await choose()
await choose('text/plain')
ControlledReader.instances[0].complete('old image')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('common.selectImageFile')
})
it('aborts a pending read when the uploader unmounts', async () => {
const { wrapper, choose } = uploader()
await choose()
wrapper.unmount()
expect(ControlledReader.instances[0].aborted).toBe(true)
})
})