diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 692da2d281..662914e1d9 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -325,7 +325,14 @@ function buildNode( 'rrweb-original-srcset', n.attributes.srcset as string, ); - } else { + } + // Set the sandbox attribute on the iframe element will make it lose its contentDocument access and therefore cause additional playback errors. + else if ( + (tagName === 'iframe' || tagName === 'frame') && + name === 'sandbox' + ) + continue; + else { node.setAttribute(name, value.toString()); } } catch (error) { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 8c29fa0d7f..38a5345e95 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -767,7 +767,10 @@ function serializeElementNode( }; } // iframe - if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { + if ( + (tagName === 'iframe' || tagName === 'frame') && + !keepIframeSrcFn(attributes.src as string) + ) { if (!(n as HTMLIFrameElement).contentDocument) { // we can't record it directly as we can't see into it // preserve the src attribute so a decision can be taken at replay time @@ -1111,7 +1114,7 @@ export function serializeNodeWithId( if ( serializedNode.type === NodeType.Element && - serializedNode.tagName === 'iframe' + (serializedNode.tagName === 'iframe' || serializedNode.tagName === 'frame') ) { onceIframeLoaded( n as HTMLIFrameElement, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 08e927a98f..04d87e575d 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -602,7 +602,7 @@ export default class MutationBuffer { let item = this.attributeMap.get(m.target); if ( - target.tagName === 'IFRAME' && + (target.tagName === 'IFRAME' || target.tagName === 'FRAME') && attributeName === 'src' && !this.keepIframeSrcFn(value as string) ) { diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 5f1101cc23..4eefd6cf6f 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -134,7 +134,7 @@ export class Replayer { private mouseTail: HTMLCanvasElement | null = null; private tailPositions: Array<{ x: number; y: number }> = []; - private emitter: Emitter = mitt(); + private emitter: Emitter = mitt() as Emitter; private nextUserInteractionEvent: eventWithTime | null; @@ -331,6 +331,8 @@ export class Replayer { this.applySelection(this.lastSelectionData); this.lastSelectionData = null; } + + this.emitter.emit(ReplayerEvents.FlushEnd); }); this.emitter.on(ReplayerEvents.PlayBack, () => { this.firstFullSnapshot = null; @@ -525,6 +527,35 @@ export class Replayer { this.emitter.emit(ReplayerEvents.Start); } + /** + * Applies all events synchronously until the given event index. + * @param eventIndex - number + */ + public replayEvent(eventIndex: number) { + const handleFinish = () => { + this.service.send('END'); + this.emitter.off(ReplayerEvents.FlushEnd, handleFinish); + }; + this.emitter.on(ReplayerEvents.FlushEnd, handleFinish); + + if (this.service.state.matches('paused')) { + this.service.send({ + type: 'PLAY_SINGLE_EVENT', + payload: { singleEvent: eventIndex }, + }); + } else { + this.service.send({ type: 'PAUSE' }); + this.service.send({ + type: 'PLAY_SINGLE_EVENT', + payload: { singleEvent: eventIndex }, + }); + } + this.iframe.contentDocument + ?.getElementsByTagName('html')[0] + ?.classList.remove('rrweb-paused'); + this.emitter.emit(ReplayerEvents.Start); + } + public pause(timeOffset?: number) { if (timeOffset === undefined && this.service.state.matches('playing')) { this.service.send({ type: 'PAUSE' }); @@ -558,6 +589,7 @@ export class Replayer { this.mediaManager.reset(); this.config.root.removeChild(this.wrapper); this.emitter.emit(ReplayerEvents.Destroy); + this.emitter.all.clear(); } public startLive(baselineTime?: number) { @@ -1111,14 +1143,15 @@ export class Replayer { e: incrementalSnapshotEvent & { timestamp: number; delay?: number }, isSync: boolean, ) { - const { data: d } = e; + const { data: d, timestamp } = e; + switch (d.source) { case IncrementalSource.Mutation: { try { this.applyMutation(d, isSync); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions - this.warn(`Exception in mutation ${error.message || error}`, d); + this.warn(`Exception in mutation ${String(error)}`, d, timestamp); } break; } @@ -1509,6 +1542,19 @@ export class Replayer { return queue.push(mutation); } + if ( + mutation.node.type === NodeType.Document && + parent?.nodeName?.toLowerCase() !== 'iframe' && + parent?.nodeName?.toLowerCase() !== 'frame' + ) { + console.warn( + '[Replayer] Skipping invalid document append to a non-iframe parent. hi2', + mutation, + parent, + ); + return; + } + if (mutation.node.isShadow) { // If the parent is attached a shadow dom after it's created, it won't have a shadow root. if (!hasShadowRoot(parent)) { diff --git a/packages/rrweb/src/replay/machine.ts b/packages/rrweb/src/replay/machine.ts index 08b72c9543..74f1e668c7 100644 --- a/packages/rrweb/src/replay/machine.ts +++ b/packages/rrweb/src/replay/machine.ts @@ -28,6 +28,12 @@ export type PlayerEvent = timeOffset: number; }; } + | { + type: 'PLAY_SINGLE_EVENT'; + payload: { + singleEvent: number; + }; + } | { type: 'CAST_EVENT'; payload: { @@ -78,6 +84,30 @@ export function discardPriorSnapshots( return events; } +function discardPriorSnapshotsToEvent( + events: eventWithTime[], + targetIndex: number, +) { + const targetEvent = events[targetIndex]; + + if (!targetEvent) { + return []; + } + + for (let idx = targetIndex; idx >= 0; idx--) { + const event = events[idx]; + + if (!event) { + continue; + } + + if (event.type === EventType.Meta) { + return events.slice(idx, targetIndex + 1); + } + } + return events; +} + type PlayerAssets = { emitter: Emitter; applyEventsSynchronously(events: Array): void; @@ -119,6 +149,10 @@ export function createPlayerService( target: 'playing', actions: ['recordTimeOffset', 'play'], }, + PLAY_SINGLE_EVENT: { + target: 'playing', + actions: ['playSingleEvent'], + }, CAST_EVENT: { target: 'paused', actions: 'castEvent', @@ -168,6 +202,23 @@ export function createPlayerService( baselineTime: ctx.events[0].timestamp + timeOffset, }; }), + + playSingleEvent(ctx, event) { + if (event.type !== 'PLAY_SINGLE_EVENT') { + return; + } + + const { singleEvent } = event.payload; + + const neededEvents = discardPriorSnapshotsToEvent( + ctx.events, + singleEvent, + ); + + applyEventsSynchronously(neededEvents); + emitter.emit(ReplayerEvents.Flush); + }, + play(ctx) { const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 13b15ec6c1..839953b41c 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -364,7 +364,9 @@ export function isSerializedIframe( n: TNode, mirror: IMirror, ): boolean { - return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n)); + return Boolean( + (n.nodeName === 'IFRAME' || n.nodeName === 'FRAME') && mirror.getMeta(n), + ); } export function isSerializedStylesheet( diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bba276e483..bfe25a3f9e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -651,6 +651,7 @@ export type Emitter = { on(type: string, handler: Handler): void; emit(type: string, event?: unknown): void; off(type: string, handler: Handler): void; + all: Map; }; export type Arguments = T extends (...payload: infer U) => unknown @@ -675,6 +676,7 @@ export enum ReplayerEvents { EventCast = 'event-cast', CustomEvent = 'custom-event', Flush = 'flush', + FlushEnd = 'flush-end', StateChange = 'state-change', PlayBack = 'play-back', Destroy = 'destroy',