fix(upload): persistent file <input> to survive iOS PWA cold launch
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:
2026-05-14 13:02:26 -04:00
parent e57e711fcc
commit 82a42011d8
14 changed files with 96 additions and 82 deletions
+34 -12
View File
@@ -70,6 +70,19 @@
</div>
</div>
</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>
<!-- Frame settings sheet -->
@@ -456,19 +469,28 @@ async function refreshDevices() {
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) {
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
uploadStore.init(file, deviceId)
router.push('/upload')
}
input.click()
// File picker must be triggered in the user-gesture context (the click
// handler), otherwise browsers block it as a popup.
pendingAddDeviceId = deviceId
fileInputEl.value?.click()
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset right away so re-selecting the same file later still fires change.
input.value = ''
if (!file) return
uploadStore.init(file, pendingAddDeviceId ?? undefined)
pendingAddDeviceId = null
router.push('/upload')
}
// ── Settings sheet ────────────────────────────────────────────────────────────
+25 -11
View File
@@ -207,6 +207,16 @@
</BaseButton>
</div>
</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>
</template>
@@ -299,19 +309,23 @@ onMounted(() => {
// ── Add Photo ─────────────────────────────────────────────────────────────────
// Hidden file <input> lives in the template (see HomeView for context).
const fileInputEl = ref<HTMLInputElement | null>(null)
function onAddPhoto() {
// File picker must be triggered in the user-gesture context (the click
// handler) before navigating, otherwise browsers block it as a popup.
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpeg,image/png,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
uploadStore.init(file)
router.push('/upload')
}
input.click()
// handler), otherwise browsers block it as a popup.
fileInputEl.value?.click()
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset so re-selecting the same file later still fires change.
input.value = ''
if (!file) return
uploadStore.init(file)
router.push('/upload')
}
// ── Pull-to-refresh ───────────────────────────────────────────────────────────