From cff65ae8b2f77d5c5987175b5d01145e0cff7ffc Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 20 Aug 2024 17:16:57 +0100 Subject: [PATCH] Add a queueing mechanism so that in Live Mode we don't render full snapshots until we receive the stylesheet assets to avoid a flash of unstyled content (fouc) --- packages/rrweb/src/record/index.ts | 31 +++++++-- .../src/record/observers/asset-manager.ts | 10 +-- packages/rrweb/src/replay/machine.ts | 64 ++++++++++++++---- packages/rrweb/src/types.ts | 1 + packages/rrweb/test/events/assets.ts | 2 + ...stylesheet-assets-to-avoid-fouc-1-snap.png | Bin 0 -> 10747 bytes .../test/replay/asset-integration.test.ts | 18 +++++ packages/types/src/index.ts | 11 +++ 8 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 5546a66af6..1f3842e9b2 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -409,6 +409,9 @@ function record( shadowDomManager.init(); + let liveBuffer = 0; + let assetCount = 0; + mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting const node = snapshot(document, { mirror, @@ -444,7 +447,19 @@ function record( stylesheetManager.attachLinkElement(linkEl, childSn); }, onAssetDetected: (asset: asset) => { - assetManager.capture(asset); + const assetStatus = assetManager.capture(asset); + if ( + 'timeout' in assetStatus && // removeme when we just capture one asset from srcset + assetStatus.timeout + ) { + // currently only stylesheet assets return a timeout + // indicating that we want the fullsnapshot to wait in order to avoid a flash of unstyled content + liveBuffer = Math.max( + liveBuffer, + assetStatus.timeout + 100, // add a guess for worst case processing time + ); + } + assetCount += 1; }, keepIframeSrcFn, }); @@ -452,14 +467,18 @@ function record( if (!node) { return console.warn('Failed to snapshot the document'); } - + const data: any = { + node, + initialOffset: getWindowScroll(window), + }; + if (liveBuffer > 0) { + data.liveBuffer = liveBuffer; + data.assetCount = assetCount; + } wrappedEmit( { type: EventType.FullSnapshot, - data: { - node, - initialOffset: getWindowScroll(window), - }, + data, }, isCheckout, ); diff --git a/packages/rrweb/src/record/observers/asset-manager.ts b/packages/rrweb/src/record/observers/asset-manager.ts index 9032d650c9..fdadf83387 100644 --- a/packages/rrweb/src/record/observers/asset-manager.ts +++ b/packages/rrweb/src/record/observers/asset-manager.ts @@ -177,10 +177,7 @@ export default class AssetManager { } const processStylesheet = () => { cssRules = el.sheet!.cssRules; // update, as a mutation may have since occurred - const cssText = stringifyCssRules( - cssRules, - sheetBaseHref, - ); + const cssText = stringifyCssRules(cssRules, sheetBaseHref); const payload: SerializedCssTextArg = { rr_type: 'CssText', cssTexts: [cssText], @@ -220,7 +217,10 @@ export default class AssetManager { requestIdleCallback(processStylesheet, { timeout, }); - return { status: 'capturing' }; // 'processing' ? + return { + status: 'capturing', // 'processing' ? + timeout, + }; } else { processStylesheet(); return { status: 'captured' }; diff --git a/packages/rrweb/src/replay/machine.ts b/packages/rrweb/src/replay/machine.ts index 08b72c9543..d01ad5db4b 100644 --- a/packages/rrweb/src/replay/machine.ts +++ b/packages/rrweb/src/replay/machine.ts @@ -87,6 +87,10 @@ export function createPlayerService( context: PlayerContext, { getCastFn, applyEventsSynchronously, emitter }: PlayerAssets, ) { + const addEventQueue: Array = []; + let addEventQueueTimeout: ReturnType | -1; + let addEventQueueAssetCount = -1; + const playerMachine = createMachine( { id: 'player', @@ -236,10 +240,9 @@ export function createPlayerService( }, }), addEvent: assign((ctx, machineEvent) => { - const { baselineTime, timer, events } = ctx; + const { events } = ctx; if (machineEvent.type === 'ADD_EVENT') { const { event } = machineEvent.payload; - addDelay(event, baselineTime); let end = events.length - 1; if (!events[end] || events[end].timestamp <= event.timestamp) { @@ -262,17 +265,52 @@ export function createPlayerService( events.splice(insertionIndex, 0, event); } - const isSync = event.timestamp < baselineTime; - const castFn = getCastFn(event, isSync); - if (isSync) { - castFn(); - } else if (timer.isActive()) { - timer.addAction({ - doAction: () => { - castFn(); - }, - delay: event.delay!, - }); + const castOrScheduleEvent = (event: eventWithTime) => { + const { baselineTime, timer } = ctx; + addDelay(event, baselineTime); + const isSync = event.timestamp < baselineTime; + const castFn = getCastFn(event, isSync); + if (isSync) { + castFn(); + } else if (timer.isActive()) { + timer.addAction({ + doAction: () => { + castFn(); + }, + delay: event.delay!, + }); + } + }; + + const flushAddEventQueue = () => { + addEventQueueTimeout = -1; + while (addEventQueue.length) { + castOrScheduleEvent(addEventQueue.shift()!); + } + }; + + if (event.type === EventType.Asset && addEventQueueTimeout) { + addEventQueueAssetCount -= 1; + } + if (addEventQueue.length) { + addEventQueue.push(event); + // TODO: support appearance of a second FullSnapshot before first one's assets load + if (addEventQueueAssetCount <= 0) { + clearTimeout(addEventQueueTimeout); + this.flushAddEventQueue(); + } + } else if ( + event.type === EventType.FullSnapshot && + event.data.assetCount + ) { + addEventQueue.push(event); + addEventQueueAssetCount = event.data.assetCount; + addEventQueueTimeout = setTimeout( + flushAddEventQueue, + event.data.liveBuffer, + ); + } else { + castOrScheduleEvent(event); } } return { ...ctx, events }; diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 940b3dfd7d..de5fd0e84b 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -238,6 +238,7 @@ export type ErrorHandler = (error: unknown) => void | boolean; export type assetStatus = { status: 'capturing' | 'captured' | 'error' | 'refused'; + timeout?: number; }; export interface ProcessingStyleElement extends HTMLStyleElement { diff --git a/packages/rrweb/test/events/assets.ts b/packages/rrweb/test/events/assets.ts index 6e486dba36..9ebce3eff6 100644 --- a/packages/rrweb/test/events/assets.ts +++ b/packages/rrweb/test/events/assets.ts @@ -119,6 +119,8 @@ const events: eventWithTime[] = [ id: 1, }, initialOffset: { left: 0, top: 0 }, + liveBuffer: 50, + liveBufferAssetCount: 3, }, timestamp: 1636379531389, }, diff --git a/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/asset-integration-test-ts-test-replay-asset-integration-test-ts-replayer-asset-should-wait-for-stylesheet-assets-to-avoid-fouc-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..848d60f0914aac2c67096b85a81fae3d067857cc GIT binary patch literal 10747 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}1B$%3e9#$4F%}28J29*~C-ahlL4m>3 z#WAE}&YRmC1CImI#9K@!1e8wnf^qxHgQy)arYjMfXI^#UXf49~o9|Lxs_oXmy-EC;O%OlH@|aXIoG zWGgf?3C*{Cc3+2y*wIZQ2SG=bfzuMngU6%cHX3gDrx!+3$Y=@~O(EbkFj{AUgMneR zmH~$Yw#nGh(sBfsmY=$=Yafql-_#!7w_p0x2Cv h3y0Cdf%Lrak?rRxnKg@THY { + // fouc = flash of unstyled content + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer([], { + liveMode: true, + }); + replayer.startLive(); + window.replayer.addEvent(events[0]); + window.replayer.addEvent(events[1]); + window.replayer.addEvent(events[2]); + `); + + await waitForRAF(page); + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); // should be blank white and not have image rendered yet + }); + it('should support urls src modified via incremental mutation', async () => { await page.evaluate(` const { Replayer } = rrweb; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3920343dd9..3f46edeaeb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -27,6 +27,17 @@ export type fullSnapshotEvent = { top: number; left: number; }; + /* + * in milliseconds, how long we should delay rebuild + * wait in a live context in order that assets can be transmitted + */ + liveBuffer?: number; + /* + * the number of assets associated with this snapshot + * useful for processing streams of events without having + * to rebuild this event to count them up + */ + assetCount?: number; }; };