diff --git a/libs/widget-lib/src/cowSwapWidget.ts b/libs/widget-lib/src/cowSwapWidget.ts index 3e1d673246..adfe34c028 100644 --- a/libs/widget-lib/src/cowSwapWidget.ts +++ b/libs/widget-lib/src/cowSwapWidget.ts @@ -68,7 +68,7 @@ export function createCowSwapWidget(container: HTMLElement, props: CowSwapWidget if (typeof window === 'undefined') return noopHandler // 1. Create a brand new iframe - const iframe = createIframe(currentParams) + let iframe = createIframe(currentParams) const iframeOrigin = getIframeOrigin(iframe) logWidget('Resolved trusted iframe origin', { iframeOrigin }) const windowListeners: WindowListener[] = [] @@ -160,12 +160,24 @@ export function createCowSwapWidget(container: HTMLElement, props: CowSwapWidget // 10. Listen for Safe SDK messages from the iframe only when explicitly enabled by the host. iframeSafeSdkBridge = createIframeSafeSdkBridge(enableSafeSdkBridge, window, iframeWindow, iframeOrigin) - const loadingContext = widgetIframeLoading(container, iframe, setup, destroy, props.onLoadingError) + const loadingContext = widgetIframeLoading(container, iframe, reloadIframe, props.onLoadingError) cancelWidgetLoading = loadingContext.cancelWidgetLoading const onWidgetReady = loadingContext.onWidgetReady } + // Rebuild the iframe from scratch and re-run setup. Reusing the same sandboxed frame and only + // swapping its `src` did not reliably re-fetch the widget on retry, so a failed load could never + // recover. A brand new iframe gives a clean document that re-emits READY once it loads. + function reloadIframe(): void { + destroy() + + iframe = createIframe(currentParams) + container.appendChild(iframe) + + setup() + } + function destroy(skipIframeDestroy = false): void { // Disconnect rpc provider and unsubscribe to events iframeRpcProviderBridge?.disconnect() @@ -192,7 +204,10 @@ export function createCowSwapWidget(container: HTMLElement, props: CowSwapWidget // 11. Return the handler, so the widget, listeners, and provider can be updated return { - iframe, + // `iframe` is rebuilt on retry, so expose it lazily to always hand back the live element. + get iframe() { + return iframe + }, updateParams: (newParams: CowSwapWidgetParams) => { if (!iframeWindow) return currentParams = resolveWidgetParams(newParams) diff --git a/libs/widget-lib/src/widgetIframeLoading.test.ts b/libs/widget-lib/src/widgetIframeLoading.test.ts index 004dcdace2..fa96c5f94b 100644 --- a/libs/widget-lib/src/widgetIframeLoading.test.ts +++ b/libs/widget-lib/src/widgetIframeLoading.test.ts @@ -54,9 +54,58 @@ describe('widgetIframeLoading error UI styling', () => { expect(document.head.querySelectorAll(`style[data-${ERROR_CLASS}]`).length).toBeLessThanOrEqual(1) }) + + it('shows a loading state on the reload button while the retry is in progress', () => { + const { container, iframe } = setup() + failIframe(iframe) + + const reloadBtn = container.querySelector(`.${ERROR_CLASS} button`) + expect(reloadBtn?.disabled).toBe(false) + + reloadBtn?.click() + + // The panel stays mounted and the button is disabled (cursor:progress) while the retry runs. + expect(container.querySelector(`.${ERROR_CLASS}`)).not.toBeNull() + expect(reloadBtn?.disabled).toBe(true) + }) + + it('triggers a full reload when the reload button is clicked', () => { + const { container, iframe, reload } = setup() + failIframe(iframe) + + container.querySelector(`.${ERROR_CLASS} button`)?.click() + + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('removes the error panel and reveals the iframe once the widget is ready', () => { + const { container, iframe, state } = setup() + failIframe(iframe) + expect(container.querySelector(`.${ERROR_CLASS}`)).not.toBeNull() + expect(iframe.style.display).toBe('none') + + state.onWidgetReady() + + expect(container.querySelector(`.${ERROR_CLASS}`)).toBeNull() + expect(iframe.style.display).toBe('') + }) + + it('keeps a single error panel across repeated failures on the same widget', () => { + const { container, iframe } = setup() + + failIframe(iframe) + failIframe(iframe) + + expect(container.querySelectorAll(`.${ERROR_CLASS}`).length).toBe(1) + }) }) -function setup(): { container: HTMLElement; iframe: HTMLIFrameElement; state: LoadingState } { +function setup(): { + container: HTMLElement + iframe: HTMLIFrameElement + state: LoadingState + reload: jest.Mock +} { const container = document.createElement('div') document.body.appendChild(container) @@ -64,15 +113,11 @@ function setup(): { container: HTMLElement; iframe: HTMLIFrameElement; state: Lo iframe.src = `${WIDGET_ORIGIN}/` container.appendChild(iframe) - const state = widgetIframeLoading( - container, - iframe, - () => void 0, - () => void 0, - ) + const reload = jest.fn() + const state = widgetIframeLoading(container, iframe, reload) activeStates.push(state) - return { container, iframe, state } + return { container, iframe, state, reload } } function failIframe(iframe: HTMLIFrameElement): void { diff --git a/libs/widget-lib/src/widgetIframeLoading.ts b/libs/widget-lib/src/widgetIframeLoading.ts index ebb7a70ee7..8143d97bc4 100644 --- a/libs/widget-lib/src/widgetIframeLoading.ts +++ b/libs/widget-lib/src/widgetIframeLoading.ts @@ -10,36 +10,41 @@ interface WidgetIframeLoadingContext { export function widgetIframeLoading( container: HTMLElement, iframe: HTMLIFrameElement, - setup: () => void, - destroy: (skipIframeDestroy?: boolean) => void, + reload: () => void, onWidgetLoadingError?: () => void, ): WidgetIframeLoadingContext { - const originalSrc = iframe.src - let cancelled = false let timeout: number | null = null + function removeLoadingErrorContainer(): void { + const existing = container.getElementsByClassName(WIDGET_LOADING_ERROR_CLASS)[0] + if (existing) container.removeChild(existing) + } + function onLoadingError(): void { onWidgetLoadingError?.() iframe.style.display = 'none' ensureLoadingErrorStyles() + // Replace any previous error/loading panel so a repeated failure doesn't stack panels. + removeLoadingErrorContainer() + const loadingContainer = document.createElement('div') const reloadBtn = document.createElement('button') const errorText = document.createElement('p') reloadBtn.innerText = 'Reload' reloadBtn.addEventListener('click', () => { - container.removeChild(loadingContainer) - destroy(true) - iframe.src = 'about:blank' - iframe.style.display = '' - - setTimeout(() => { - iframe.src = originalSrc - setup() - }, 100) + // Keep the panel mounted and switch it to a loading state so the retry is visibly in + // progress; it's cleared by onWidgetReady on success or replaced by onLoadingError on + // another failure. `reload` rebuilds the iframe from scratch so the widget actually + // re-fetches and re-emits READY — swapping `iframe.src` on the sandboxed frame in place + // did not reliably reload it. + reloadBtn.disabled = true + errorText.innerText = 'Loading…' + + reload() }) errorText.innerText = "Couldn't load the page. Please, try again later" @@ -69,8 +74,9 @@ export function widgetIframeLoading( onWidgetReady() { if (timeout) clearTimeout(timeout) - const loadingContainer = document.getElementsByClassName(WIDGET_LOADING_ERROR_CLASS)[0] - if (loadingContainer) container.removeChild(loadingContainer) + // Reveal the iframe again — it is hidden while an error/retry panel is shown. + iframe.style.display = '' + removeLoadingErrorContainer() }, cancelWidgetLoading() { cancelled = true