Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2476c83
fix: fix widget retry logic
Danziger Jun 9, 2026
debf435
Merge branch 'develop' into feat/fix-widget-retry-logic
Danziger Jun 10, 2026
18a7631
Merge branch 'develop' into feat/fix-widget-retry-logic
Danziger Jun 11, 2026
167870f
Merge branch 'develop' into feat/fix-widget-retry-logic
Danziger Jun 15, 2026
158a172
Merge branch 'develop' into feat/fix-widget-retry-logic
fairlighteth Jun 16, 2026
8a63567
Merge branch 'develop' into feat/fix-widget-retry-logic
fairlighteth Jun 19, 2026
afba401
Merge branch 'develop' into feat/fix-widget-retry-logic
elena-zh Jun 23, 2026
8bf7e8e
docs: spec for CSP-safe widget error UI
shoom3301 Jun 23, 2026
e99f863
feat(widget): refactor widget loading error
shoom3301 Jun 23, 2026
5d9982a
feat(widget): restyle widget loading
shoom3301 Jun 23, 2026
3fa32c7
fix: pass onLoadingError from CowSwapWidget
shoom3301 Jun 23, 2026
e988bb3
chore: remove docs
shoom3301 Jun 23, 2026
02edf78
chore: add localStorageOverride WIDGET_BASE_URL
shoom3301 Jun 24, 2026
fd753b5
docs: design for rootStyle rename (iframeStyle -> rootStyle on contai…
shoom3301 Jun 24, 2026
cfff9c3
docs: implementation plan for rootStyle rename
shoom3301 Jun 24, 2026
c06da84
feat(widget)!: rename iframeStyle to rootStyle and apply it to the co…
shoom3301 Jun 24, 2026
8ae14af
refactor(widget-configurator): rename iframeStyle to rootStyle
shoom3301 Jun 24, 2026
69c5025
refactor(widget-configurator): rename iframe-styles UI copy to root
shoom3301 Jun 24, 2026
da91023
chore: changeset for rootStyle rename
shoom3301 Jun 24, 2026
2ad4ce9
chore: remove docs
shoom3301 Jun 24, 2026
869c4c6
Merge branch 'develop' into feat/fix-widget-retry-logic
shoom3301 Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface VersionedCowSwapWidgetProps {
provider?: CowSwapWidgetProps['provider']
listeners?: CowWidgetEventListeners
onReady?: () => void
onLoadingError?: () => void
}

function attachIframeLoadReveal(host: HTMLElement, onIframeLoad: () => void): void {
Expand Down Expand Up @@ -80,8 +81,9 @@ export function VersionedCowSwapWidget({
provider,
listeners,
onReady,
onLoadingError,
}: VersionedCowSwapWidgetProps): ReactNode {
const widgetProps = { params, provider, listeners, onReady }
const widgetProps = { params, provider, listeners, onReady, onLoadingError }

if (sdkVersion === 'local') {
return <CowSwapWidget {...widgetProps} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ declare global {
}
}

const onLoadingError: () => void = () => {
console.log('WIDGET LOADING ERROR')
}

// eslint-disable-next-line max-lines-per-function
export function Configurator({ title }: { title: string }): ReactNode {
const configuratorRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -191,6 +195,7 @@ export function Configurator({ title }: { title: string }): ReactNode {
provider={configuratorState.widgetMode === 'standalone' ? undefined : provider}
listeners={listeners}
onReady={handlePreviewReady}
onLoadingError={onLoadingError}
/>
</Box>

Expand Down
168 changes: 94 additions & 74 deletions libs/widget-lib/src/cowSwapWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,117 +76,137 @@ export function createCowSwapWidget(container: HTMLElement, props: CowSwapWidget
container.innerHTML = ''
container.appendChild(iframe)

const { cancelWidgetLoading, onWidgetReady } = widgetIframeLoading(
iframe,
props.onLoadingError,
props.loadingErrorStyles,
)
let iframeWindow: Window | null = null
let updateInterceptDeepLinks: () => void = () => void 0
let updateWidgetHooks: () => void = () => void 0
let cancelWidgetLoading: () => void = () => void 0

const { contentWindow: iframeWindow } = iframe
if (!iframeWindow) {
console.error('Iframe does not contain a window', iframe)
throw new Error('Iframe does not contain a window!')
}
let iFrameCowEventEmitter: IframeCowEventEmitter | null = null
let iframeRpcProviderBridge: IframeRpcProviderBridge | null = null
let iframeSafeSdkBridge: IframeSafeSdkBridge | null = null

windowListeners.push(
listenToReady(iframeWindow, iframeOrigin, () => {
onReady?.()
onWidgetReady()
}),
)
let heightChangeListeners: WindowListener[] = []
let widgetHooksListener: WindowListener | null = null

// 3. Send appCode (once the widget posts the ACTIVATE message)
windowListeners.push(sendAppCodeOnActivation(iframeWindow, iframeOrigin, currentParams.appCode))
function setup(): void {
iframeWindow = iframe.contentWindow
if (!iframeWindow) {
console.error('Iframe does not contain a window', iframe)
throw new Error('Iframe does not contain a window!')
}

// 4. Handle widget height changes (re-registered when params change so defaults/maxHeight stay in sync)
const heightChangeListeners: WindowListener[] = listenToHeightChanges(iframe, iframeOrigin, (nextHeight) => {
lastDynamicHeight = nextHeight
})
windowListeners.push(
listenToReady(iframeWindow, iframeOrigin, () => {
onReady?.()
onWidgetReady()
}),
)

// 3. Send appCode (once the widget posts the ACTIVATE message)
windowListeners.push(sendAppCodeOnActivation(iframeWindow, iframeOrigin, currentParams.appCode))

// 4. Handle widget height changes (re-registered when params change so defaults/maxHeight stay in sync)
heightChangeListeners = listenToHeightChanges(iframe, iframeOrigin, (nextHeight) => {
lastDynamicHeight = nextHeight
})

// 5. Intercept deeplinks navigation in the iframe
let interceptDeepLinksListener: WindowListener | null = null

// 5. Intercept deeplinks navigation in the iframe
let interceptDeepLinksListener: WindowListener | null = null
updateInterceptDeepLinks = () => {
if (!iframeWindow) return

function updateInterceptDeepLinks(): void {
if (!iframeWindow) return
if (interceptDeepLinksListener) {
window.removeEventListener('message', interceptDeepLinksListener)
}

// If `window.open` is disabled, do not intercept deep links.
if (currentParams.disableWindowOpen) return

interceptDeepLinksListener = interceptDeepLinks(iframeOrigin, iframeWindow)
windowListeners.push(interceptDeepLinksListener)
}
// 6. Handle two-way communication of widget hooks

updateWidgetHooks = () => {
if (!iframeWindow) return

if (interceptDeepLinksListener) {
window.removeEventListener('message', interceptDeepLinksListener)
if (widgetHooksListener) {
window.removeEventListener('message', widgetHooksListener)
}

widgetHooksListener = processWidgetHooks(iframeWindow, iframeOrigin, currentParams.hooks)
}

// If `window.open` is disabled, do not intercept deep links.
if (currentParams.disableWindowOpen) return
updateInterceptDeepLinks()
updateWidgetHooks()

// 7. Handle and forward widget events to the listeners
iFrameCowEventEmitter = new IframeCowEventEmitter(window, iframeOrigin, iframeWindow, listeners)

interceptDeepLinksListener = interceptDeepLinks(iframeOrigin, iframeWindow)
windowListeners.push(interceptDeepLinksListener)
// 8. Wire up the iframeRpcProviderBridge with the provider (so RPC calls flow back and forth)
iframeRpcProviderBridge = updateProvider(iframeWindow, iframeOrigin, null, provider)

// 9. Schedule the uploading of the params, once the iframe is loaded
iframe.addEventListener('load', () => {
if (!iframeWindow) return
updateParams(iframeWindow, iframeOrigin, currentParams, provider)
})

// 10. Listen for Safe SDK messages from the iframe only when explicitly enabled by the host.
iframeSafeSdkBridge = enableSafeSdkBridge ? new IframeSafeSdkBridge(window, iframeWindow) : null

const loadingContext = widgetIframeLoading(container, iframe, setup, destroy, props.onLoadingError)

cancelWidgetLoading = loadingContext.cancelWidgetLoading
const onWidgetReady = loadingContext.onWidgetReady
}
// 6. Handle two-way communication of widget hooks
let widgetHooksListener: WindowListener | null = null

function updateWidgetHooks(): void {
if (!iframeWindow) return
function destroy(skipIframeDestroy = false): void {
// Disconnect rpc provider and unsubscribe to events
iframeRpcProviderBridge?.disconnect()
// Stop listening for cow events
iFrameCowEventEmitter?.stopListeningIframe()

// Disconnect all listeners
heightChangeListeners.forEach((listener) => window.removeEventListener('message', listener))
windowListeners.forEach((listener) => window.removeEventListener('message', listener))
if (widgetHooksListener) {
window.removeEventListener('message', widgetHooksListener)
}

widgetHooksListener = processWidgetHooks(iframeWindow, iframeOrigin, currentParams.hooks)
}

updateInterceptDeepLinks()
updateWidgetHooks()
// Stop listening for SDK messages
iframeSafeSdkBridge?.stopListening()

// 7. Handle and forward widget events to the listeners
const iFrameCowEventEmitter = new IframeCowEventEmitter(window, iframeOrigin, iframeWindow, listeners)
// Destroy the iframe
if (!skipIframeDestroy && iframe && iframe.parentNode === container) container.removeChild(iframe)

// 8. Wire up the iframeRpcProviderBridge with the provider (so RPC calls flow back and forth)
let iframeRpcProviderBridge = updateProvider(iframeWindow, iframeOrigin, null, provider)

// 9. Schedule the uploading of the params, once the iframe is loaded
iframe.addEventListener('load', () => {
updateParams(iframeWindow, iframeOrigin, currentParams, provider)
})
cancelWidgetLoading?.()
}

// 10. Listen for Safe SDK messages from the iframe only when explicitly enabled by the host.
const iframeSafeSdkBridge = enableSafeSdkBridge ? new IframeSafeSdkBridge(window, iframeWindow) : null
setup()

// 11. Return the handler, so the widget, listeners, and provider can be updated
return {
iframe,
updateParams: (newParams: CowSwapWidgetParams) => {
if (!iframeWindow) return
currentParams = resolveWidgetParams(newParams)

updateIframeElement(iframe, currentParams, lastDynamicHeight)
updateParams(iframeWindow, iframeOrigin, currentParams, provider)
updateInterceptDeepLinks()
updateWidgetHooks()
},
updateListeners: (newListeners?: CowWidgetEventListeners) => iFrameCowEventEmitter.updateListeners(newListeners),
updateListeners: (newListeners?: CowWidgetEventListeners) => iFrameCowEventEmitter?.updateListeners(newListeners),
updateProvider: (newProvider) => {
if (!iframeWindow) return

provider = newProvider
iframeRpcProviderBridge = updateProvider(iframeWindow, iframeOrigin, iframeRpcProviderBridge, newProvider)
},

destroy: () => {
// Disconnect rpc provider and unsubscribe to events
iframeRpcProviderBridge.disconnect()
// Stop listening for cow events
iFrameCowEventEmitter.stopListeningIframe()

// Disconnect all listeners
heightChangeListeners.forEach((listener) => window.removeEventListener('message', listener))
windowListeners.forEach((listener) => window.removeEventListener('message', listener))
if (widgetHooksListener) {
window.removeEventListener('message', widgetHooksListener)
}

// Stop listening for SDK messages
iframeSafeSdkBridge?.stopListening()

// Destroy the iframe
if (iframe && iframe.parentNode === container) container.removeChild(iframe)

cancelWidgetLoading()
},
destroy,
}
}

Expand Down
5 changes: 0 additions & 5 deletions libs/widget-lib/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,6 @@ export interface CowSwapWidgetProps {
listeners?: CowWidgetEventListeners
onReady?(): void
onLoadingError?(): void
/**
* Custom CSS appended to the error document displayed inside the iframe when the widget fails to load.
* Use it to override the default look (`.errorContent` and `.reloadButton` classes).
*/
loadingErrorStyles?: string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the loading error DOM is on the host side, they can style it using css selector .cow-widget-loading-error

enableSafeSdkBridge?: boolean
}

Expand Down
80 changes: 80 additions & 0 deletions libs/widget-lib/src/widgetIframeLoading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @jest-environment jsdom
*/

import { widgetIframeLoading } from './widgetIframeLoading'

const ERROR_CLASS = 'cow-widget-loading-error'
const WIDGET_ORIGIN = 'https://swap.cow.fi'

type LoadingState = ReturnType<typeof widgetIframeLoading>

const activeStates: LoadingState[] = []

describe('widgetIframeLoading error UI styling', () => {
beforeEach(() => {
// The iframe `error` handler logs intentionally; keep test output pristine.
jest.spyOn(console, 'error').mockImplementation(() => void 0)
})

afterEach(() => {
activeStates.splice(0).forEach((state) => state.cancelWidgetLoading())
document.body.innerHTML = ''
document.head.querySelectorAll(`style[data-${ERROR_CLASS}]`).forEach((el) => el.remove())
jest.restoreAllMocks()
})

it('renders the error container with its class', () => {
const { container, iframe } = setup()

failIframe(iframe)

const errorEl = container.querySelector<HTMLElement>(`.${ERROR_CLASS}`)
expect(errorEl).not.toBeNull()
})

it('injects the default error styles for the container and its children', () => {
const { iframe } = setup()

failIframe(iframe)

const style = document.head.querySelector(`style[data-${ERROR_CLASS}]`)
expect(style).not.toBeNull()
expect(style?.textContent).toContain(`.${ERROR_CLASS}`)
expect(style?.textContent).toContain(`.${ERROR_CLASS} button`)
expect(style?.textContent).toContain(`.${ERROR_CLASS} p`)
})

it('injects the default error styles at most once across repeated failures', () => {
const first = setup()
failIframe(first.iframe)

const second = setup()
failIframe(second.iframe)

expect(document.head.querySelectorAll(`style[data-${ERROR_CLASS}]`).length).toBeLessThanOrEqual(1)
})
})

function setup(): { container: HTMLElement; iframe: HTMLIFrameElement; state: LoadingState } {
const container = document.createElement('div')
document.body.appendChild(container)

const iframe = document.createElement('iframe')
iframe.src = `${WIDGET_ORIGIN}/`
container.appendChild(iframe)

const state = widgetIframeLoading(
container,
iframe,
() => void 0,
() => void 0,
)
activeStates.push(state)

return { container, iframe, state }
}

function failIframe(iframe: HTMLIFrameElement): void {
iframe.dispatchEvent(new Event('error'))
}
Loading
Loading