Preserve slow custom-app overlays through their loading deadline

This commit is contained in:
archipelago
2026-10-06 13:01:44 -04:00
parent a49d4d7128
commit e8683aa5b1
6 changed files with 107 additions and 18 deletions
+17 -3
View File
@@ -92,6 +92,9 @@
@error="onIframeError"
/>
<AppSlowLoadNotice v-if="slowLoad && !iframeBlocked" :title="store.title || 'App'"
@refresh="refreshIframe" @dismiss-slow-load="slowLoad = false" />
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
@@ -210,6 +213,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
@@ -234,6 +238,7 @@ const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
const slowLoad = ref(false)
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
// when present; AppLoadingScreen's <img> falls back to the default icon if the
@@ -366,12 +371,14 @@ function refreshIframe() {
isRefreshing.value = true
iframeLoading.value = true
iframeBlocked.value = false
slowLoad.value = false
clearTimers()
iframeRefreshKey.value++
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
isRefreshing.value = false
slowLoad.value = true
}
}, 15000)
}
@@ -400,6 +407,8 @@ function openInNewTabAndClose() {
}
function onIframeLoad() {
slowLoad.value = false
iframeBlocked.value = false
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
iframeLoading.value = false
@@ -413,6 +422,8 @@ function onIframeLoad() {
}
function onIframeError() {
slowLoad.value = false
isRefreshing.value = false
clearTimers()
iframeLoading.value = false
iframeBlocked.value = true
@@ -654,12 +665,14 @@ watch(
if (open) {
iframeLoading.value = true
iframeBlocked.value = false
slowLoad.value = false
clearTimers()
// Set max load timeout — if iframe never fires load, show fallback
// Keep a slow navigation alive; its eventual load clears this notice.
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
isRefreshing.value = false
slowLoad.value = true
}
}, 15000)
closeBtnRef.value?.focus()
@@ -667,6 +680,7 @@ watch(
isRefreshing.value = false
iframeLoading.value = true
iframeBlocked.value = false
slowLoad.value = false
clearTimers()
// Clear any pending payment when closing
if (pendingPayment.value) {
@@ -0,0 +1,15 @@
<template>
<div role="status"
class="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-3 py-2 text-xs text-white/75 backdrop-blur-md">
<span class="min-w-0 flex-1">{{ title }} is taking longer to load. You can keep waiting.</span>
<button type="button" class="shrink-0 rounded px-2 py-1 text-white hover:bg-white/10" @click="$emit('refresh')">Retry</button>
<button type="button" class="shrink-0 rounded p-1 text-white/60 hover:bg-white/10" aria-label="Dismiss loading notice" @click="$emit('dismissSlowLoad')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" /></svg>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string }>()
defineEmits<{ refresh: []; dismissSlowLoad: [] }>()
</script>
@@ -0,0 +1,51 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppLauncherOverlay from '../AppLauncherOverlay.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
vi.mock('@/stores/appLauncher', async () => {
const { reactive } = await import('vue')
const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review' })
return { useAppLauncherStore: () => state }
})
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
describe('legacy custom app slow loading', () => {
let wrapper: VueWrapper
beforeEach(() => { vi.useFakeTimers(); const store = useAppLauncherStore(); store.isOpen = false; store.url = ''; localStorage.clear() })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
async function render() {
wrapper = mount(AppLauncherOverlay, { global: { stubs: { Teleport: false, Transition: false, AppLoadingScreen: true, NostrSignConsent: true, NostrIdentityPicker: true, PaymentSuccessPane: true } } })
const store = useAppLauncherStore(); store.url = 'https://custom-app.invalid/'; store.isOpen = true
await nextTick()
}
function iframe() { return document.querySelector('iframe')! }
it('retains the frame after the deadline and recovers on its eventual load', async () => {
await render(); const original = iframe()
expect(original).not.toBeNull()
await vi.advanceTimersByTimeAsync(16000); await nextTick()
expect(iframe()).toBe(original)
expect(document.querySelector('[role="status"]')?.textContent).toContain('Custom app is taking longer')
original.dispatchEvent(new Event('load')); await nextTick()
expect(iframe()).toBe(original)
expect(document.querySelector('[role="status"]')).toBeNull()
})
it('allows explicit retry and dismissing the second slow-load notice without another navigation', async () => {
await render(); const original = iframe()
await vi.advanceTimersByTimeAsync(16000); await nextTick()
const retry = document.querySelector('[role="status"] button') as HTMLButtonElement
retry.click(); await nextTick()
const retried = iframe(); expect(retried).not.toBe(original)
await vi.advanceTimersByTimeAsync(16000); await nextTick()
expect(iframe()).toBe(retried)
;(document.querySelector('[aria-label="Dismiss loading notice"]') as HTMLButtonElement).click(); await nextTick()
expect(document.querySelector('[role="status"]')).toBeNull()
expect(iframe()).toBe(retried)
})
it('does not leave a loading notice after closing the app', async () => {
await render(); useAppLauncherStore().isOpen = false; await nextTick()
await vi.advanceTimersByTimeAsync(16000); await nextTick()
expect(document.querySelector('iframe')).toBeNull()
expect(document.querySelector('[role="status"]')).toBeNull()
})
})
@@ -61,14 +61,8 @@
/>
</div>
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status"
class="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-3 py-2 text-xs text-white/75 backdrop-blur-md">
<span class="min-w-0 flex-1">{{ appTitle }} is taking longer to load. You can keep waiting.</span>
<button type="button" class="shrink-0 rounded px-2 py-1 text-white hover:bg-white/10" @click="$emit('refresh')">Retry</button>
<button type="button" class="shrink-0 rounded p-1 text-white/60 hover:bg-white/10" aria-label="Dismiss loading notice" @click="$emit('dismissSlowLoad')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" /></svg>
</button>
</div>
<AppSlowLoadNotice v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" :title="appTitle"
@refresh="$emit('refresh')" @dismiss-slow-load="$emit('dismissSlowLoad')" />
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
@@ -129,6 +123,7 @@
</template>
<script setup lang="ts">
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'