fix(upload): persistent file <input> to survive iOS PWA cold launch
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
A dynamically-created <input type="file"> that's never attached to the DOM drops its first `change` event on a cold-launched iOS PWA — the native photo picker resolves out of the original user-gesture context and the closure that captured the input is gone. Symptom Matt hit 2026-05-14: first image-pick after hard-close + reopen of the PWA silently failed to advance to the crop tool; the second attempt worked. HomeView and LibraryView now keep a hidden <input ref="fileInputEl" type="file"> live in their templates. onAddPhoto clicks that input inside the user-gesture context; @change fires reliably even on cold launches. The picker resets input.value between selections so picking the same file twice still fires. Tests updated to query the template input via wrapper.find() instead of stubbing document.createElement; 347/347 frontend tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,38 +191,31 @@ describe('HomeView', () => {
|
|||||||
expect(wrapper.text()).toContain('Loading')
|
expect(wrapper.text()).toContain('Loading')
|
||||||
})
|
})
|
||||||
|
|
||||||
// HV-04: add-photo opens a file picker, primes the upload store, and navigates
|
// HV-04: add-photo opens a file picker, primes the upload store, and navigates.
|
||||||
|
// The file input lives in the template (persistent across renders) so iOS
|
||||||
|
// PWA cold launches don't drop the first change event on a detached node.
|
||||||
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
|
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
|
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
|
||||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
routerPush.mockClear()
|
routerPush.mockClear()
|
||||||
|
|
||||||
// Spy on createElement so we can intercept the synthetic file input
|
|
||||||
const realCreate = document.createElement.bind(document)
|
|
||||||
let capturedInput: HTMLInputElement | null = null
|
|
||||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
||||||
const el = realCreate(tag)
|
|
||||||
if (tag === 'input') {
|
|
||||||
capturedInput = el as HTMLInputElement
|
|
||||||
// Don't actually open a file dialog
|
|
||||||
;(el as HTMLInputElement).click = vi.fn()
|
|
||||||
}
|
|
||||||
return el
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
expect(fileInput.exists()).toBe(true)
|
||||||
|
// Stub click so jsdom doesn't try to open a real picker.
|
||||||
|
const clickSpy = vi.fn()
|
||||||
|
;(fileInput.element as HTMLInputElement).click = clickSpy
|
||||||
|
|
||||||
const card = wrapper.findComponent({ name: 'FrameCard' })
|
const card = wrapper.findComponent({ name: 'FrameCard' })
|
||||||
await card.vm.$emit('add-photo', 7)
|
await card.vm.$emit('add-photo', 7)
|
||||||
|
expect(clickSpy).toHaveBeenCalled()
|
||||||
expect(capturedInput).not.toBeNull()
|
|
||||||
expect(capturedInput!.type).toBe('file')
|
|
||||||
|
|
||||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
||||||
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
|
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||||
capturedInput!.onchange?.(new Event('change'))
|
await fileInput.trigger('change')
|
||||||
|
|
||||||
const upload = useUploadStore()
|
const upload = useUploadStore()
|
||||||
expect(upload.originalFile).toStrictEqual(file)
|
expect(upload.originalFile).toStrictEqual(file)
|
||||||
@@ -789,33 +782,25 @@ describe('HomeView', () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
|
expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add-photo handler creates a hidden file input and (on file pick) navigates
|
// Add-photo handler clicks the template's persistent hidden file input
|
||||||
// to /upload with the staged file in the upload store.
|
// and (on file pick) navigates to /upload with the staged file.
|
||||||
it('add-photo opens a file picker and navigates after a file is chosen', async () => {
|
it('add-photo opens a file picker and navigates after a file is chosen', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({ id: 7 })]
|
devicesStore.devices = [makeDevice({ id: 7 })]
|
||||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
let capturedInput: HTMLInputElement | null = null
|
|
||||||
const origCreate = document.createElement.bind(document)
|
|
||||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
||||||
const el = origCreate(tag) as HTMLInputElement
|
|
||||||
if (tag === 'input') capturedInput = el
|
|
||||||
return el
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
expect(fileInput.exists()).toBe(true)
|
||||||
|
;(fileInput.element as HTMLInputElement).click = vi.fn()
|
||||||
|
|
||||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)
|
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)
|
||||||
|
|
||||||
expect(capturedInput).not.toBeNull()
|
|
||||||
expect(capturedInput!.type).toBe('file')
|
|
||||||
|
|
||||||
// Simulate the user picking a file.
|
|
||||||
const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' })
|
const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' })
|
||||||
Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true })
|
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||||
capturedInput!.onchange?.(new Event('change'))
|
await fileInput.trigger('change')
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(routerPush).toHaveBeenCalledWith('/upload')
|
expect(routerPush).toHaveBeenCalledWith('/upload')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -148,26 +148,19 @@ describe('LibraryView', () => {
|
|||||||
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
|
||||||
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
|
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
// Spy on createElement so we can intercept the synthetic file input
|
|
||||||
const realCreate = document.createElement.bind(document)
|
|
||||||
let capturedInput: HTMLInputElement | null = null
|
|
||||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
||||||
const el = realCreate(tag)
|
|
||||||
if (tag === 'input') {
|
|
||||||
capturedInput = el as HTMLInputElement
|
|
||||||
;(el as HTMLInputElement).click = vi.fn()
|
|
||||||
}
|
|
||||||
return el
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
// The file <input> is persistent in the template (see LibraryView).
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
expect(fileInput.exists()).toBe(true)
|
||||||
|
;(fileInput.element as HTMLInputElement).click = vi.fn()
|
||||||
|
|
||||||
await wrapper.find('.library__add-btn').trigger('click')
|
await wrapper.find('.library__add-btn').trigger('click')
|
||||||
|
|
||||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
|
||||||
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
|
Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true })
|
||||||
capturedInput!.onchange?.(new Event('change'))
|
await fileInput.trigger('change')
|
||||||
|
|
||||||
expect(uploadInit).toHaveBeenCalledWith(file)
|
expect(uploadInit).toHaveBeenCalledWith(file)
|
||||||
expect(routerPush).toHaveBeenCalledWith('/upload')
|
expect(routerPush).toHaveBeenCalledWith('/upload')
|
||||||
|
|||||||
@@ -70,6 +70,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
|
|
||||||
|
<!-- Hidden persistent file picker. iOS Safari's PWA shell drops the
|
||||||
|
first `change` event of a dynamically-created (DOM-detached)
|
||||||
|
input element on cold launches — the photo picker resolves
|
||||||
|
out-of-gesture-context and the closure never fires. Keeping the
|
||||||
|
input live in the template sidesteps that. -->
|
||||||
|
<input
|
||||||
|
ref="fileInputEl"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
hidden
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Frame settings sheet -->
|
<!-- Frame settings sheet -->
|
||||||
@@ -456,19 +469,28 @@ async function refreshDevices() {
|
|||||||
await devicesStore.fetchDevices({ silent: true })
|
await devicesStore.fetchDevices({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hidden file <input> lives in the template so iOS PWA cold launches
|
||||||
|
// can't drop the first `change` event on a detached node. Click handlers
|
||||||
|
// route through this single input.
|
||||||
|
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
let pendingAddDeviceId: number | null = null
|
||||||
|
|
||||||
function onAddPhoto(deviceId: number) {
|
function onAddPhoto(deviceId: number) {
|
||||||
// File picker must be triggered in the user-gesture context (the click handler)
|
// File picker must be triggered in the user-gesture context (the click
|
||||||
// before navigating, otherwise browsers block it as a popup.
|
// handler), otherwise browsers block it as a popup.
|
||||||
const input = document.createElement('input')
|
pendingAddDeviceId = deviceId
|
||||||
input.type = 'file'
|
fileInputEl.value?.click()
|
||||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
}
|
||||||
input.onchange = () => {
|
|
||||||
const file = input.files?.[0]
|
function onFileSelected(e: Event) {
|
||||||
if (!file) return
|
const input = e.target as HTMLInputElement
|
||||||
uploadStore.init(file, deviceId)
|
const file = input.files?.[0]
|
||||||
router.push('/upload')
|
// Reset right away so re-selecting the same file later still fires change.
|
||||||
}
|
input.value = ''
|
||||||
input.click()
|
if (!file) return
|
||||||
|
uploadStore.init(file, pendingAddDeviceId ?? undefined)
|
||||||
|
pendingAddDeviceId = null
|
||||||
|
router.push('/upload')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings sheet ────────────────────────────────────────────────────────────
|
// ── Settings sheet ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -207,6 +207,16 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</BaseBottomSheet>
|
</BaseBottomSheet>
|
||||||
|
|
||||||
|
<!-- Hidden persistent file picker — see the matching block in HomeView
|
||||||
|
for why this isn't a one-shot document.createElement('input'). -->
|
||||||
|
<input
|
||||||
|
ref="fileInputEl"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
hidden
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -299,19 +309,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
// ── Add Photo ─────────────────────────────────────────────────────────────────
|
// ── Add Photo ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Hidden file <input> lives in the template (see HomeView for context).
|
||||||
|
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
function onAddPhoto() {
|
function onAddPhoto() {
|
||||||
// File picker must be triggered in the user-gesture context (the click
|
// File picker must be triggered in the user-gesture context (the click
|
||||||
// handler) before navigating, otherwise browsers block it as a popup.
|
// handler), otherwise browsers block it as a popup.
|
||||||
const input = document.createElement('input')
|
fileInputEl.value?.click()
|
||||||
input.type = 'file'
|
}
|
||||||
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
|
|
||||||
input.onchange = () => {
|
function onFileSelected(e: Event) {
|
||||||
const file = input.files?.[0]
|
const input = e.target as HTMLInputElement
|
||||||
if (!file) return
|
const file = input.files?.[0]
|
||||||
uploadStore.init(file)
|
// Reset so re-selecting the same file later still fires change.
|
||||||
router.push('/upload')
|
input.value = ''
|
||||||
}
|
if (!file) return
|
||||||
input.click()
|
uploadStore.init(file)
|
||||||
|
router.push('/upload')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-vGi2xK-_.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-DHJU4XGZ.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user