diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index 9b76b2af..a01ab3c8 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -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 run with reduced concurrency and no simultaneous build is required and running. 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. diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 73bc97bc..03ced061 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -92,6 +92,9 @@ @error="onIframeError" /> + +
@@ -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 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) { diff --git a/neode-ui/src/components/AppSlowLoadNotice.vue b/neode-ui/src/components/AppSlowLoadNotice.vue new file mode 100644 index 00000000..bf2b14b9 --- /dev/null +++ b/neode-ui/src/components/AppSlowLoadNotice.vue @@ -0,0 +1,15 @@ + + + diff --git a/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts b/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts new file mode 100644 index 00000000..3e898574 --- /dev/null +++ b/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts @@ -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() + }) +}) diff --git a/neode-ui/src/views/appSession/AppSessionFrame.vue b/neode-ui/src/views/appSession/AppSessionFrame.vue index ddd7c54d..9d1ac12f 100644 --- a/neode-ui/src/views/appSession/AppSessionFrame.vue +++ b/neode-ui/src/views/appSession/AppSessionFrame.vue @@ -61,14 +61,8 @@ />
-
- {{ appTitle }} is taking longer to load. You can keep waiting. - - -
+