Support top-layer <dialog> recording & replay (#1503)

* chore: its important to run `yarn build:all` before running `yarn dev`

* feat: trigger showModal from rrdom and rrweb

* feat: Add support for replaying modal and non modal dialog elements

* chore: Update dev script to remove CLEAR_DIST_DIR flag

* Get modal recording and replay working

* DRY up dialog test and dedupe snapshot images

* feat: Refactor dialog test to use updated attribute name

* feat: Update dialog test to include rr_open attribute

* chore: Add npm dependency happy-dom@14.12.0

* Add more test cases for dialog

* Clean up naming

* Refactor dialog open code

* Revert changed code that doesn't do anything

* Add documentation for unimplemented type

* chore: Remove unnecessary comments in dialog.test.ts

* rename rr_open to rr_openMode

* Replace todo with a skipped test

* Add better logging for CI

* Rename rr_openMode to rr_open_mode

rrdom downcases all attribute names which made `rr_openMode` tricky to deal with

* Remove unused images

* Move after iframe append based on @YunFeng0817's comment
https://github.com/rrweb-io/rrweb/pull/1503#discussion_r1666363931

* Remove redundant dialog handling from rrdom.

rrdom already handles dialog element creation it's self

* Rename variables for dialog handling in rrweb replay module

* Update packages/rrdom/src/document.ts

---------

Co-authored-by: Eoghan Murray <eoghan@getthere.ie>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c110e1c21d
commit 5217a09c60
38 changed files with 1902 additions and 75 deletions

View File

@@ -0,0 +1,5 @@
---
"rrweb-snapshot": minor
---
Record dialog's modal status for replay in rrweb. (Currently triggering `dialog.showModal()` is not supported in rrweb-snapshot's rebuild)

View File

@@ -0,0 +1,7 @@
---
"rrdom": minor
"rrweb": minor
"@rrweb/types": minor
---
Support top-layer <dialog> components. Fixes #1381.

View File

@@ -38,5 +38,5 @@ jobs:
if: failure() if: failure()
with: with:
name: image-diff name: image-diff
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png path: packages/**/__image_snapshots__/__diff_output__/*.png
if-no-files-found: ignore if-no-files-found: ignore

View File

@@ -62,7 +62,7 @@ Since we want the record and replay sides to share a strongly typed data structu
1. Fork this repository. 1. Fork this repository.
2. Run `yarn install` in the root to install required dependencies for all sub-packages (note: `npm install` is _not_ recommended). 2. Run `yarn install` in the root to install required dependencies for all sub-packages (note: `npm install` is _not_ recommended).
3. Run `yarn dev` in the root to get auto-building for all the sub-packages whenever you modify anything. 3. Run `yarn build:all` to build all packages and get a stable base, then `yarn dev` in the root to get auto-building for all the sub-packages whenever you modify anything.
4. Navigate to one of the sub-packages (in the `packages` folder) where you'd like to make a change. 4. Navigate to one of the sub-packages (in the `packages` folder) where you'd like to make a change.
5. Patch the code and run `yarn test` to run the tests, make sure they pass before you commit anything. Add test cases in order to avoid future regression. 5. Patch the code and run `yarn test` to run the tests, make sure they pass before you commit anything. Add test cases in order to avoid future regression.
6. If tests are failing, but the change in output is desirable, run `yarn test:update` and carefully commit the changes in test output. 6. If tests are failing, but the change in output is desirable, run `yarn test:update` and carefully commit the changes in test output.

View File

@@ -46,6 +46,7 @@
"@typescript-eslint/eslint-plugin": "^5.23.0", "@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0", "@typescript-eslint/parser": "^5.23.0",
"eslint": "^8.15.0", "eslint": "^8.15.0",
"happy-dom": "^14.12.0",
"puppeteer": "^17.1.3", "puppeteer": "^17.1.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.3.1", "vite": "^5.3.1",

View File

@@ -21,6 +21,7 @@ import type {
} from './document'; } from './document';
import type { import type {
RRCanvasElement, RRCanvasElement,
RRDialogElement,
RRElement, RRElement,
RRIFrameElement, RRIFrameElement,
RRMediaElement, RRMediaElement,
@@ -285,6 +286,29 @@ function diffAfterUpdatingChildren(
); );
break; break;
} }
case 'DIALOG': {
const dialog = oldElement as HTMLDialogElement;
const rrDialog = newRRElement as unknown as RRDialogElement;
const wasOpen = dialog.open;
const wasModal = dialog.matches('dialog:modal');
const shouldBeOpen = rrDialog.open;
const shouldBeModal = rrDialog.isModal;
const modalChanged = wasModal !== shouldBeModal;
const openChanged = wasOpen !== shouldBeOpen;
if (modalChanged || (wasOpen && openChanged)) dialog.close();
if (shouldBeOpen && (openChanged || modalChanged)) {
try {
if (shouldBeModal) dialog.showModal();
else dialog.show();
} catch (e) {
console.warn(e);
}
}
break;
}
} }
break; break;
} }
@@ -335,7 +359,6 @@ function diffProps(
for (const { name } of Array.from(oldAttributes)) for (const { name } of Array.from(oldAttributes))
if (!(name in newAttributes)) oldTree.removeAttribute(name); if (!(name in newAttributes)) oldTree.removeAttribute(name);
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft); newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop); newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
} }

View File

@@ -474,7 +474,8 @@ export class BaseRRElement extends BaseRRNode implements IRRElement {
} }
public getAttribute(name: string): string | null { public getAttribute(name: string): string | null {
return this.attributes[name] || null; if (this.attributes[name] === undefined) return null;
return this.attributes[name];
} }
public setAttribute(name: string, attribute: string) { public setAttribute(name: string, attribute: string) {
@@ -547,6 +548,30 @@ export class BaseRRMediaElement extends BaseRRElement {
} }
} }
export class BaseRRDialogElement extends BaseRRElement {
public readonly tagName = 'DIALOG' as const;
public readonly nodeName = 'DIALOG' as const;
get isModal() {
return this.getAttribute('rr_open_mode') === 'modal';
}
get open() {
return this.getAttribute('open') !== null;
}
public close() {
this.removeAttribute('open');
this.removeAttribute('rr_open_mode');
}
public show() {
this.setAttribute('open', '');
this.setAttribute('rr_open_mode', 'non-modal');
}
public showModal() {
this.setAttribute('open', '');
this.setAttribute('rr_open_mode', 'modal');
}
}
export class BaseRRText extends BaseRRNode implements IRRText { export class BaseRRText extends BaseRRNode implements IRRText {
public readonly nodeType: number = NodeType.TEXT_NODE; public readonly nodeType: number = NodeType.TEXT_NODE;
public readonly nodeName = '#text' as const; public readonly nodeName = '#text' as const;

View File

@@ -31,6 +31,7 @@ import {
type IRRDocumentType, type IRRDocumentType,
type IRRText, type IRRText,
type IRRComment, type IRRComment,
BaseRRDialogElement,
} from './document'; } from './document';
export class RRDocument extends BaseRRDocument { export class RRDocument extends BaseRRDocument {
@@ -104,6 +105,9 @@ export class RRDocument extends BaseRRDocument {
case 'STYLE': case 'STYLE':
element = new RRStyleElement(upperTagName); element = new RRStyleElement(upperTagName);
break; break;
case 'DIALOG':
element = new RRDialogElement(upperTagName);
break;
default: default:
element = new RRElement(upperTagName); element = new RRElement(upperTagName);
break; break;
@@ -151,6 +155,8 @@ export class RRElement extends BaseRRElement {
export class RRMediaElement extends BaseRRMediaElement {} export class RRMediaElement extends BaseRRMediaElement {}
export class RRDialogElement extends BaseRRDialogElement {}
export class RRCanvasElement extends RRElement implements IRRElement { export class RRCanvasElement extends RRElement implements IRRElement {
public rr_dataURL: string | null = null; public rr_dataURL: string | null = null;
public canvasMutations: { public canvasMutations: {

View File

@@ -0,0 +1,112 @@
/**
* @vitest-environment happy-dom
*/
import { vi, MockInstance } from 'vitest';
import {
NodeType as RRNodeType,
createMirror,
Mirror as NodeMirror,
serializedNodeWithId,
} from 'rrweb-snapshot';
import { RRDocument } from '../../src';
import { diff, ReplayerHandler } from '../../src/diff';
describe('diff algorithm for rrdom', () => {
let mirror: NodeMirror;
let replayer: ReplayerHandler;
let warn: MockInstance;
let elementSn: serializedNodeWithId;
let elementSn2: serializedNodeWithId;
beforeEach(() => {
mirror = createMirror();
replayer = {
mirror,
applyCanvas: () => {},
applyInput: () => {},
applyScroll: () => {},
applyStyleSheetMutation: () => {},
afterAppend: () => {},
};
document.write('<!DOCTYPE html><html><head></head><body></body></html>');
// Mock the original console.warn function to make the test fail once console.warn is called.
warn = vi.spyOn(console, 'warn');
elementSn = {
type: RRNodeType.Element,
tagName: 'DIALOG',
attributes: {},
childNodes: [],
id: 1,
};
elementSn2 = {
...elementSn,
attributes: {},
};
});
afterEach(() => {
// Check that warn was not called (fail on warning)
expect(warn).not.toBeCalled();
vi.resetAllMocks();
});
describe('diff dialog elements', () => {
vi.setConfig({ testTimeout: 60_000 });
it('should trigger `showModal` on rr_open_mode:modal attributes', () => {
const tagName = 'DIALOG';
const node = document.createElement(tagName) as HTMLDialogElement;
vi.spyOn(node, 'matches').mockReturnValue(false); // matches is used to check if the dialog was opened with showModal
const showModalFn = vi.spyOn(node, 'showModal');
const rrDocument = new RRDocument();
const rrNode = rrDocument.createElement(tagName);
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
mirror.add(node, elementSn);
rrDocument.mirror.add(rrNode, elementSn);
diff(node, rrNode, replayer);
expect(showModalFn).toBeCalled();
});
it('should trigger `close` on rr_open_mode removed', () => {
const tagName = 'DIALOG';
const node = document.createElement(tagName) as HTMLDialogElement;
node.showModal();
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
const closeFn = vi.spyOn(node, 'close');
const rrDocument = new RRDocument();
const rrNode = rrDocument.createElement(tagName);
rrNode.attributes = {};
mirror.add(node, elementSn);
rrDocument.mirror.add(rrNode, elementSn);
diff(node, rrNode, replayer);
expect(closeFn).toBeCalled();
});
it('should not trigger `close` on rr_open_mode is kept', () => {
const tagName = 'DIALOG';
const node = document.createElement(tagName) as HTMLDialogElement;
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
node.setAttribute('rr_open_mode', 'modal');
node.setAttribute('open', '');
const closeFn = vi.spyOn(node, 'close');
const rrDocument = new RRDocument();
const rrNode = rrDocument.createElement(tagName);
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
mirror.add(node, elementSn);
rrDocument.mirror.add(rrNode, elementSn);
diff(node, rrNode, replayer);
expect(closeFn).not.toBeCalled();
expect(node.open).toBe(true);
});
});
});

View File

@@ -287,6 +287,11 @@ function buildNode(
(node as HTMLMediaElement).loop = value; (node as HTMLMediaElement).loop = value;
} else if (name === 'rr_mediaVolume' && typeof value === 'number') { } else if (name === 'rr_mediaVolume' && typeof value === 'number') {
(node as HTMLMediaElement).volume = value; (node as HTMLMediaElement).volume = value;
} else if (name === 'rr_open_mode') {
(node as HTMLDialogElement).setAttribute(
'rr_open_mode',
value as string,
); // keep this attribute for rrweb to trigger showModal
} }
} }

View File

@@ -6,6 +6,7 @@ import {
type MaskInputOptions, type MaskInputOptions,
type SlimDOMOptions, type SlimDOMOptions,
type DataURLOptions, type DataURLOptions,
type DialogAttributes,
type MaskTextFn, type MaskTextFn,
type MaskInputFn, type MaskInputFn,
type KeepIframeSrcFn, type KeepIframeSrcFn,
@@ -652,6 +653,16 @@ function serializeElementNode(
delete attributes.selected; delete attributes.selected;
} }
} }
if (tagName === 'dialog' && (n as HTMLDialogElement).open) {
// register what type of dialog is this
// `modal` or `non-modal`
// this is used to trigger `showModal()` or `show()` on replay (outside of rrweb-snapshot, in rrweb)
(attributes as DialogAttributes).rr_open_mode = n.matches('dialog:modal')
? 'modal'
: 'non-modal';
}
// canvas image data // canvas image data
if (tagName === 'canvas' && recordCanvas) { if (tagName === 'canvas' && recordCanvas) {
if ((n as ICanvas).__context === '2d') { if ((n as ICanvas).__context === '2d') {

View File

@@ -103,6 +103,23 @@ export type mediaAttributes = {
rr_mediaVolume?: number; rr_mediaVolume?: number;
}; };
export type DialogAttributes = {
open: string;
/**
* Represents the dialog's open mode.
* `modal` means the dialog is opened with `showModal()`.
* `non-modal` means the dialog is opened with `show()` or
* by adding an `open` attribute.
*/
rr_open_mode: 'modal' | 'non-modal';
/**
* Currently unimplemented, but in future can be used to:
* Represents the order of which of the dialog was opened.
* This is useful for replaying the dialog `.showModal()` in the correct order.
*/
// rr_open_mode_index?: number;
};
// @deprecated // @deprecated
export interface INode extends Node { export interface INode extends Node {
__sn: serializedNodeWithId; __sn: serializedNodeWithId;

View File

@@ -1,5 +1,123 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`dialog integration tests > should capture open attribute for modal dialogs 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
}"
`;
exports[`dialog integration tests > should capture open attribute for non modal dialogs 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
}"
`;
exports[`iframe integration tests > snapshot async iframes 1`] = ` exports[`iframe integration tests > snapshot async iframes 1`] = `
"{ "{
\\"type\\": 0, \\"type\\": 0,
@@ -214,6 +332,12 @@ exports[`integration tests > [html file]: cors-style-sheet.html 1`] = `
<body></body></html>" <body></body></html>"
`; `;
exports[`integration tests > [html file]: dialog.html 1`] = `
"<html><head></head><body>
<dialog>I'm a dialog</dialog>
</body></html>"
`;
exports[`integration tests > [html file]: dynamic-stylesheet.html 1`] = ` exports[`integration tests > [html file]: dynamic-stylesheet.html 1`] = `
"<!DOCTYPE html><html lang=\\"en\\"><head> "<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" /> <meta charset=\\"UTF-8\\" />

View File

@@ -0,0 +1,5 @@
<html>
<body>
<dialog>I'm a dialog</dialog>
</body>
</html>

View File

@@ -1,10 +1,20 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http'; import * as http from 'http';
import * as url from 'url'; import * as path from 'path';
import * as puppeteer from 'puppeteer'; import * as puppeteer from 'puppeteer';
import { vi, assert, describe, it, beforeAll, afterAll, expect } from 'vitest'; import * as url from 'url';
import { waitForRAF, getServerURL } from './utils'; import {
afterAll,
assert,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { getServerURL, waitForRAF } from './utils';
const htmlFolder = path.join(__dirname, 'html'); const htmlFolder = path.join(__dirname, 'html');
const htmls = fs.readdirSync(htmlFolder).map((filePath) => { const htmls = fs.readdirSync(htmlFolder).map((filePath) => {
@@ -60,6 +70,15 @@ function sanitizeSnapshot(snapshot: string): string {
return snapshot.replace(/localhost:[0-9]+/g, 'localhost:3030'); return snapshot.replace(/localhost:[0-9]+/g, 'localhost:3030');
} }
async function snapshot(page: puppeteer.Page, code: string): Promise<string> {
await waitForRAF(page);
const result = (await page.evaluate(`${code}
const snapshot = rrwebSnapshot.snapshot(document);
JSON.stringify(snapshot, null, 2);
`)) as string;
return result;
}
function assertSnapshot(snapshot: string): void { function assertSnapshot(snapshot: string): void {
expect(sanitizeSnapshot(snapshot)).toMatchSnapshot(); expect(sanitizeSnapshot(snapshot)).toMatchSnapshot();
} }
@@ -68,6 +87,7 @@ interface ISuite {
server: http.Server; server: http.Server;
serverURL: string; serverURL: string;
browser: puppeteer.Browser; browser: puppeteer.Browser;
page: puppeteer.Page;
code: string; code: string;
} }
@@ -431,6 +451,53 @@ describe('iframe integration tests', function (this: ISuite) {
}); });
}); });
describe('dialog integration tests', function (this: ISuite) {
vi.setConfig({ testTimeout: 30_000 });
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
let browser: ISuite['browser'];
let code: ISuite['code'];
let page: ISuite['page'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await puppeteer.launch({
// headless: false,
});
code = fs.readFileSync(
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
'utf-8',
);
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`${serverURL}/html/dialog.html`, {
waitUntil: 'load',
});
});
afterAll(async () => {
await browser.close();
await server.close();
});
it('should capture open attribute for non modal dialogs', async () => {
page.evaluate('document.querySelector("dialog").show()');
const snapshotResult = await snapshot(page, code);
assertSnapshot(snapshotResult);
});
it('should capture open attribute for modal dialogs', async () => {
await page.evaluate('document.querySelector("dialog").showModal()');
const snapshotResult = await snapshot(page, code);
assertSnapshot(snapshotResult);
});
});
describe('shadow DOM integration tests', function (this: ISuite) { describe('shadow DOM integration tests', function (this: ISuite) {
vi.setConfig({ testTimeout: 30_000 }); vi.setConfig({ testTimeout: 30_000 });
let server: ISuite['server']; let server: ISuite['server'];

View File

@@ -3,7 +3,7 @@
*/ */
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { describe, it, beforeEach, expect as _expect } from 'vitest'; import { beforeEach, describe, expect as _expect, it } from 'vitest';
import { import {
adaptCssForReplay, adaptCssForReplay,
buildNodeWithSN, buildNodeWithSN,

View File

@@ -2,12 +2,31 @@
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { serializeNodeWithId, _isBlockedElement } from '../src/snapshot';
import snapshot from '../src/snapshot'; import snapshot, {
import { serializedNodeWithId, elementNode } from '../src/types'; _isBlockedElement,
serializeNodeWithId,
} from '../src/snapshot';
import { elementNode, serializedNodeWithId } from '../src/types';
import { Mirror, absolutifyURLs } from '../src/utils'; import { Mirror, absolutifyURLs } from '../src/utils';
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
});
};
describe('absolute url to stylesheet', () => { describe('absolute url to stylesheet', () => {
const href = 'http://localhost/css/style.css'; const href = 'http://localhost/css/style.css';
@@ -135,22 +154,6 @@ describe('isBlockedElement()', () => {
}); });
describe('style elements', () => { describe('style elements', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
});
};
const render = (html: string): HTMLStyleElement => { const render = (html: string): HTMLStyleElement => {
document.write(html); document.write(html);
return document.querySelector('style')!; return document.querySelector('style')!;
@@ -180,23 +183,6 @@ describe('style elements', () => {
}); });
describe('scrollTop/scrollLeft', () => { describe('scrollTop/scrollLeft', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLDivElement => { const render = (html: string): HTMLDivElement => {
document.write(html); document.write(html);
return document.querySelector('div')!; return document.querySelector('div')!;
@@ -218,23 +204,6 @@ describe('scrollTop/scrollLeft', () => {
}); });
describe('form', () => { describe('form', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLTextAreaElement => { const render = (html: string): HTMLTextAreaElement => {
document.write(html); document.write(html);
return document.querySelector('textarea')!; return document.querySelector('textarea')!;

View File

@@ -663,6 +663,12 @@ export default class MutationBuffer {
item.styleDiff[pname] = false; // delete item.styleDiff[pname] = false; // delete
} }
} }
} else if (attributeName === 'open' && target.tagName === 'DIALOG') {
if (target.matches('dialog:modal')) {
item.attributes['rr_open_mode'] = 'modal';
} else {
item.attributes['rr_open_mode'] = 'non-modal';
}
} }
} }
break; break;

View File

@@ -0,0 +1,67 @@
import type { attributeMutation } from '@rrweb/types';
import { RRNode } from 'rrdom';
/**
* Checks if the dialog is a top level dialog and applies the dialog to the top level
* @param node - potential dialog element to apply top level `showModal()` to, or other node (which will be ignored)
* @param attributeMutation - the attribute mutation used to change the dialog (optional)
* @returns void
*/
export function applyDialogToTopLevel(
node: HTMLDialogElement | Node | RRNode,
attributeMutation?: attributeMutation,
): void {
if (node.nodeName !== 'DIALOG' || node instanceof RRNode) return;
const dialog = node as HTMLDialogElement;
const oldIsOpen = dialog.open;
const oldIsModalState = oldIsOpen && dialog.matches('dialog:modal');
const rrOpenMode = dialog.getAttribute('rr_open_mode');
const newIsOpen =
typeof attributeMutation?.attributes.open === 'string' ||
typeof dialog.getAttribute('open') === 'string';
const newIsModalState = rrOpenMode === 'modal';
const newIsNonModalState = rrOpenMode === 'non-modal';
const modalStateChanged =
(oldIsModalState && newIsNonModalState) ||
(!oldIsModalState && newIsModalState);
if (oldIsOpen && !modalStateChanged) return;
// complain if dialog is not attached to the dom
if (!dialog.isConnected) {
console.warn('dialog is not attached to the dom', dialog);
return;
}
if (oldIsOpen) dialog.close();
if (!newIsOpen) return;
if (newIsModalState) dialog.showModal();
else dialog.show();
}
/**
* Check if the dialog is a top level dialog and removes the dialog from the top level if necessary
* @param node - potential dialog element to remove from top level, or other node (which will be ignored)
* @param attributeMutation - the attribute mutation used to change the dialog
* @returns void
*/
export function removeDialogFromTopLevel(
node: HTMLDialogElement | Node | RRNode,
attributeMutation: attributeMutation,
): void {
if (node.nodeName !== 'DIALOG' || node instanceof RRNode) return;
const dialog = node as HTMLDialogElement;
// complain if dialog is not attached to the dom
if (!dialog.isConnected) {
console.warn('dialog is not attached to the dom', dialog);
return;
}
if (attributeMutation.attributes.open === null) {
dialog.removeAttribute('open');
dialog.removeAttribute('rr_open_mode');
}
}

View File

@@ -88,6 +88,7 @@ import './styles/style.css';
import canvasMutation from './canvas'; import canvasMutation from './canvas';
import { deserializeArg } from './canvas/deserialize-args'; import { deserializeArg } from './canvas/deserialize-args';
import { MediaManager } from './media'; import { MediaManager } from './media';
import { applyDialogToTopLevel, removeDialogFromTopLevel } from './dialog';
const SKIP_TIME_INTERVAL = 5 * 1000; const SKIP_TIME_INTERVAL = 5 * 1000;
@@ -803,9 +804,12 @@ export class Replayer {
); );
} }
this.legacy_missingNodeRetryMap = {}; this.legacy_missingNodeRetryMap = {};
const collected: AppendedIframe[] = []; const collectedIframes: AppendedIframe[] = [];
const collectedDialogs = new Set<HTMLDialogElement>();
const afterAppend = (builtNode: Node, id: number) => { const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode); if (builtNode.nodeName === 'DIALOG')
collectedDialogs.add(builtNode as HTMLDialogElement);
this.collectIframeAndAttachDocument(collectedIframes, builtNode);
if (this.mediaManager.isSupportedMediaElement(builtNode)) { if (this.mediaManager.isSupportedMediaElement(builtNode)) {
const { events } = this.service.state.context; const { events } = this.service.state.context;
this.mediaManager.addMediaElements( this.mediaManager.addMediaElements(
@@ -842,7 +846,7 @@ export class Replayer {
}); });
afterAppend(this.iframe.contentDocument, event.data.node.id); afterAppend(this.iframe.contentDocument, event.data.node.id);
for (const { mutationInQueue, builtNode } of collected) { for (const { mutationInQueue, builtNode } of collectedIframes) {
this.attachDocumentToIframe(mutationInQueue, builtNode); this.attachDocumentToIframe(mutationInQueue, builtNode);
this.newDocumentQueue = this.newDocumentQueue.filter( this.newDocumentQueue = this.newDocumentQueue.filter(
(m) => m !== mutationInQueue, (m) => m !== mutationInQueue,
@@ -850,6 +854,7 @@ export class Replayer {
} }
const { documentElement, head } = this.iframe.contentDocument; const { documentElement, head } = this.iframe.contentDocument;
this.insertStyleRules(documentElement, head); this.insertStyleRules(documentElement, head);
collectedDialogs.forEach((d) => applyDialogToTopLevel(d));
if (!this.service.state.matches('playing')) { if (!this.service.state.matches('playing')) {
this.iframe.contentDocument this.iframe.contentDocument
.getElementsByTagName('html')[0] .getElementsByTagName('html')[0]
@@ -912,9 +917,12 @@ export class Replayer {
type TNode = typeof mirror extends Mirror ? Node : RRNode; type TNode = typeof mirror extends Mirror ? Node : RRNode;
type TMirror = typeof mirror extends Mirror ? Mirror : RRDOMMirror; type TMirror = typeof mirror extends Mirror ? Mirror : RRDOMMirror;
const collected: AppendedIframe[] = []; const collectedIframes: AppendedIframe[] = [];
const collectedDialogs = new Set<HTMLDialogElement>();
const afterAppend = (builtNode: Node, id: number) => { const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode); if (builtNode.nodeName === 'DIALOG')
collectedDialogs.add(builtNode as HTMLDialogElement);
this.collectIframeAndAttachDocument(collectedIframes, builtNode);
const sn = (mirror as TMirror).getMeta(builtNode as unknown as TNode); const sn = (mirror as TMirror).getMeta(builtNode as unknown as TNode);
if ( if (
sn?.type === NodeType.Element && sn?.type === NodeType.Element &&
@@ -948,12 +956,14 @@ export class Replayer {
}); });
afterAppend(iframeEl.contentDocument! as Document, mutation.node.id); afterAppend(iframeEl.contentDocument! as Document, mutation.node.id);
for (const { mutationInQueue, builtNode } of collected) { for (const { mutationInQueue, builtNode } of collectedIframes) {
this.attachDocumentToIframe(mutationInQueue, builtNode); this.attachDocumentToIframe(mutationInQueue, builtNode);
this.newDocumentQueue = this.newDocumentQueue.filter( this.newDocumentQueue = this.newDocumentQueue.filter(
(m) => m !== mutationInQueue, (m) => m !== mutationInQueue,
); );
} }
collectedDialogs.forEach((d) => applyDialogToTopLevel(d));
} }
private collectIframeAndAttachDocument( private collectIframeAndAttachDocument(
@@ -1534,6 +1544,7 @@ export class Replayer {
const afterAppend = (node: Node | RRNode, id: number) => { const afterAppend = (node: Node | RRNode, id: number) => {
// Skip the plugin onBuild callback for virtual dom // Skip the plugin onBuild callback for virtual dom
if (this.usingVirtualDom) return; if (this.usingVirtualDom) return;
applyDialogToTopLevel(node);
for (const plugin of this.config.plugins || []) { for (const plugin of this.config.plugins || []) {
if (plugin.onBuild) plugin.onBuild(node, { id, replayer: this }); if (plugin.onBuild) plugin.onBuild(node, { id, replayer: this });
} }
@@ -1757,6 +1768,8 @@ export class Replayer {
const value = mutation.attributes[attributeName]; const value = mutation.attributes[attributeName];
if (value === null) { if (value === null) {
(target as Element | RRElement).removeAttribute(attributeName); (target as Element | RRElement).removeAttribute(attributeName);
if (attributeName === 'open')
removeDialogFromTopLevel(target, mutation);
} else if (typeof value === 'string') { } else if (typeof value === 'string') {
try { try {
// When building snapshot, some link styles haven't loaded. Then they are loaded, they will be inlined as incremental mutation change of attribute. We need to replace the old elements whose styles aren't inlined. // When building snapshot, some link styles haven't loaded. Then they are loaded, they will be inlined as incremental mutation change of attribute. We need to replace the old elements whose styles aren't inlined.
@@ -1812,6 +1825,13 @@ export class Replayer {
value, value,
); );
} }
if (
attributeName === 'rr_open_mode' &&
target.nodeName === 'DIALOG'
) {
applyDialogToTopLevel(target, mutation);
}
} catch (error) { } catch (error) {
this.warn( this.warn(
'An error occurred may due to the checkout feature.', 'An error occurred may due to the checkout feature.',

View File

@@ -0,0 +1,458 @@
import { eventWithTime, IncrementalSource } from '@rrweb/types';
const startTime = 1900000000;
export const closedFullSnapshotTime = 132;
export const showIncrementalAttributeTime = 1500;
export const closeIncrementalAttributeTime = 2000;
export const showModalIncrementalAttributeTime = 2500;
export const switchBetweenShowModalAndShowIncrementalAttributeTime = 2600;
export const switchBetweenShowAndShowModalIncrementalAttributeTime = 2700;
export const showFullSnapshotTime = 3000;
export const showModalFullSnapshotTime = 3500;
export const showModalIncrementalAddTime = 4000;
const events: eventWithTime[] = [
{ type: 0, data: {}, timestamp: startTime + 1 },
{ type: 1, data: {}, timestamp: startTime + closedFullSnapshotTime },
{
type: 4,
data: {
href: 'http://127.0.0.1:5500/test/html/dialog.html',
width: 1600,
height: 900,
},
timestamp: startTime + closedFullSnapshotTime,
},
{
type: 2,
data: {
node: {
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: blue solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + closedFullSnapshotTime,
},
// open dialog with .show()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { open: '', rr_open_mode: 'non-modal', class: 'show' },
},
],
},
timestamp: startTime + showIncrementalAttributeTime,
},
// close dialog with .close()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { open: null, class: 'closed' },
},
],
},
timestamp: startTime + closeIncrementalAttributeTime,
},
// open dialog with .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { rr_open_mode: 'modal', open: '', class: 'showModal' },
},
],
},
timestamp: startTime + showModalIncrementalAttributeTime,
},
// switch between .showModal() and .show()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: {
rr_open_mode: 'non-modal',
class: 'switched-from-show-modal-to-show',
},
},
],
},
timestamp:
startTime + switchBetweenShowModalAndShowIncrementalAttributeTime,
},
// switch between .show() and .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: {
rr_open_mode: 'modal',
class: 'switched-from-show-to-show-modal',
},
},
],
},
timestamp:
startTime + switchBetweenShowAndShowModalIncrementalAttributeTime,
},
// open dialog with .show()
{
type: 2,
data: {
node: {
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
open: '',
rr_open_mode: 'non-modal',
style: 'outline: blue solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + showFullSnapshotTime,
},
{
type: 2,
data: {
node: {
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
rr_open_mode: 'modal',
open: '',
style: 'outline: blue solid 1px;',
class: 'existing-1',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
class: 'existing-2',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + showModalFullSnapshotTime,
},
// add open dialog with .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [
{
parentId: 22,
previousId: 23,
nextId: 24,
node: {
type: 2,
tagName: 'dialog',
attributes: {
rr_open_mode: 'modal',
open: '',
style: 'outline: orange solid 1px;',
class: 'new-dialog',
},
childNodes: [],
id: 32,
},
},
{
parentId: 32,
previousId: null,
nextId: null,
node: { type: 3, textContent: 'Dialog 3', id: 33 },
},
],
removes: [],
texts: [],
attributes: [],
},
timestamp: startTime + showModalIncrementalAddTime,
},
];
export default events;

View File

@@ -0,0 +1,5 @@
<html>
<body>
<dialog>I'm a dialog</dialog>
</body>
</html>

View File

@@ -0,0 +1,487 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`dialog > add dialog and show 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 6,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
},
\\"childNodes\\": [],
\\"id\\": 11
}
}
]
}
}
]"
`;
exports[`dialog > add dialog and showModal 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 6,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
},
\\"childNodes\\": [],
\\"id\\": 11
}
}
]
}
}
]"
`;
exports[`dialog > switch to show dialog 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;
exports[`dialog > switch to showModal dialog 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;

View File

@@ -0,0 +1,229 @@
import * as fs from 'fs';
import * as path from 'path';
import { vi } from 'vitest';
import {
assertSnapshot,
getServerURL,
ISuite,
launchPuppeteer,
startServer,
waitForRAF,
} from '../utils';
import {
attributeMutation,
EventType,
eventWithTime,
listenerHandler,
} from '@rrweb/types';
import { recordOptions } from '../../src/types';
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const attributeMutationFactory = (
mutation: attributeMutation['attributes'],
) => {
return {
data: {
attributes: [
{
attributes: mutation,
},
],
},
};
};
describe('dialog', () => {
vi.setConfig({ testTimeout: 100_000 });
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
let events: ISuite['events'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => {
console.log(msg.text());
});
await page.goto(`${serverURL}/html/dialog.html`);
await page.addScriptTag({
path: path.resolve(__dirname, '../../dist/rrweb.umd.cjs'),
});
await waitForRAF(page);
events = [];
await page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
events.push(e);
});
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
emit: (window as unknown as IWindow).emit,
});
});
await waitForRAF(page);
});
it('show dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: '' }));
// assertSnapshot(events);
});
it('showModal dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(
attributeMutationFactory({ rr_open_mode: 'modal' }),
);
});
it('showModal & close dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: null }));
});
it('show & close dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: null }));
});
it('switch to showModal dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
dialog.showModal();
});
await assertSnapshot(events);
});
it('switch to show dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
dialog.show();
});
await assertSnapshot(events);
});
it('add dialog and showModal', async () => {
await page.evaluate(() => {
const dialog = document.createElement('dialog') as HTMLDialogElement;
document.body.appendChild(dialog);
dialog.showModal();
});
await waitForRAF(page);
await assertSnapshot(events);
});
it('add dialog and show', async () => {
await page.evaluate(() => {
const dialog = document.createElement('dialog') as HTMLDialogElement;
document.body.appendChild(dialog);
dialog.show();
});
await waitForRAF(page);
await assertSnapshot(events);
});
// TODO: implement me in the future
it.skip('should record playback order with multiple dialogs opening', async () => {
await page.evaluate(() => {
const dialog1 = document.createElement('dialog') as HTMLDialogElement;
dialog1.className = 'dialog1';
document.body.appendChild(dialog1);
const dialog2 = document.createElement('dialog') as HTMLDialogElement;
dialog1.className = 'dialog2';
document.body.appendChild(dialog2);
dialog2.showModal(); // <== Note that dialog TWO is being triggered first
dialog1.showModal();
});
await waitForRAF(page);
await assertSnapshot(events); // <== This should trigger showModal() on dialog2 first, then dialog1
});
});

View File

@@ -0,0 +1,159 @@
import * as fs from 'fs';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import * as path from 'path';
import { vi } from 'vitest';
import dialogPlaybackEvents, {
closedFullSnapshotTime,
showIncrementalAttributeTime,
closeIncrementalAttributeTime,
showModalIncrementalAttributeTime,
showFullSnapshotTime,
showModalFullSnapshotTime,
showModalIncrementalAddTime,
switchBetweenShowModalAndShowIncrementalAttributeTime,
switchBetweenShowAndShowModalIncrementalAttributeTime,
} from '../events/dialog-playback';
import {
fakeGoto,
getServerURL,
hideMouseAnimation,
ISuite,
launchPuppeteer,
startServer,
waitForRAF,
} from '../utils';
expect.extend({ toMatchImageSnapshot });
describe('dialog', () => {
vi.setConfig({ testTimeout: 100_000 });
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => {
console.log(msg.text());
});
await fakeGoto(page, `${serverURL}/html/dialog.html`);
await page.evaluate(code);
await waitForRAF(page);
await hideMouseAnimation(page);
});
[
{
name: 'show the dialog when open attribute gets added',
time: showIncrementalAttributeTime,
},
{
name: 'should close dialog again when open attribute gets removed',
time: closeIncrementalAttributeTime,
},
{
name: 'should open dialog with showModal',
time: showModalIncrementalAttributeTime,
},
{
name: 'should switch between showModal and show',
time: switchBetweenShowModalAndShowIncrementalAttributeTime,
},
{
name: 'should switch between show and showModal',
time: switchBetweenShowAndShowModalIncrementalAttributeTime,
},
{
name: 'should open dialog with show in full snapshot',
time: showFullSnapshotTime,
},
{
name: 'should open dialog with showModal in full snapshot',
time: showModalFullSnapshotTime,
},
{
name: 'should add an opened dialog with showModal in incremental snapshot',
time: showModalIncrementalAddTime,
},
{
name: 'should add an opened dialog with showModal in incremental snapshot alternative',
time: [showModalFullSnapshotTime, showModalIncrementalAddTime],
},
].forEach(({ name, time }) => {
[true, false].forEach((useVirtualDom) => {
it(`${name} (virtual dom: ${useVirtualDom})`, async () => {
await page.evaluate(
`let events = ${JSON.stringify(dialogPlaybackEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events, { useVirtualDom: ${useVirtualDom} });
`);
const timeArray = Array.isArray(time) ? time : [time];
for (let i = 0; i < timeArray.length; i++) {
await page.evaluate(`
window.replayer.pause(${timeArray[i]});
`);
await waitForRAF(page);
}
const frameImage = await page!.screenshot({
fullPage: false,
});
const defaultImageFilePrefix =
'dialog-test-ts-test-replay-dialog-test-ts-dialog';
const kebabCaseName = name
.replace(/ /g, '-')
.replace(/showModal/g, 'show-modal');
const imageFileName = `${defaultImageFilePrefix}-${kebabCaseName}`;
expect(frameImage).toMatchImageSnapshot({
customSnapshotIdentifier: imageFileName,
failureThreshold: 0.05,
failureThresholdType: 'percent',
dumpDiffToConsole: true,
storeReceivedOnFailure: true,
});
});
});
});
it('closed dialogs show nothing', async () => {
await page.evaluate(`let events = ${JSON.stringify(dialogPlaybackEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
`);
await waitForRAF(page);
const frameImage = await page!.screenshot();
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
});
// TODO: implement me in the future
it.skip('should trigger showModal on multiple dialogs in a specific order');
});

View File

@@ -5,6 +5,8 @@ import * as path from 'path';
import type { PackageJson } from 'type-fest'; import type { PackageJson } from 'type-fest';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
const emptyOutDir = !process.argv.includes('--watch');
function useSpecialFormat( function useSpecialFormat(
entriesToUse: string[], entriesToUse: string[],
format: LibraryFormats, format: LibraryFormats,
@@ -46,7 +48,7 @@ export default defineConfig({
'dist', 'dist',
process.env.TARGET_BROWSER as string, process.env.TARGET_BROWSER as string,
), ),
emptyOutDir: true, emptyOutDir,
}, },
// Add the webExtension plugin // Add the webExtension plugin
plugins: [ plugins: [

View File

@@ -7,6 +7,7 @@
"vite.config.defaults.ts", "vite.config.defaults.ts",
"tsconfig.json" "tsconfig.json"
], ],
"globalPassThroughEnv": ["PUPPETEER_HEADLESS"],
"tasks": { "tasks": {
"prepublish": { "prepublish": {
"dependsOn": ["^prepublish", "//#references:update"], "dependsOn": ["^prepublish", "//#references:update"],
@@ -20,17 +21,14 @@
] ]
}, },
"test": { "test": {
"dependsOn": ["^prepublish"], "dependsOn": ["^prepublish"]
"passThroughEnv": ["PUPPETEER_HEADLESS"]
}, },
"test:watch": { "test:watch": {
"persistent": true, "persistent": true,
"passThroughEnv": ["PUPPETEER_HEADLESS"],
"cache": false "cache": false
}, },
"test:update": { "test:update": {
"dependsOn": ["^prepublish"], "dependsOn": ["^prepublish"]
"passThroughEnv": ["PUPPETEER_HEADLESS"]
}, },
"dev": { "dev": {
"dependsOn": ["prepublish", "^prepublish"], "dependsOn": ["prepublish", "^prepublish"],

View File

@@ -5793,6 +5793,15 @@ growly@^1.3.0:
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw== integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==
happy-dom@^14.12.0:
version "14.12.0"
resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-14.12.0.tgz#40c748578c6ebfb707e6ae69179d6c541d8f63b3"
integrity sha512-dHcnlGFY2o2CdxfuYpqwSrBrpj/Kuzv4u4f3TU5yHW1GL24dKij4pv1BRjXnXc3uWo8qsCbToF9weaDsm/He8A==
dependencies:
entities "^4.5.0"
webidl-conversions "^7.0.0"
whatwg-mimetype "^3.0.0"
hard-rejection@^2.1.0: hard-rejection@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
@@ -10427,6 +10436,11 @@ webidl-conversions@^6.1.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
whatwg-encoding@^1.0.5: whatwg-encoding@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
@@ -10439,6 +10453,11 @@ whatwg-mimetype@^2.3.0:
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
whatwg-mimetype@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7"
integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==
whatwg-url@^5.0.0: whatwg-url@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"