Skip to content

Commit

Permalink
Support top-layer <dialog> recording & replay (#1503)
Browse files Browse the repository at this point in the history
* 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 [email protected]

* 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 <[email protected]>
  • Loading branch information
Juice10 and eoghanmurray authored Aug 2, 2024
1 parent d350da8 commit 335639a
Show file tree
Hide file tree
Showing 38 changed files with 1,902 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/happy-carrots-hide.md
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions .changeset/silly-knives-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"rrdom": minor
"rrweb": minor
"@rrweb/types": minor
---

Support top-layer <dialog> components. Fixes #1381.
2 changes: 1 addition & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ jobs:
if: failure()
with:
name: image-diff
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png
path: packages/**/__image_snapshots__/__diff_output__/*.png
if-no-files-found: ignore
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Since we want the record and replay sides to share a strongly typed data structu

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).
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.
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.
Expand Down
1 change: 1 addition & 0 deletions packages/rrdom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0",
"eslint": "^8.15.0",
"happy-dom": "^14.12.0",
"puppeteer": "^17.1.3",
"typescript": "^5.4.5",
"vite": "^5.3.1",
Expand Down
25 changes: 24 additions & 1 deletion packages/rrdom/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
} from './document';
import type {
RRCanvasElement,
RRDialogElement,
RRElement,
RRIFrameElement,
RRMediaElement,
Expand Down Expand Up @@ -285,6 +286,29 @@ function diffAfterUpdatingChildren(
);
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;
}
Expand Down Expand Up @@ -335,7 +359,6 @@ function diffProps(

for (const { name } of Array.from(oldAttributes))
if (!(name in newAttributes)) oldTree.removeAttribute(name);

newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
}
Expand Down
27 changes: 26 additions & 1 deletion packages/rrdom/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,8 @@ export class BaseRRElement extends BaseRRNode implements IRRElement {
}

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) {
Expand Down Expand Up @@ -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 {
public readonly nodeType: number = NodeType.TEXT_NODE;
public readonly nodeName = '#text' as const;
Expand Down
6 changes: 6 additions & 0 deletions packages/rrdom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type IRRDocumentType,
type IRRText,
type IRRComment,
BaseRRDialogElement,
} from './document';

export class RRDocument extends BaseRRDocument {
Expand Down Expand Up @@ -104,6 +105,9 @@ export class RRDocument extends BaseRRDocument {
case 'STYLE':
element = new RRStyleElement(upperTagName);
break;
case 'DIALOG':
element = new RRDialogElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
break;
Expand Down Expand Up @@ -151,6 +155,8 @@ export class RRElement extends BaseRRElement {

export class RRMediaElement extends BaseRRMediaElement {}

export class RRDialogElement extends BaseRRDialogElement {}

export class RRCanvasElement extends RRElement implements IRRElement {
public rr_dataURL: string | null = null;
public canvasMutations: {
Expand Down
112 changes: 112 additions & 0 deletions packages/rrdom/test/diff/dialog.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
5 changes: 5 additions & 0 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ function buildNode(
(node as HTMLMediaElement).loop = value;
} else if (name === 'rr_mediaVolume' && typeof value === 'number') {
(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
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type MaskInputOptions,
type SlimDOMOptions,
type DataURLOptions,
type DialogAttributes,
type MaskTextFn,
type MaskInputFn,
type KeepIframeSrcFn,
Expand Down Expand Up @@ -652,6 +653,16 @@ function serializeElementNode(
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
if (tagName === 'canvas' && recordCanvas) {
if ((n as ICanvas).__context === '2d') {
Expand Down
17 changes: 17 additions & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ export type mediaAttributes = {
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
export interface INode extends Node {
__sn: serializedNodeWithId;
Expand Down
Loading

0 comments on commit 335639a

Please sign in to comment.