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
+8
View File
@@ -741,3 +741,11 @@ passed 1,299 tests but timed out in a Bitcoin modal case and then failed its nex
case; all 20 targeted tests including both modal cases pass on rerun. A new full case; all 20 targeted tests including both modal cases pass on rerun. A new full
run with reduced concurrency and no simultaneous build is required and running. run with reduced concurrency and no simultaneous build is required and running.
No failing full run is recorded as passed. No failing full run is recorded as passed.
The reduced-concurrency full rerun passed **1,301/1,301 tests in 163 files**
(`/tmp/archy-slow-app-launch-full-tests-final.log`). Review then found the same
iframe-removal defect in the custom-app overlay's 15-second timeout. It now keeps
the frame alive too, sharing the same small loading notice. Three additional
legacy-overlay tests pass, together with all nine frame tests; both launch paths
are included in the served-browser harness. The final build and deployment for
this extension are pending; physical companion acceptance is not implied.
+17 -3
View File
@@ -92,6 +92,9 @@
@error="onIframeError" @error="onIframeError"
/> />
<AppSlowLoadNotice v-if="slowLoad && !iframeBlocked" :title="store.title || 'App'"
@refresh="refreshIframe" @dismiss-slow-load="slowLoad = false" />
<!-- Iframe blocked fallback --> <!-- Iframe blocked fallback -->
<Transition name="content-fade"> <Transition name="content-fade">
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center"> <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 NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue' import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue' import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue' import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig' import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -234,6 +238,7 @@ const iframeRefreshKey = ref(0)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const iframeLoading = ref(true) const iframeLoading = ref(true)
const iframeBlocked = ref(false) const iframeBlocked = ref(false)
const slowLoad = ref(false)
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path // 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 // when present; AppLoadingScreen's <img> falls back to the default icon if the
@@ -366,12 +371,14 @@ function refreshIframe() {
isRefreshing.value = true isRefreshing.value = true
iframeLoading.value = true iframeLoading.value = true
iframeBlocked.value = false iframeBlocked.value = false
slowLoad.value = false
clearTimers() clearTimers()
iframeRefreshKey.value++ iframeRefreshKey.value++
loadTimeoutId = setTimeout(() => { loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) { if (iframeLoading.value) {
iframeLoading.value = false iframeLoading.value = false
iframeBlocked.value = true isRefreshing.value = false
slowLoad.value = true
} }
}, 15000) }, 15000)
} }
@@ -400,6 +407,8 @@ function openInNewTabAndClose() {
} }
function onIframeLoad() { function onIframeLoad() {
slowLoad.value = false
iframeBlocked.value = false
injectScrollbarHideIfSameOrigin() injectScrollbarHideIfSameOrigin()
isRefreshing.value = false isRefreshing.value = false
iframeLoading.value = false iframeLoading.value = false
@@ -413,6 +422,8 @@ function onIframeLoad() {
} }
function onIframeError() { function onIframeError() {
slowLoad.value = false
isRefreshing.value = false
clearTimers() clearTimers()
iframeLoading.value = false iframeLoading.value = false
iframeBlocked.value = true iframeBlocked.value = true
@@ -654,12 +665,14 @@ watch(
if (open) { if (open) {
iframeLoading.value = true iframeLoading.value = true
iframeBlocked.value = false iframeBlocked.value = false
slowLoad.value = false
clearTimers() 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(() => { loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) { if (iframeLoading.value) {
iframeLoading.value = false iframeLoading.value = false
iframeBlocked.value = true isRefreshing.value = false
slowLoad.value = true
} }
}, 15000) }, 15000)
closeBtnRef.value?.focus() closeBtnRef.value?.focus()
@@ -667,6 +680,7 @@ watch(
isRefreshing.value = false isRefreshing.value = false
iframeLoading.value = true iframeLoading.value = true
iframeBlocked.value = false iframeBlocked.value = false
slowLoad.value = false
clearTimers() clearTimers()
// Clear any pending payment when closing // Clear any pending payment when closing
if (pendingPayment.value) { 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>
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status" <AppSlowLoadNotice v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" :title="appTitle"
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"> @refresh="$emit('refresh')" @dismiss-slow-load="$emit('dismissSlowLoad')" />
<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>
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen <!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't (the "pre UI") is showing: a still-syncing Electrum server isn't
@@ -129,6 +123,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync' import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue' import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
+13 -7
View File
@@ -11,7 +11,9 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
(bytes[0] === 127 || bytes[0] === 10 || (bytes[0] === 192 && bytes[1] === 168) || (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] <= 31) || (bytes[0] === 100 && bytes[1] >= 64 && bytes[1] <= 127))); (bytes[0] === 127 || bytes[0] === 10 || (bytes[0] === 192 && bytes[1] === 168) || (bytes[0] === 172 && bytes[1] >= 16 && bytes[1] <= 31) || (bytes[0] === 100 && bytes[1] >= 64 && bytes[1] <= 127)));
if (!privateHost || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Refusing non-private node origin'); if (!privateHost || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Refusing non-private node origin');
const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911'); const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
for (const width of [390, 1440]) { for (const mode of ['session', 'overlay']) for (const width of [390, 1440]) {
const port = mode === 'session' ? 7778 : 19999;
const fixtureUrl = new URL(origin); fixtureUrl.port = String(port);
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' }); const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
let releaseDocument; let releaseDocument;
const heldDocument = new Promise(resolve => { releaseDocument = resolve; }); const heldDocument = new Promise(resolve => { releaseDocument = resolve; });
@@ -23,7 +25,7 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
localStorage.setItem('neode-auth', 'true'); localStorage.setItem('neode-auth', 'true');
localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000)); localStorage.setItem('lnd-seed-backup-prompt-snooze-until', String(Date.now() + 3600000));
}); });
await context.route('**:7778/**', async route => { await context.route('**:' + port + '/**', async route => {
if (route.request().resourceType() !== 'document') return route.abort(); if (route.request().resourceType() !== 'document') return route.abort();
documentRequests++; documentRequests++;
await heldDocument; await heldDocument;
@@ -36,8 +38,12 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
return route.continue(); return route.continue();
}); });
const page = await context.newPage(); const page = await context.newPage();
await page.goto(origin + '/dashboard/app-session/indeedhub', { waitUntil: 'domcontentloaded' }); await page.goto(origin + (mode === 'session' ? '/dashboard/app-session/indeedhub' : '/dashboard/apps'), { waitUntil: 'domcontentloaded' });
const frame = page.locator('iframe[src*="7778"]'); if (mode === 'overlay') {
await page.waitForFunction(() => document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'));
await page.evaluate(url => document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').open({ url, title: 'Custom app' }), fixtureUrl.href);
}
const frame = page.locator('iframe[src*="' + port + '"]');
await expect(frame).toHaveCount(1, { timeout: 30000 }); await expect(frame).toHaveCount(1, { timeout: 30000 });
await frame.evaluate(el => { window.__qualificationFrame = el; }); await frame.evaluate(el => { window.__qualificationFrame = el; });
const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' }); const notice = page.getByRole('status').filter({ hasText: 'is taking longer to load' });
@@ -45,11 +51,11 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true); expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Close companion modal', exact: true })).toHaveCount(0);
releaseDocument(); releaseDocument();
await expect(page.frameLocator('iframe[src*="7778"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 }); await expect(page.frameLocator('iframe[src*="' + port + '"]').locator('#ready')).toHaveText('Delayed app ready', { timeout: 15000 });
await expect(notice).toHaveCount(0); await expect(notice).toHaveCount(0);
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true); expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
// Modify readiness only in this isolated browser's store, never the node. // Modify readiness only in this isolated browser's store, never the node.
for (const ready of [false, true]) { if (mode === 'session') for (const ready of [false, true]) {
await page.evaluate(ready => { await page.evaluate(ready => {
const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app'); const app = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('app');
app.data['package-data'].indeedhub['ui-ready'] = ready; app.data['package-data'].indeedhub['ui-ready'] = ready;
@@ -59,7 +65,7 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '@playwrig
expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true); expect(await frame.evaluate(el => el === window.__qualificationFrame)).toBe(true);
} }
expect(documentRequests).toBe(1); expect(documentRequests).toBe(1);
console.log(JSON.stringify({ node, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: true, companionOfferDeferred: true, documentRequests })); console.log(JSON.stringify({ node, mode, width, result: 'PASS', delayedBeyondDeadline: true, sameIframeRetained: true, readinessFlapRetained: mode === 'session' ? true : 'not-applicable', companionOfferDeferred: true, documentRequests }));
} finally { } finally {
releaseDocument(); releaseDocument();
await context.close(); await context.close();