diff --git a/src/rebuild.ts b/src/rebuild.ts index b09d423..8091be3 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -198,13 +198,20 @@ export function buildNodeWithSN( map: idNodeMap; skipChild?: boolean; hackCss: boolean; + afterAppend?: (n: INode) => unknown; }, ): INode | null { - const { doc, map, skipChild = false, hackCss = true } = options; + const { doc, map, skipChild = false, hackCss = true, afterAppend } = options; let node = buildNode(n, { doc, hackCss }); if (!node) { return null; } + if (n.rootId) { + console.assert( + ((map[n.rootId] as unknown) as Document) === doc, + 'Target document should has the same root id.', + ); + } // use target document as root document if (n.type === NodeType.Document) { // close before open to make sure document was closed @@ -215,6 +222,7 @@ export function buildNodeWithSN( (node as INode).__sn = n; map[n.id] = node as INode; + if ( (n.type === NodeType.Document || n.type === NodeType.Element) && !skipChild @@ -225,14 +233,20 @@ export function buildNodeWithSN( map, skipChild: false, hackCss, + afterAppend, }); if (!childNode) { console.warn('Failed to rebuild', childN); - } else { - node.appendChild(childNode); + continue; + } + + node.appendChild(childNode); + if (afterAppend) { + afterAppend(childNode); } } } + return node as INode; } @@ -274,15 +288,17 @@ function rebuild( doc: Document; onVisit?: (node: INode) => unknown; hackCss?: boolean; + afterAppend?: (n: INode) => unknown; }, ): [Node | null, idNodeMap] { - const { doc, onVisit, hackCss = true } = options; + const { doc, onVisit, hackCss = true, afterAppend } = options; const idNodeMap: idNodeMap = {}; const node = buildNodeWithSN(n, { doc, map: idNodeMap, skipChild: false, hackCss, + afterAppend, }); visit(idNodeMap, (visitedNode) => { if (onVisit) { diff --git a/src/snapshot.ts b/src/snapshot.ts index fcfb9f5..9ddc39d 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -196,6 +196,34 @@ export function _isBlockedElement( return false; } +// https://stackoverflow.com/a/36155560 +function onceIframeLoaded( + iframeEl: HTMLIFrameElement, + listener: () => unknown, +) { + const win = iframeEl.contentWindow; + if (!win) { + return; + } + // document is loading + if (win.document.readyState !== 'complete') { + iframeEl.addEventListener('load', listener); + return; + } + // check blank frame for Chrome + const blankUrl = 'about:blank'; + if ( + win.location.href !== blankUrl || + iframeEl.src === blankUrl || + iframeEl.src === '' + ) { + listener(); + return; + } + // use default listener + iframeEl.addEventListener('load', listener); +} + function serializeNode( n: Node, options: { @@ -215,11 +243,18 @@ function serializeNode( maskInputOptions = {}, recordCanvas, } = options; + // Only record root id when document object is not the base document + let rootId: number | undefined; + if (((doc as unknown) as INode).__sn) { + const docId = ((doc as unknown) as INode).__sn.id; + rootId = docId === 1 ? undefined : docId; + } switch (n.nodeType) { case n.DOCUMENT_NODE: return { type: NodeType.Document, childNodes: [], + rootId, }; case n.DOCUMENT_TYPE_NODE: return { @@ -227,6 +262,7 @@ function serializeNode( name: (n as DocumentType).name, publicId: (n as DocumentType).publicId, systemId: (n as DocumentType).systemId, + rootId, }; case n.ELEMENT_NODE: const needBlock = _isBlockedElement( @@ -318,6 +354,7 @@ function serializeNode( if ((n as HTMLElement).scrollTop) { attributes.rr_scrollTop = (n as HTMLElement).scrollTop; } + // block element if (needBlock) { const { width, height } = (n as HTMLElement).getBoundingClientRect(); attributes = { @@ -326,6 +363,10 @@ function serializeNode( rr_height: `${height}px`, }; } + // iframe + if (tagName === 'iframe') { + delete attributes.src; + } return { type: NodeType.Element, tagName, @@ -333,6 +374,7 @@ function serializeNode( childNodes: [], isSVG: isSVGElement(n as Element) || undefined, needBlock, + rootId, }; case n.TEXT_NODE: // The parent node may not be a html element which has a tagName attribute. @@ -351,16 +393,19 @@ function serializeNode( type: NodeType.Text, textContent: textContent || '', isStyle, + rootId, }; case n.CDATA_SECTION_NODE: return { type: NodeType.CDATA, textContent: '', + rootId, }; case n.COMMENT_NODE: return { type: NodeType.Comment, textContent: (n as Comment).textContent || '', + rootId, }; default: return false; @@ -472,6 +517,8 @@ export function serializeNodeWithId( slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; }, ): serializedNodeWithId | null { const { @@ -484,6 +531,8 @@ export function serializeNodeWithId( maskInputOptions = {}, slimDOMOptions, recordCanvas = false, + onSerialize, + onIframeLoad, } = options; let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { @@ -521,6 +570,9 @@ export function serializeNodeWithId( return null; // slimDOM } map[id] = n as INode; + if (onSerialize) { + onSerialize(n as INode); + } let recordChild = !skipChild; if (serializedNode.type === NodeType.Element) { recordChild = recordChild && !serializedNode.needBlock; @@ -552,12 +604,44 @@ export function serializeNodeWithId( slimDOMOptions, recordCanvas, preserveWhiteSpace, + onSerialize, + onIframeLoad, }); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } } + + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' + ) { + onceIframeLoaded(n as HTMLIFrameElement, () => { + const iframeDoc = (n as HTMLIFrameElement).contentDocument; + if (iframeDoc && onIframeLoad) { + const serializedIframeNode = serializeNodeWithId(iframeDoc, { + doc: iframeDoc, + map, + blockClass, + blockSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + slimDOMOptions, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + }); + + if (serializedIframeNode) { + onIframeLoad(n as INode, serializedIframeNode); + } + } + }); + } + return serializedNode; } @@ -570,6 +654,9 @@ function snapshot( slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; blockSelector?: string | null; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; }, ): [serializedNodeWithId | null, idNodeMap] { const { @@ -579,6 +666,9 @@ function snapshot( blockSelector = null, maskAllInputs = false, slimDOM = false, + preserveWhiteSpace, + onSerialize, + onIframeLoad, } = options || {}; const idNodeMap: idNodeMap = {}; const maskInputOptions: MaskInputOptions = @@ -632,6 +722,9 @@ function snapshot( maskInputOptions, slimDOMOptions, recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, }), idNodeMap, ]; diff --git a/src/types.ts b/src/types.ts index 9a53c01..3ae2d76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,13 +47,16 @@ export type commentNode = { textContent: string; }; -export type serializedNode = +export type serializedNode = ( | documentNode | documentTypeNode | elementNode | textNode | cdataNode - | commentNode; + | commentNode +) & { + rootId?: number; +}; export type serializedNodeWithId = serializedNode & { id: number }; diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index d7d76c6..75734c1 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -277,3 +277,135 @@ exports[`[html file]: with-style-sheet-with-import.html 1`] = ` " `; + +exports[`iframe integration tests 1`] = ` +"{ + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Main\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"one\\" + }, + \\"childNodes\\": [], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"two\\" + }, + \\"childNodes\\": [], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"id\\": 19 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 +}" +`; diff --git a/test/iframe-html/frame1.html b/test/iframe-html/frame1.html new file mode 100644 index 0000000..8810af4 --- /dev/null +++ b/test/iframe-html/frame1.html @@ -0,0 +1,13 @@ + + + + + + Frame 1 + + + frame 1 + + + + diff --git a/test/iframe-html/frame2.html b/test/iframe-html/frame2.html new file mode 100644 index 0000000..3432408 --- /dev/null +++ b/test/iframe-html/frame2.html @@ -0,0 +1,11 @@ + + + + + + Frame 2 + + + frame 2 + + diff --git a/test/iframe-html/main.html b/test/iframe-html/main.html new file mode 100644 index 0000000..d8e712b --- /dev/null +++ b/test/iframe-html/main.html @@ -0,0 +1,12 @@ + + + + + + Main + + + + + + diff --git a/test/integration.ts b/test/integration.ts index ddfb5bc..807639f 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -119,3 +119,50 @@ describe('integration tests', function (this: ISuite) { }).timeout(5000); } }); + +describe('iframe integration tests', function (this: ISuite) { + const iframeHtml = path.join(__dirname, 'iframe-html/main.html'); + const raw = fs.readFileSync(iframeHtml, 'utf-8'); + + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + // headless: false, + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(async () => { + await this.browser.close(); + await this.server.close(); + }); + + it('snapshot async iframes', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(raw, { + waitUntil: 'load', + }); + const snapshotResult = JSON.stringify( + await page.evaluate(`${this.code}; + rrweb.snapshot(document)[0]; + `), + null, + 2, + ); + const result = matchSnapshot(snapshotResult, __filename, this.title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); +}); diff --git a/typings/rebuild.d.ts b/typings/rebuild.d.ts index 35eb602..6ab9f04 100644 --- a/typings/rebuild.d.ts +++ b/typings/rebuild.d.ts @@ -5,10 +5,12 @@ export declare function buildNodeWithSN(n: serializedNodeWithId, options: { map: idNodeMap; skipChild?: boolean; hackCss: boolean; + afterAppend?: (n: INode) => unknown; }): INode | null; declare function rebuild(n: serializedNodeWithId, options: { doc: Document; onVisit?: (node: INode) => unknown; hackCss?: boolean; + afterAppend?: (n: INode) => unknown; }): [Node | null, idNodeMap]; export default rebuild; diff --git a/typings/snapshot.d.ts b/typings/snapshot.d.ts index 8ae27a3..053e3a1 100644 --- a/typings/snapshot.d.ts +++ b/typings/snapshot.d.ts @@ -15,6 +15,8 @@ export declare function serializeNodeWithId(n: Node | INode, options: { slimDOMOptions: SlimDOMOptions; recordCanvas?: boolean; preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { blockClass?: string | RegExp; @@ -23,6 +25,9 @@ declare function snapshot(n: Document, options?: { slimDOM?: boolean | SlimDOMOptions; recordCanvas?: boolean; blockSelector?: string | null; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; }): [serializedNodeWithId | null, idNodeMap]; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; export declare function cleanupSnapshot(): void; diff --git a/typings/types.d.ts b/typings/types.d.ts index 256abba..8efab81 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -40,7 +40,9 @@ export declare type commentNode = { type: NodeType.Comment; textContent: string; }; -export declare type serializedNode = documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode; +export declare type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & { + rootId?: number; +}; export declare type serializedNodeWithId = serializedNode & { id: number; };