Skip to content

Commit 335639a

Browse files
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 #1503 (comment) * 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>
1 parent d350da8 commit 335639a

File tree

38 files changed

+1902
-75
lines changed

38 files changed

+1902
-75
lines changed

.changeset/happy-carrots-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"rrweb-snapshot": minor
3+
---
4+
5+
Record dialog's modal status for replay in rrweb. (Currently triggering `dialog.showModal()` is not supported in rrweb-snapshot's rebuild)

.changeset/silly-knives-chew.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"rrdom": minor
3+
"rrweb": minor
4+
"@rrweb/types": minor
5+
---
6+
7+
Support top-layer <dialog> components. Fixes #1381.

.github/workflows/ci-cd.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,5 @@ jobs:
3838
if: failure()
3939
with:
4040
name: image-diff
41-
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png
41+
path: packages/**/__image_snapshots__/__diff_output__/*.png
4242
if-no-files-found: ignore

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Since we want the record and replay sides to share a strongly typed data structu
6262

6363
1. Fork this repository.
6464
2. Run `yarn install` in the root to install required dependencies for all sub-packages (note: `npm install` is _not_ recommended).
65-
3. Run `yarn dev` in the root to get auto-building for all the sub-packages whenever you modify anything.
65+
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.
6666
4. Navigate to one of the sub-packages (in the `packages` folder) where you'd like to make a change.
6767
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.
6868
6. If tests are failing, but the change in output is desirable, run `yarn test:update` and carefully commit the changes in test output.

packages/rrdom/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@typescript-eslint/eslint-plugin": "^5.23.0",
4747
"@typescript-eslint/parser": "^5.23.0",
4848
"eslint": "^8.15.0",
49+
"happy-dom": "^14.12.0",
4950
"puppeteer": "^17.1.3",
5051
"typescript": "^5.4.5",
5152
"vite": "^5.3.1",

packages/rrdom/src/diff.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from './document';
2222
import type {
2323
RRCanvasElement,
24+
RRDialogElement,
2425
RRElement,
2526
RRIFrameElement,
2627
RRMediaElement,
@@ -285,6 +286,29 @@ function diffAfterUpdatingChildren(
285286
);
286287
break;
287288
}
289+
case 'DIALOG': {
290+
const dialog = oldElement as HTMLDialogElement;
291+
const rrDialog = newRRElement as unknown as RRDialogElement;
292+
const wasOpen = dialog.open;
293+
const wasModal = dialog.matches('dialog:modal');
294+
const shouldBeOpen = rrDialog.open;
295+
const shouldBeModal = rrDialog.isModal;
296+
297+
const modalChanged = wasModal !== shouldBeModal;
298+
const openChanged = wasOpen !== shouldBeOpen;
299+
300+
if (modalChanged || (wasOpen && openChanged)) dialog.close();
301+
if (shouldBeOpen && (openChanged || modalChanged)) {
302+
try {
303+
if (shouldBeModal) dialog.showModal();
304+
else dialog.show();
305+
} catch (e) {
306+
console.warn(e);
307+
}
308+
}
309+
310+
break;
311+
}
288312
}
289313
break;
290314
}
@@ -335,7 +359,6 @@ function diffProps(
335359

336360
for (const { name } of Array.from(oldAttributes))
337361
if (!(name in newAttributes)) oldTree.removeAttribute(name);
338-
339362
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
340363
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
341364
}

packages/rrdom/src/document.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,8 @@ export class BaseRRElement extends BaseRRNode implements IRRElement {
474474
}
475475

476476
public getAttribute(name: string): string | null {
477-
return this.attributes[name] || null;
477+
if (this.attributes[name] === undefined) return null;
478+
return this.attributes[name];
478479
}
479480

480481
public setAttribute(name: string, attribute: string) {
@@ -547,6 +548,30 @@ export class BaseRRMediaElement extends BaseRRElement {
547548
}
548549
}
549550

551+
export class BaseRRDialogElement extends BaseRRElement {
552+
public readonly tagName = 'DIALOG' as const;
553+
public readonly nodeName = 'DIALOG' as const;
554+
555+
get isModal() {
556+
return this.getAttribute('rr_open_mode') === 'modal';
557+
}
558+
get open() {
559+
return this.getAttribute('open') !== null;
560+
}
561+
public close() {
562+
this.removeAttribute('open');
563+
this.removeAttribute('rr_open_mode');
564+
}
565+
public show() {
566+
this.setAttribute('open', '');
567+
this.setAttribute('rr_open_mode', 'non-modal');
568+
}
569+
public showModal() {
570+
this.setAttribute('open', '');
571+
this.setAttribute('rr_open_mode', 'modal');
572+
}
573+
}
574+
550575
export class BaseRRText extends BaseRRNode implements IRRText {
551576
public readonly nodeType: number = NodeType.TEXT_NODE;
552577
public readonly nodeName = '#text' as const;

packages/rrdom/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type IRRDocumentType,
3232
type IRRText,
3333
type IRRComment,
34+
BaseRRDialogElement,
3435
} from './document';
3536

3637
export class RRDocument extends BaseRRDocument {
@@ -104,6 +105,9 @@ export class RRDocument extends BaseRRDocument {
104105
case 'STYLE':
105106
element = new RRStyleElement(upperTagName);
106107
break;
108+
case 'DIALOG':
109+
element = new RRDialogElement(upperTagName);
110+
break;
107111
default:
108112
element = new RRElement(upperTagName);
109113
break;
@@ -151,6 +155,8 @@ export class RRElement extends BaseRRElement {
151155

152156
export class RRMediaElement extends BaseRRMediaElement {}
153157

158+
export class RRDialogElement extends BaseRRDialogElement {}
159+
154160
export class RRCanvasElement extends RRElement implements IRRElement {
155161
public rr_dataURL: string | null = null;
156162
public canvasMutations: {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @vitest-environment happy-dom
3+
*/
4+
import { vi, MockInstance } from 'vitest';
5+
import {
6+
NodeType as RRNodeType,
7+
createMirror,
8+
Mirror as NodeMirror,
9+
serializedNodeWithId,
10+
} from 'rrweb-snapshot';
11+
import { RRDocument } from '../../src';
12+
import { diff, ReplayerHandler } from '../../src/diff';
13+
14+
describe('diff algorithm for rrdom', () => {
15+
let mirror: NodeMirror;
16+
let replayer: ReplayerHandler;
17+
let warn: MockInstance;
18+
let elementSn: serializedNodeWithId;
19+
let elementSn2: serializedNodeWithId;
20+
21+
beforeEach(() => {
22+
mirror = createMirror();
23+
replayer = {
24+
mirror,
25+
applyCanvas: () => {},
26+
applyInput: () => {},
27+
applyScroll: () => {},
28+
applyStyleSheetMutation: () => {},
29+
afterAppend: () => {},
30+
};
31+
document.write('<!DOCTYPE html><html><head></head><body></body></html>');
32+
// Mock the original console.warn function to make the test fail once console.warn is called.
33+
warn = vi.spyOn(console, 'warn');
34+
35+
elementSn = {
36+
type: RRNodeType.Element,
37+
tagName: 'DIALOG',
38+
attributes: {},
39+
childNodes: [],
40+
id: 1,
41+
};
42+
43+
elementSn2 = {
44+
...elementSn,
45+
attributes: {},
46+
};
47+
});
48+
49+
afterEach(() => {
50+
// Check that warn was not called (fail on warning)
51+
expect(warn).not.toBeCalled();
52+
vi.resetAllMocks();
53+
});
54+
describe('diff dialog elements', () => {
55+
vi.setConfig({ testTimeout: 60_000 });
56+
57+
it('should trigger `showModal` on rr_open_mode:modal attributes', () => {
58+
const tagName = 'DIALOG';
59+
const node = document.createElement(tagName) as HTMLDialogElement;
60+
vi.spyOn(node, 'matches').mockReturnValue(false); // matches is used to check if the dialog was opened with showModal
61+
const showModalFn = vi.spyOn(node, 'showModal');
62+
63+
const rrDocument = new RRDocument();
64+
const rrNode = rrDocument.createElement(tagName);
65+
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
66+
67+
mirror.add(node, elementSn);
68+
rrDocument.mirror.add(rrNode, elementSn);
69+
diff(node, rrNode, replayer);
70+
71+
expect(showModalFn).toBeCalled();
72+
});
73+
74+
it('should trigger `close` on rr_open_mode removed', () => {
75+
const tagName = 'DIALOG';
76+
const node = document.createElement(tagName) as HTMLDialogElement;
77+
node.showModal();
78+
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
79+
const closeFn = vi.spyOn(node, 'close');
80+
81+
const rrDocument = new RRDocument();
82+
const rrNode = rrDocument.createElement(tagName);
83+
rrNode.attributes = {};
84+
85+
mirror.add(node, elementSn);
86+
rrDocument.mirror.add(rrNode, elementSn);
87+
diff(node, rrNode, replayer);
88+
89+
expect(closeFn).toBeCalled();
90+
});
91+
92+
it('should not trigger `close` on rr_open_mode is kept', () => {
93+
const tagName = 'DIALOG';
94+
const node = document.createElement(tagName) as HTMLDialogElement;
95+
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
96+
node.setAttribute('rr_open_mode', 'modal');
97+
node.setAttribute('open', '');
98+
const closeFn = vi.spyOn(node, 'close');
99+
100+
const rrDocument = new RRDocument();
101+
const rrNode = rrDocument.createElement(tagName);
102+
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
103+
104+
mirror.add(node, elementSn);
105+
rrDocument.mirror.add(rrNode, elementSn);
106+
diff(node, rrNode, replayer);
107+
108+
expect(closeFn).not.toBeCalled();
109+
expect(node.open).toBe(true);
110+
});
111+
});
112+
});

packages/rrweb-snapshot/src/rebuild.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ function buildNode(
287287
(node as HTMLMediaElement).loop = value;
288288
} else if (name === 'rr_mediaVolume' && typeof value === 'number') {
289289
(node as HTMLMediaElement).volume = value;
290+
} else if (name === 'rr_open_mode') {
291+
(node as HTMLDialogElement).setAttribute(
292+
'rr_open_mode',
293+
value as string,
294+
); // keep this attribute for rrweb to trigger showModal
290295
}
291296
}
292297

0 commit comments

Comments
 (0)