From 79837ac8f2f459935f6737210890b5c12033a53b Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 20 Jan 2025 15:40:09 -0500 Subject: [PATCH 1/3] fix: remote CSS does not get rebuilt properly (#1618) * fix: remote CSS does not get rebuilt properly This fixes an issue where inlined CSS from a remotely loaded `` does not get applied properly due to object reference mutation. * add changeset * ci-cd on ubuntu-22.04 instead of latest * keep mirror meta synced --- .changeset/red-peaches-explode.md | 5 + .github/workflows/ci-cd.yml | 2 +- packages/rrweb/src/replay/index.ts | 24 +++-- .../test/__snapshots__/replayer.test.ts.snap | 67 ++++++++++++++ packages/rrweb/test/replayer.test.ts | 18 ++++ packages/rrweb/test/utils.ts | 92 +++++++++++++++++++ 6 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 .changeset/red-peaches-explode.md diff --git a/.changeset/red-peaches-explode.md b/.changeset/red-peaches-explode.md new file mode 100644 index 0000000000..1792341031 --- /dev/null +++ b/.changeset/red-peaches-explode.md @@ -0,0 +1,5 @@ +--- +"rrweb": patch +--- + +This fixes an issue where inlined CSS from a remotely loaded `` does not get applied properly due to object reference mutation. diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index ca456c5da6..06d4169baf 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: lts/* - + - name: Install Dependencies run: yarn install --frozen-lockfile diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 5dacdc104d..f1347224ce 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1792,17 +1792,27 @@ export class Replayer { const newSn = mirror.getMeta( target as Node & RRNode, ) as serializedElementNodeWithId; + const newNode = buildNodeWithSN( + { + ...newSn, + attributes: { + ...newSn.attributes, + ...(mutation.attributes as attributes), + }, + }, + { + doc: target.ownerDocument as Document, // can be Document or RRDocument + mirror: mirror as Mirror, + skipChild: true, + hackCss: true, + cache: this.cache, + }, + ); + // Update mirror meta's attributes Object.assign( newSn.attributes, mutation.attributes as attributes, ); - const newNode = buildNodeWithSN(newSn, { - doc: target.ownerDocument as Document, // can be Document or RRDocument - mirror: mirror as Mirror, - skipChild: true, - hackCss: true, - cache: this.cache, - }); const siblingNode = target.nextSibling; const parentNode = target.parentNode; if (newNode && parentNode) { diff --git a/packages/rrweb/test/__snapshots__/replayer.test.ts.snap b/packages/rrweb/test/__snapshots__/replayer.test.ts.snap index 4d4711ba63..16ed3dd49c 100644 --- a/packages/rrweb/test/__snapshots__/replayer.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/replayer.test.ts.snap @@ -158,6 +158,73 @@ file-cid-3 " `; +exports[`replayer > can handle remote stylesheets 1`] = ` +"file-frame-2 + + + + + +
+
+ +
+ + + + +file-frame-3 + + + + + + + + + + +file-cid-0 +@charset \\"utf-8\\"; + +.rr-block { background: currentcolor; } + +noscript { display: none !important; } + +html.rrweb-paused *, html.rrweb-paused ::before, html.rrweb-paused ::after { animation-play-state: paused !important; } + + +file-cid-1 +@charset \\"utf-8\\"; + +.OverlayDrawer-modal-187 { } + +.OverlayDrawer-paper-188 { width: 100%; } + +@media (min-width: 48em) { + .OverlayDrawer-paper-188 { width: 38rem; } +} + +@media (min-width: 48em) { +} + +@media (min-width: 48em) { +} +" +`; + exports[`replayer > can handle removing style elements 1`] = ` "file-frame-1 diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 96e2012b15..c38ec356da 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -8,6 +8,7 @@ import { launchPuppeteer, sampleEvents as events, sampleStyleSheetRemoveEvents as stylesheetRemoveEvents, + sampleRemoteStyleSheetEvents as remoteStyleSheetEvents, waitForRAF, } from './utils'; import styleSheetRuleEvents from './events/style-sheet-rule-events'; @@ -209,6 +210,23 @@ describe('replayer', function () { await assertDomSnapshot(page); }); + it('can handle remote stylesheets', async () => { + await page.evaluate(`events = ${JSON.stringify(remoteStyleSheetEvents)}`); + const actionLength = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.play(2500); + replayer['timer']['actions'].length; + `); + expect(actionLength).toEqual( + remoteStyleSheetEvents.filter( + (e) => e.timestamp - remoteStyleSheetEvents[0].timestamp >= 2500, + ).length, + ); + + await assertDomSnapshot(page); + }); + it('can fast forward selection events', async () => { await page.evaluate(`events = ${JSON.stringify(selectionEvents)}`); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 50b39ccb99..9699543a4a 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -561,6 +561,98 @@ export const sampleStyleSheetRemoveEvents: eventWithTime[] = [ }, ]; +export const sampleRemoteStyleSheetEvents: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 1000, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1000, + height: 800, + }, + timestamp: now + 1000, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'link', + attributes: { + rel: 'stylesheet', + href: '', + }, + childNodes: [], + id: 4, + }, + ], + id: 3, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 6, + }, + ], + id: 2, + }, + ], + id: 1, + }, + initialOffset: { + top: 0, + left: 0, + }, + }, + timestamp: now + 1000, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [ + { + id: 4, + attributes: { + href: null, + rel: null, + _cssText: + '.OverlayDrawer-modal-187 { }.OverlayDrawer-paper-188 { width: 100%; }@media (min-width: 48em) {\n .OverlayDrawer-paper-188 { width: 38rem; }\n}@media (min-width: 48em) {\n}@media (min-width: 48em) {\n}', + }, + }, + ], + removes: [], + adds: [], + }, + timestamp: now + 2000, + }, +]; + export const polyfillWebGLGlobals = () => { // polyfill as jsdom does not have support for these classes // consider replacing with https://www.npmjs.com/package/canvas From 24f5fd99ba9490735420ce0275ff3874c9f2d120 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Mon, 20 Jan 2025 13:07:04 -0800 Subject: [PATCH 2/3] refactor: improved tab recording to improve stability (#1632) * refactor: improved tab recording to improve stability * feat: enable to import session * improve stability * feat: enable to edit session name * prevent duplicate rrweb player in the dev mode --- .changeset/four-panthers-fly.md | 5 + .../web-extension/src/background/index.ts | 314 +++++++++++++----- packages/web-extension/src/content/index.ts | 81 +---- packages/web-extension/src/content/inject.ts | 4 - packages/web-extension/src/pages/Player.tsx | 3 + .../web-extension/src/pages/SessionList.tsx | 161 ++++++++- packages/web-extension/src/popup/App.tsx | 122 ++----- packages/web-extension/src/types.ts | 12 +- packages/web-extension/src/utils/index.ts | 6 +- packages/web-extension/src/utils/recording.ts | 90 ----- packages/web-extension/src/utils/storage.ts | 14 +- packages/web-extension/vite.config.ts | 3 +- 12 files changed, 433 insertions(+), 382 deletions(-) create mode 100644 .changeset/four-panthers-fly.md delete mode 100644 packages/web-extension/src/utils/recording.ts diff --git a/.changeset/four-panthers-fly.md b/.changeset/four-panthers-fly.md new file mode 100644 index 0000000000..6bb92a03f8 --- /dev/null +++ b/.changeset/four-panthers-fly.md @@ -0,0 +1,5 @@ +--- +"@rrweb/web-extension": patch +--- + +web-extension: improve recording stability across tabs and enable session import diff --git a/packages/web-extension/src/background/index.ts b/packages/web-extension/src/background/index.ts index f553a3c742..ed299a2007 100644 --- a/packages/web-extension/src/background/index.ts +++ b/packages/web-extension/src/background/index.ts @@ -1,17 +1,25 @@ import Browser from 'webextension-polyfill'; +import { nanoid } from 'nanoid'; import type { eventWithTime } from '@rrweb/types'; import Channel from '~/utils/channel'; import { - type LocalData, + EventName, LocalDataKey, + MessageName, RecorderStatus, - type Settings, - type SyncData, + ServiceName, SyncDataKey, } from '~/types'; -import { pauseRecording, resumeRecording } from '~/utils/recording'; - -const channel = new Channel(); +import type { + LocalData, + RecordStartedMessage, + RecordStoppedMessage, + Session, + Settings, + SyncData, +} from '~/types'; +import { isFirefox } from '~/utils'; +import { addSession } from '~/utils/storage'; void (async () => { // assign default value to settings of this extension @@ -28,105 +36,215 @@ void (async () => { settings, } as SyncData); - // When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab. - Browser.tabs.onActivated.addListener((activeInfo) => { - Browser.storage.local - .get(LocalDataKey.recorderStatus) - .then(async (data) => { - const localData = data as LocalData; - if (!localData || !localData[LocalDataKey.recorderStatus]) return; - let statusData = localData[LocalDataKey.recorderStatus]; - let { status } = statusData; - let bufferedEvents: eventWithTime[] | undefined; - - if (status === RecorderStatus.RECORDING) { - const result = await pauseRecording( - channel, - RecorderStatus.PausedSwitch, - statusData, - ).catch(async () => { - /** - * This error happen when the old tab is closed. - * In this case, the recording process would be stopped through Browser.tabs.onRemoved API. - * So we just read the new status here. - */ - const localData = (await Browser.storage.local.get( - LocalDataKey.recorderStatus, - )) as LocalData; - return { - status: localData[LocalDataKey.recorderStatus], - bufferedEvents, - }; - }); - if (!result) return; - statusData = result.status; - status = statusData.status; - bufferedEvents = result.bufferedEvents; - } - if (status === RecorderStatus.PausedSwitch) - await resumeRecording( - channel, - activeInfo.tabId, - statusData, - bufferedEvents, - ); - }) + const events: eventWithTime[] = []; + const channel = new Channel(); + let recorderStatus: LocalData[LocalDataKey.recorderStatus] = { + status: RecorderStatus.IDLE, + activeTabId: -1, + }; + // Reset recorder status when the extension is reloaded. + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + + channel.on(EventName.StartButtonClicked, async () => { + if (recorderStatus.status !== RecorderStatus.IDLE) return; + recorderStatus = { + status: RecorderStatus.IDLE, + activeTabId: -1, + }; + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + + events.length = 0; // clear events before recording + const tabId = await channel.getCurrentTabId(); + if (tabId === -1) return; + + const res = (await channel + .requestToTab(tabId, ServiceName.StartRecord, {}) + .catch(async (error: Error) => { + recorderStatus.errorMessage = error.message; + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + })) as RecordStartedMessage; + if (!res) return; + Object.assign(recorderStatus, { + status: RecorderStatus.RECORDING, + activeTabId: tabId, + startTimestamp: res.startTimestamp, + }); + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + }); + + channel.on(EventName.StopButtonClicked, async () => { + if (recorderStatus.status === RecorderStatus.IDLE) return; + + if (recorderStatus.status === RecorderStatus.RECORDING) + (await channel + .requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {}) + .catch(() => ({ + message: MessageName.RecordStopped, + endTimestamp: Date.now(), + }))) as RecordStoppedMessage; + recorderStatus = { + status: RecorderStatus.IDLE, + activeTabId: -1, + }; + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + const title = + (await Browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => tabs[0]?.title) + .catch(() => { + // ignore error + })) ?? 'new session'; + const newSession = generateSession(title); + await addSession(newSession, events).catch((e) => { + recorderStatus.errorMessage = (e as { message: string }).message; + void Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + }); + channel.emit(EventName.SessionUpdated, { + session: newSession, + }); + events.length = 0; + }); + + async function pauseRecording(newStatus: RecorderStatus) { + if ( + recorderStatus.status !== RecorderStatus.RECORDING || + recorderStatus.activeTabId === -1 + ) + return; + + const stopResponse = (await channel + .requestToTab(recorderStatus.activeTabId, ServiceName.StopRecord, {}) .catch(() => { - // the extension can't access to the tab + // ignore error + })) as RecordStoppedMessage | undefined; + Object.assign(recorderStatus, { + status: newStatus, + activeTabId: -1, + pausedTimestamp: stopResponse?.endTimestamp, + }); + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + } + channel.on(EventName.PauseButtonClicked, async () => { + if (recorderStatus.status !== RecorderStatus.RECORDING) return; + await pauseRecording(RecorderStatus.PAUSED); + }); + + async function resumeRecording(newTabId: number) { + if ( + ![RecorderStatus.PAUSED, RecorderStatus.PausedSwitch].includes( + recorderStatus.status, + ) + ) + return; + const { startTimestamp, pausedTimestamp } = recorderStatus; + // On Firefox, the new tab is not communicable immediately after it is created. + if (isFirefox()) await new Promise((r) => setTimeout(r, 50)); + const pausedTime = pausedTimestamp ? Date.now() - pausedTimestamp : 0; + // Decrease the time spent in the pause state and make them look like a continuous recording. + events.forEach((event) => { + event.timestamp += pausedTime; + }); + const startResponse = (await channel + .requestToTab(newTabId, ServiceName.StartRecord, {}) + .catch((e: { message: string }) => { + recorderStatus.errorMessage = e.message; + void Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + })) as RecordStartedMessage | undefined; + if (!startResponse) { + // Restore the events data when the recording fails to start. + events.forEach((event) => { + event.timestamp -= pausedTime; }); + return; + } + recorderStatus = { + status: RecorderStatus.RECORDING, + activeTabId: newTabId, + startTimestamp: (startTimestamp || Date.now()) + pausedTime, + }; + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + } + channel.on(EventName.ResumeButtonClicked, async () => { + if (recorderStatus.status !== RecorderStatus.PAUSED) return; + recorderStatus.errorMessage = undefined; + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, + }); + const tabId = await channel.getCurrentTabId(); + await resumeRecording(tabId); + }); + + channel.on(EventName.ContentScriptEmitEvent, (data) => { + events.push(data as eventWithTime); + }); + + // When tab is changed during the recording process, pause recording in the old tab and start a new one in the new tab. + Browser.tabs.onActivated.addListener((activeInfo) => { + void (async () => { + if ( + recorderStatus.status !== RecorderStatus.RECORDING && + recorderStatus.status !== RecorderStatus.PausedSwitch + ) + return; + if (activeInfo.tabId === recorderStatus.activeTabId) return; + if (recorderStatus.status === RecorderStatus.RECORDING) + await pauseRecording(RecorderStatus.PausedSwitch); + if (recorderStatus.status === RecorderStatus.PausedSwitch) + await resumeRecording(activeInfo.tabId); + })(); + return; }); // If the recording can't start on an invalid tab, resume it when the tab content is updated. Browser.tabs.onUpdated.addListener(function (tabId, info) { if (info.status !== 'complete') return; - Browser.storage.local - .get(LocalDataKey.recorderStatus) - .then(async (data) => { - const localData = data as LocalData; - if (!localData || !localData[LocalDataKey.recorderStatus]) return; - const { status, activeTabId } = localData[LocalDataKey.recorderStatus]; - if (status !== RecorderStatus.PausedSwitch || activeTabId === tabId) - return; - await resumeRecording( - channel, - tabId, - localData[LocalDataKey.recorderStatus], - ); - }) - .catch(() => { - // the extension can't access to the tab - }); + if ( + recorderStatus.status !== RecorderStatus.PausedSwitch || + recorderStatus.activeTabId === tabId + ) + return; + void resumeRecording(tabId); }); /** - * When the current tab is closed, the recording events will be lost because this event is fired after it is closed. - * This event listener is just used to make sure the recording status is updated. + * When the current tab is closed, and there's no other tab to resume recording, make sure the recording status is updated to SwitchPaused. */ Browser.tabs.onRemoved.addListener((tabId) => { - Browser.storage.local - .get(LocalDataKey.recorderStatus) - .then(async (data) => { - const localData = data as LocalData; - if (!localData || !localData[LocalDataKey.recorderStatus]) return; - const { status, activeTabId, startTimestamp } = - localData[LocalDataKey.recorderStatus]; - if (activeTabId !== tabId || status !== RecorderStatus.RECORDING) - return; - - // Update the recording status to make it resumable after users switch to other tabs. - const statusData: LocalData[LocalDataKey.recorderStatus] = { - status: RecorderStatus.PausedSwitch, - activeTabId, - startTimestamp, - pausedTimestamp: Date.now(), - }; - await Browser.storage.local.set({ - [LocalDataKey.recorderStatus]: statusData, - }); - }) - .catch((err) => { - console.error(err); + void (async () => { + if ( + recorderStatus.activeTabId !== tabId || + recorderStatus.status !== RecorderStatus.RECORDING + ) + return; + // Update the recording status to make it resumable after users switch to other tabs. + Object.assign(recorderStatus, { + status: RecorderStatus.PausedSwitch, + activeTabId: -1, + pausedTimestamp: Date.now(), + }); + + await Browser.storage.local.set({ + [LocalDataKey.recorderStatus]: recorderStatus, }); + })(); }); })(); @@ -160,3 +278,15 @@ function setDefaultSettings( } } } + +function generateSession(title: string) { + const newSession: Session = { + id: nanoid(), + name: title, + tags: [], + createTimestamp: Date.now(), + modifyTimestamp: Date.now(), + recorderVersion: Browser.runtime.getManifest().version_name || 'unknown', + }; + return newSession; +} diff --git a/packages/web-extension/src/content/index.ts b/packages/web-extension/src/content/index.ts index 0fb866c1e5..baf1c8de8a 100644 --- a/packages/web-extension/src/content/index.ts +++ b/packages/web-extension/src/content/index.ts @@ -1,16 +1,14 @@ import Browser from 'webextension-polyfill'; -import { nanoid } from 'nanoid'; -import type { eventWithTime } from '@rrweb/types'; import { type LocalData, LocalDataKey, RecorderStatus, ServiceName, - type Session, type RecordStartedMessage, type RecordStoppedMessage, MessageName, type EmitEventMessage, + EventName, } from '~/types'; import Channel from '~/utils/channel'; import { isInCrossOriginIFrame } from '~/utils'; @@ -46,8 +44,6 @@ void (() => { })(); async function initMainPage() { - let bufferedEvents: eventWithTime[] = []; - let newEvents: eventWithTime[] = []; let startResponseCb: ((response: RecordStartedMessage) => void) | undefined = undefined; channel.provide(ServiceName.StartRecord, async () => { @@ -58,24 +54,6 @@ async function initMainPage() { }; }); }); - channel.provide(ServiceName.ResumeRecord, async (params) => { - const { events, pausedTimestamp } = params as { - events: eventWithTime[]; - pausedTimestamp: number; - }; - bufferedEvents = events; - startRecord(); - return new Promise((resolve) => { - startResponseCb = (response) => { - const pausedTime = response.startTimestamp - pausedTimestamp; - // Decrease the time spent in the pause state and make them look like a continuous recording. - bufferedEvents.forEach((event) => { - event.timestamp += pausedTime; - }); - resolve(response); - }; - }); - }); let stopResponseCb: ((response: RecordStoppedMessage) => void) | undefined = undefined; channel.provide(ServiceName.StopRecord, () => { @@ -83,29 +61,7 @@ async function initMainPage() { return new Promise((resolve) => { stopResponseCb = (response: RecordStoppedMessage) => { stopResponseCb = undefined; - const newSession = generateSession(); - response.session = newSession; - bufferedEvents = []; - newEvents = []; - resolve(response); - // clear cache - void Browser.storage.local.set({ - [LocalDataKey.bufferedEvents]: [], - }); - }; - }); - }); - channel.provide(ServiceName.PauseRecord, () => { - window.postMessage({ message: MessageName.StopRecord }); - return new Promise((resolve) => { - stopResponseCb = (response: RecordStoppedMessage) => { - stopResponseCb = undefined; - bufferedEvents = []; - newEvents = []; resolve(response); - void Browser.storage.local.set({ - [LocalDataKey.bufferedEvents]: response.events, - }); }; }); }); @@ -132,15 +88,14 @@ async function initMainPage() { event.data.message === MessageName.RecordStopped && stopResponseCb ) { - const data = event.data as RecordStoppedMessage; // On firefox, the event.data is immutable, so we need to clone it to avoid errors. - const newData = { - ...data, - }; - newData.events = bufferedEvents.concat(data.events); - stopResponseCb(newData); + const data = { ...(event.data as RecordStoppedMessage) }; + stopResponseCb(data); } else if (event.data.message === MessageName.EmitEvent) - newEvents.push((event.data as EmitEventMessage).event); + channel.emit( + EventName.ContentScriptEmitEvent, + (event.data as EmitEventMessage).event, + ); }, ); @@ -150,17 +105,7 @@ async function initMainPage() { RecorderStatus.RECORDING ) { startRecord(); - bufferedEvents = localData[LocalDataKey.bufferedEvents] || []; } - - // Before unload pages, cache the new events in the local storage. - window.addEventListener('beforeunload', (event) => { - if (!newEvents.length) return; - event.preventDefault(); - void Browser.storage.local.set({ - [LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents), - }); - }); } async function initCrossOriginIframe() { @@ -193,15 +138,3 @@ function startRecord() { document.documentElement.removeChild(scriptEl); }; } - -function generateSession() { - const newSession: Session = { - id: nanoid(), - name: document.title, - tags: [], - createTimestamp: Date.now(), - modifyTimestamp: Date.now(), - recorderVersion: Browser.runtime.getManifest().version_name || 'unknown', - }; - return newSession; -} diff --git a/packages/web-extension/src/content/inject.ts b/packages/web-extension/src/content/inject.ts index b83a343983..82c59d4602 100644 --- a/packages/web-extension/src/content/inject.ts +++ b/packages/web-extension/src/content/inject.ts @@ -8,15 +8,12 @@ import { isInCrossOriginIFrame } from '~/utils'; * This script is injected into both main page and cross-origin IFrames through