feat: add support for recording and replaying adoptedStyleSheets API (#989)
* test(recording side): add test case for adopted stylesheets in shadow doms and iframe * add type definition for adopted StyleSheets * create a StyleSheet Mirror * enable to record the outermost document's adoptedStyleSheet * enable to serialize all stylesheets in documents (iframe) and shadow roots * enable to record adopted stylesheets while building full snapshot * test: add test case for mutations on adoptedStyleSheets * defer to record adoptedStyleSheets to avoid create events before full snapshot * feat: enable to track the mutation of AdoptedStyleSheets * Merge branch 'fix-shadowdom-record' into construct-style * fix: incorrect id conditional judgement * test: add a test case for replaying side * tweak the style mirror for replayer * feat: enable to replay adoptedStyleSheet events * fix: rule index wasn't recorded when serializing the adoptedStyleSheets * add test case for mutation of stylesheet objects and add support for replace & replaceSync * refactor: improve the code quality * feat: monkey patch adoptedStyleSheet API to track its modification * feat: add support for checkouting fullsnapshot * CI: fix failed type checks * test: add test case for nested shadow doms and iframe elements * feat: add support for adoptedStyleSheets in VirtualDom mode * style: format files * test: improve the robustness of the test case * CI: fix an eslint error * test: improve the robustness of the test case * fix: adoptedStyleSheets not applied in fast-forward mode (virtual dom optimization not used) * refactor the data structure of adoptedStyleSheet event to make it more efficient and robust * improve the robustness in the live mode In the live mode where events are transferred over network without strict order guarantee, some newer events are applied before some old events and adopted stylesheets may haven't been created. Added a retry mechanism to solve this problem. * apply Yanzhen's review suggestion * update action name * test: make the test case more robust for travis CI * Update packages/rrweb/src/record/constructableStyleSheets.d.ts Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com> * Update packages/rrweb/src/record/constructableStyleSheets.d.ts Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com> * apply Justin's review suggestions add more browser compatibility checks * add eslint-plugin-compat and config * fix record test type errors * make Mirror's replace function act the same with the original one when there's no existed node to replace * test: increase the robustness of test cases * remove eslint disable in favor of feature detection Early returns aren't supported yet unfortunately, otherwise this code would be cleaner https://github.com/amilajack/eslint-plugin-compat/issues/523 * Remove eslint-disable-next-line compat/compat * Standardize browserslist and remove lint exceptions (#1010) * test: revert deleting virtual style tests and rewrite them to fit the current code base Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:compat/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
@@ -17,7 +18,7 @@ module.exports = {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.eslint.json', './packages/*/tsconfig.json'],
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc', 'jest'],
|
||||
plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc', 'jest', 'compat'],
|
||||
rules: {
|
||||
'tsdoc/syntax': 'warn',
|
||||
},
|
||||
|
||||
10
.github/workflows/style-check.yml
vendored
10
.github/workflows/style-check.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
run: yarn lint:report
|
||||
# Continue to the next step even if this fails
|
||||
continue-on-error: true
|
||||
- name: Upload ESLint report
|
||||
- name: Upload ESLint Report
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: eslint_report.json
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
report-json: 'eslint_report.json'
|
||||
|
||||
prettier_check:
|
||||
# In the forked PR, it's hard to format code and push to the branch directly.
|
||||
# In the forked PR, it's hard to format code and push to the branch directly, so the action only check the format correctness.
|
||||
if: github.event_name != 'push' && github.event.pull_request.head.repo.full_name != 'rrweb-io/rrweb'
|
||||
runs-on: ubuntu-latest
|
||||
name: Format Check
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
cache: 'yarn'
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
- name: Prettify code
|
||||
- name: Prettier Check
|
||||
run: yarn prettier --check '**/*.{ts,md}'
|
||||
|
||||
prettier:
|
||||
@@ -86,9 +86,9 @@ jobs:
|
||||
cache: 'yarn'
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
- name: Prettify code
|
||||
- name: Prettify Code
|
||||
run: yarn prettier --write '**/*.{ts,md}'
|
||||
- name: Commit changes
|
||||
- name: Commit Changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v4
|
||||
with:
|
||||
commit_message: Apply formatting changes
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.8.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.25.0",
|
||||
"@typescript-eslint/parser": "^5.25.0",
|
||||
"browserslist": "^4.21.4",
|
||||
"concurrently": "^7.1.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-compat": "^4.0.2",
|
||||
"eslint-plugin-jest": "^26.5.3",
|
||||
"eslint-plugin-tsdoc": "^0.2.16",
|
||||
"lerna": "^4.0.0",
|
||||
@@ -44,5 +46,9 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"**/jsdom/cssom": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
"not op_mini all"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,5 +51,8 @@
|
||||
"nwsapi": "^2.2.0",
|
||||
"rrdom": "^0.1.5",
|
||||
"rrweb-snapshot": "^2.0.0-alpha.2"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"supports es6-class"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"exclude": ["test"],
|
||||
"include": ["src", "test.d.ts", "../rrweb/src/record/workers/workers.d.ts"],
|
||||
"include": [
|
||||
"src",
|
||||
"test.d.ts",
|
||||
"../rrweb/src/record/workers/workers.d.ts",
|
||||
"../rrweb/src/record/constructable-stylesheets.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../rrdom"
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
canvasEventWithTime,
|
||||
inputData,
|
||||
scrollData,
|
||||
styleDeclarationData,
|
||||
styleSheetRuleData,
|
||||
} from 'rrweb/src/types';
|
||||
import type {
|
||||
IRRCDATASection,
|
||||
@@ -79,6 +81,10 @@ export type ReplayerHandler = {
|
||||
) => void;
|
||||
applyInput: (data: inputData) => void;
|
||||
applyScroll: (data: scrollData, isSync: boolean) => void;
|
||||
applyStyleSheetMutation: (
|
||||
data: styleDeclarationData | styleSheetRuleData,
|
||||
styleSheet: CSSStyleSheet,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function diff(
|
||||
@@ -161,20 +167,25 @@ export function diff(
|
||||
}
|
||||
break;
|
||||
case 'STYLE':
|
||||
applyVirtualStyleRulesToNode(
|
||||
oldElement as HTMLStyleElement,
|
||||
(newTree as RRStyleElement).rules,
|
||||
);
|
||||
{
|
||||
const styleSheet = (oldElement as HTMLStyleElement).sheet;
|
||||
styleSheet &&
|
||||
(newTree as RRStyleElement).rules.forEach((data) =>
|
||||
replayer.applyStyleSheetMutation(data, styleSheet),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (newRRElement.shadowRoot) {
|
||||
if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const oldChildren = oldElement.shadowRoot!.childNodes;
|
||||
const newChildren = newRRElement.shadowRoot.childNodes;
|
||||
if (oldChildren.length > 0 || newChildren.length > 0)
|
||||
diffChildren(
|
||||
Array.from(oldChildren),
|
||||
newChildren,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
oldElement.shadowRoot!,
|
||||
replayer,
|
||||
rrnodeMirror,
|
||||
@@ -335,6 +346,7 @@ function diffChildren(
|
||||
}
|
||||
indexInOld = oldIdToIndex[rrnodeMirror.getId(newStartNode)];
|
||||
if (indexInOld) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const nodeToMove = oldChildren[indexInOld]!;
|
||||
parentNode.insertBefore(nodeToMove, oldStartNode);
|
||||
diff(nodeToMove, newStartNode, replayer, rrnodeMirror);
|
||||
@@ -439,110 +451,3 @@ export function createOrGetNode(
|
||||
if (sn) domMirror.add(node, { ...sn });
|
||||
return node;
|
||||
}
|
||||
|
||||
export function getNestedRule(
|
||||
rules: CSSRuleList,
|
||||
position: number[],
|
||||
): CSSGroupingRule {
|
||||
const rule = rules[position[0]] as CSSGroupingRule;
|
||||
if (position.length === 1) {
|
||||
return rule;
|
||||
} else {
|
||||
return getNestedRule(
|
||||
(rule.cssRules[position[1]] as CSSGroupingRule).cssRules,
|
||||
position.slice(2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export enum StyleRuleType {
|
||||
Insert,
|
||||
Remove,
|
||||
Snapshot,
|
||||
SetProperty,
|
||||
RemoveProperty,
|
||||
}
|
||||
type InsertRule = {
|
||||
cssText: string;
|
||||
type: StyleRuleType.Insert;
|
||||
index?: number | number[];
|
||||
};
|
||||
type RemoveRule = {
|
||||
type: StyleRuleType.Remove;
|
||||
index: number | number[];
|
||||
};
|
||||
type SetPropertyRule = {
|
||||
type: StyleRuleType.SetProperty;
|
||||
index: number[];
|
||||
property: string;
|
||||
value: string | null;
|
||||
priority: string | undefined;
|
||||
};
|
||||
type RemovePropertyRule = {
|
||||
type: StyleRuleType.RemoveProperty;
|
||||
index: number[];
|
||||
property: string;
|
||||
};
|
||||
|
||||
export type VirtualStyleRules = Array<
|
||||
InsertRule | RemoveRule | SetPropertyRule | RemovePropertyRule
|
||||
>;
|
||||
|
||||
export function getPositionsAndIndex(nestedIndex: number[]) {
|
||||
const positions = [...nestedIndex];
|
||||
const index = positions.pop();
|
||||
return { positions, index };
|
||||
}
|
||||
|
||||
export function applyVirtualStyleRulesToNode(
|
||||
styleNode: HTMLStyleElement,
|
||||
virtualStyleRules: VirtualStyleRules,
|
||||
) {
|
||||
const sheet = styleNode.sheet!;
|
||||
|
||||
virtualStyleRules.forEach((rule) => {
|
||||
if (rule.type === StyleRuleType.Insert) {
|
||||
try {
|
||||
if (Array.isArray(rule.index)) {
|
||||
const { positions, index } = getPositionsAndIndex(rule.index);
|
||||
const nestedRule = getNestedRule(sheet.cssRules, positions);
|
||||
nestedRule.insertRule(rule.cssText, index);
|
||||
} else {
|
||||
sheet.insertRule(rule.cssText, rule.index);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* sometimes we may capture rules with browser prefix
|
||||
* insert rule with prefixs in other browsers may cause Error
|
||||
*/
|
||||
}
|
||||
} else if (rule.type === StyleRuleType.Remove) {
|
||||
try {
|
||||
if (Array.isArray(rule.index)) {
|
||||
const { positions, index } = getPositionsAndIndex(rule.index);
|
||||
const nestedRule = getNestedRule(sheet.cssRules, positions);
|
||||
nestedRule.deleteRule(index || 0);
|
||||
} else {
|
||||
sheet.deleteRule(rule.index);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
} else if (rule.type === StyleRuleType.SetProperty) {
|
||||
const nativeRule = (getNestedRule(
|
||||
sheet.cssRules,
|
||||
rule.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
nativeRule.style.setProperty(rule.property, rule.value, rule.priority);
|
||||
} else if (rule.type === StyleRuleType.RemoveProperty) {
|
||||
const nativeRule = (getNestedRule(
|
||||
sheet.cssRules,
|
||||
rule.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
nativeRule.style.removeProperty(rule.property);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import type {
|
||||
canvasEventWithTime,
|
||||
inputData,
|
||||
scrollData,
|
||||
styleSheetRuleData,
|
||||
styleDeclarationData,
|
||||
} from 'rrweb/src/types';
|
||||
import type { VirtualStyleRules } from './diff';
|
||||
import {
|
||||
BaseRRNode as RRNode,
|
||||
BaseRRCDATASectionImpl,
|
||||
@@ -164,7 +165,7 @@ export class RRCanvasElement extends RRElement implements IRRElement {
|
||||
}
|
||||
|
||||
export class RRStyleElement extends RRElement {
|
||||
public rules: VirtualStyleRules = [];
|
||||
public rules: (styleSheetRuleData | styleDeclarationData)[] = [];
|
||||
}
|
||||
|
||||
export class RRIFrameElement extends RRElement {
|
||||
@@ -312,7 +313,8 @@ export function buildFromDom(
|
||||
}
|
||||
|
||||
if (node.nodeName === 'IFRAME') {
|
||||
walk((node as HTMLIFrameElement).contentDocument!, rrNode);
|
||||
const iframeDoc = (node as HTMLIFrameElement).contentDocument;
|
||||
iframeDoc && walk(iframeDoc, rrNode);
|
||||
} else if (
|
||||
node.nodeType === NodeType.DOCUMENT_NODE ||
|
||||
node.nodeType === NodeType.ELEMENT_NODE ||
|
||||
@@ -323,6 +325,7 @@ export function buildFromDom(
|
||||
node.nodeType === NodeType.ELEMENT_NODE &&
|
||||
(node as HTMLElement).shadowRoot
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
walk((node as HTMLElement).shadowRoot!, rrNode);
|
||||
node.childNodes.forEach((childNode) => walk(childNode, rrNode));
|
||||
}
|
||||
@@ -475,11 +478,5 @@ function walk(node: IRRNode, mirror: IMirror<IRRNode>, blankSpace: string) {
|
||||
|
||||
export { RRNode };
|
||||
|
||||
export {
|
||||
diff,
|
||||
createOrGetNode,
|
||||
StyleRuleType,
|
||||
ReplayerHandler,
|
||||
VirtualStyleRules,
|
||||
} from './diff';
|
||||
export { diff, createOrGetNode, ReplayerHandler } from './diff';
|
||||
export * from './document';
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { getDefaultSN, RRDocument, RRMediaElement } from '../src';
|
||||
import {
|
||||
applyVirtualStyleRulesToNode,
|
||||
createOrGetNode,
|
||||
diff,
|
||||
ReplayerHandler,
|
||||
StyleRuleType,
|
||||
VirtualStyleRules,
|
||||
} from '../src/diff';
|
||||
import { createOrGetNode, diff, ReplayerHandler } from '../src/diff';
|
||||
import {
|
||||
NodeType as RRNodeType,
|
||||
serializedNodeWithId,
|
||||
@@ -17,11 +10,14 @@ import {
|
||||
Mirror,
|
||||
} from 'rrweb-snapshot';
|
||||
import type { IRRNode } from '../src/document';
|
||||
import {
|
||||
import { Replayer } from 'rrweb';
|
||||
import type {
|
||||
canvasMutationData,
|
||||
EventType,
|
||||
IncrementalSource,
|
||||
styleDeclarationData,
|
||||
styleSheetRuleData,
|
||||
} from 'rrweb/src/types';
|
||||
import { EventType, IncrementalSource } from 'rrweb/src/types';
|
||||
import type { eventWithTime } from 'rrweb/typings/types';
|
||||
|
||||
const elementSn = {
|
||||
type: RRNodeType.Element,
|
||||
@@ -101,6 +97,7 @@ describe('diff algorithm for rrdom', () => {
|
||||
applyCanvas: () => {},
|
||||
applyInput: () => {},
|
||||
applyScroll: () => {},
|
||||
applyStyleSheetMutation: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -162,12 +159,23 @@ describe('diff algorithm for rrdom', () => {
|
||||
document.documentElement.appendChild(element);
|
||||
const rrDocument = new RRDocument();
|
||||
const rrStyle = rrDocument.createElement('style');
|
||||
rrStyle.rules = [
|
||||
{ cssText: 'div{color: black;}', type: StyleRuleType.Insert, index: 0 },
|
||||
];
|
||||
const styleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: [
|
||||
{
|
||||
rule: 'div{color: black;}',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
rrStyle.rules = [styleData];
|
||||
replayer.applyStyleSheetMutation = jest.fn();
|
||||
diff(element, rrStyle, replayer);
|
||||
expect(element.sheet!.cssRules.length).toEqual(1);
|
||||
expect(element.sheet!.cssRules[0].cssText).toEqual('div {color: black;}');
|
||||
expect(replayer.applyStyleSheetMutation).toHaveBeenCalledTimes(1);
|
||||
expect(replayer.applyStyleSheetMutation).toHaveBeenCalledWith(
|
||||
styleData,
|
||||
element.sheet,
|
||||
);
|
||||
});
|
||||
|
||||
it('should diff a canvas element', () => {
|
||||
@@ -1267,16 +1275,50 @@ describe('diff algorithm for rrdom', () => {
|
||||
});
|
||||
|
||||
describe('apply virtual style rules to node', () => {
|
||||
beforeEach(() => {
|
||||
const dummyReplayer = new Replayer(([
|
||||
{
|
||||
type: EventType.DomContentLoaded,
|
||||
timestamp: 0,
|
||||
},
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
with: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
timestamp: 0,
|
||||
},
|
||||
] as unknown) as eventWithTime[]);
|
||||
replayer.applyStyleSheetMutation = (
|
||||
data: styleDeclarationData | styleSheetRuleData,
|
||||
styleSheet: CSSStyleSheet,
|
||||
) => {
|
||||
if (data.source === IncrementalSource.StyleSheetRule)
|
||||
// Disable the ts check here because these two functions are private methods.
|
||||
// @ts-ignore
|
||||
dummyReplayer.applyStyleSheetRule(data, styleSheet);
|
||||
else if (data.source === IncrementalSource.StyleDeclaration)
|
||||
// @ts-ignore
|
||||
dummyReplayer.applyStyleDeclaration(data, styleSheet);
|
||||
};
|
||||
});
|
||||
|
||||
it('should insert rule at index 0 in empty sheet', () => {
|
||||
document.write('<style></style>');
|
||||
const styleEl = document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssText = '.added-rule {border: 1px solid yellow;}';
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ cssText, index: 0, type: StyleRuleType.Insert },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(styleEl, virtualStyleRules);
|
||||
const styleRuleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: [
|
||||
{
|
||||
rule: cssText,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).toEqual(1);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).toEqual(cssText);
|
||||
@@ -1292,10 +1334,16 @@ describe('diff algorithm for rrdom', () => {
|
||||
const styleEl = document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssText = '.added-rule {border: 1px solid yellow;}';
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ cssText, index: 0, type: StyleRuleType.Insert },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(styleEl, virtualStyleRules);
|
||||
const styleRuleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: [
|
||||
{
|
||||
rule: cssText,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).toEqual(3);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).toEqual(cssText);
|
||||
@@ -1310,10 +1358,15 @@ describe('diff algorithm for rrdom', () => {
|
||||
`);
|
||||
const styleEl = document.getElementsByTagName('style')[0];
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ index: 0, type: StyleRuleType.Remove },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(styleEl, virtualStyleRules);
|
||||
const styleRuleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
removes: [
|
||||
{
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!);
|
||||
|
||||
expect(styleEl.sheet?.cssRules?.length).toEqual(1);
|
||||
expect(styleEl.sheet?.cssRules[0].cssText).toEqual('div {color: black;}');
|
||||
@@ -1331,10 +1384,16 @@ describe('diff algorithm for rrdom', () => {
|
||||
const styleEl = document.getElementsByTagName('style')[0];
|
||||
|
||||
const cssText = '.added-rule {border: 1px solid yellow;}';
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ cssText, index: [0, 0], type: StyleRuleType.Insert },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(styleEl, virtualStyleRules);
|
||||
const styleRuleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: [
|
||||
{
|
||||
rule: cssText,
|
||||
index: [0, 0],
|
||||
},
|
||||
],
|
||||
};
|
||||
replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!);
|
||||
|
||||
expect(
|
||||
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
|
||||
@@ -1354,11 +1413,15 @@ describe('diff algorithm for rrdom', () => {
|
||||
</style>
|
||||
`);
|
||||
const styleEl = document.getElementsByTagName('style')[0];
|
||||
|
||||
const virtualStyleRules: VirtualStyleRules = [
|
||||
{ index: [0, 1], type: StyleRuleType.Remove },
|
||||
];
|
||||
applyVirtualStyleRulesToNode(styleEl, virtualStyleRules);
|
||||
const styleRuleData: styleSheetRuleData = {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
removes: [
|
||||
{
|
||||
index: [0, 1],
|
||||
},
|
||||
],
|
||||
};
|
||||
replayer.applyStyleSheetMutation(styleRuleData, styleEl.sheet!);
|
||||
|
||||
expect(
|
||||
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
],
|
||||
"compileOnSave": true,
|
||||
"exclude": ["test"],
|
||||
"include": ["src", "../rrweb/src/record/workers/workers.d.ts"]
|
||||
"include": [
|
||||
"src",
|
||||
"../rrweb/src/record/workers/workers.d.ts",
|
||||
"../rrweb/src/record/constructable-stylesheets.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -83,12 +83,18 @@ export function exitFullscreen(): Promise<void> {
|
||||
}
|
||||
|
||||
export function isFullscreen(): boolean {
|
||||
return (
|
||||
document.fullscreen ||
|
||||
document.webkitIsFullScreen ||
|
||||
document.mozFullScreen ||
|
||||
document.msFullscreenElement
|
||||
);
|
||||
let fullscreen = false;
|
||||
[
|
||||
'fullscreen',
|
||||
'webkitIsFullScreen',
|
||||
'mozFullScreen',
|
||||
'msFullscreenElement',
|
||||
].forEach((fullScreenAccessor) => {
|
||||
if (fullScreenAccessor in document) {
|
||||
fullscreen = fullscreen || Boolean(document[fullScreenAccessor]);
|
||||
}
|
||||
});
|
||||
return fullscreen;
|
||||
}
|
||||
|
||||
export function onFullscreenChange(handler: () => unknown): () => void {
|
||||
|
||||
@@ -364,11 +364,9 @@ export function buildNodeWithSN(
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (n.rootId) {
|
||||
console.assert(
|
||||
(mirror.getNode(n.rootId) as Document) === doc,
|
||||
'Target document should have the same root id.',
|
||||
);
|
||||
// If the snapshot is created by checkout, the rootId doesn't change but the iframe's document can be changed automatically when a new iframe element is created.
|
||||
if (n.rootId && (mirror.getNode(n.rootId) as Document) !== doc) {
|
||||
mirror.replace(n.rootId, doc);
|
||||
}
|
||||
// use target document as root document
|
||||
if (n.type === NodeType.Document) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
isShadowRoot,
|
||||
maskInputValue,
|
||||
isNativeShadowDom,
|
||||
getCssRulesString,
|
||||
} from './utils';
|
||||
|
||||
let _id = 1;
|
||||
@@ -47,31 +48,6 @@ function getValidTagName(element: HTMLElement): string {
|
||||
return processedTagName;
|
||||
}
|
||||
|
||||
function getCssRulesString(s: CSSStyleSheet): string | null {
|
||||
try {
|
||||
const rules = s.rules || s.cssRules;
|
||||
return rules ? Array.from(rules).map(getCssRuleString).join('') : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCssRuleString(rule: CSSRule): string {
|
||||
let cssStringified = rule.cssText;
|
||||
if (isCSSImportRule(rule)) {
|
||||
try {
|
||||
cssStringified = getCssRulesString(rule.styleSheet) || cssStringified;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return cssStringified;
|
||||
}
|
||||
|
||||
function isCSSImportRule(rule: CSSRule): rule is CSSImportRule {
|
||||
return 'styleSheet' in rule;
|
||||
}
|
||||
|
||||
function stringifyStyleSheet(sheet: CSSStyleSheet): string {
|
||||
return sheet.cssRules
|
||||
? Array.from(sheet.cssRules)
|
||||
|
||||
@@ -24,6 +24,31 @@ export function isNativeShadowDom(shadowRoot: ShadowRoot) {
|
||||
return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]';
|
||||
}
|
||||
|
||||
export function getCssRulesString(s: CSSStyleSheet): string | null {
|
||||
try {
|
||||
const rules = s.rules || s.cssRules;
|
||||
return rules ? Array.from(rules).map(getCssRuleString).join('') : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCssRuleString(rule: CSSRule): string {
|
||||
let cssStringified = rule.cssText;
|
||||
if (isCSSImportRule(rule)) {
|
||||
try {
|
||||
cssStringified = getCssRulesString(rule.styleSheet) || cssStringified;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return cssStringified;
|
||||
}
|
||||
|
||||
export function isCSSImportRule(rule: CSSRule): rule is CSSImportRule {
|
||||
return 'styleSheet' in rule;
|
||||
}
|
||||
|
||||
export class Mirror implements IMirror<Node> {
|
||||
private idNodeMap: idNodeMap = new Map();
|
||||
private nodeMetaMap: nodeMetaMap = new WeakMap();
|
||||
@@ -76,6 +101,11 @@ export class Mirror implements IMirror<Node> {
|
||||
}
|
||||
|
||||
replace(id: number, n: Node) {
|
||||
const oldNode = this.getNode(id);
|
||||
if (oldNode) {
|
||||
const meta = this.nodeMetaMap.get(oldNode);
|
||||
if (meta) this.nodeMetaMap.set(n, meta);
|
||||
}
|
||||
this.idNodeMap.set(id, n);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/prettier": "^2.3.2",
|
||||
"@types/puppeteer": "^5.4.4",
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"esbuild": "^0.14.38",
|
||||
"fast-mhtml": "^1.1.9",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
/// <reference types="node" />
|
||||
declare module 'simple-peer-light' {
|
||||
import * as stream from 'stream';
|
||||
|
||||
10
packages/rrweb/src/record/constructable-stylesheets.d.ts
vendored
Normal file
10
packages/rrweb/src/record/constructable-stylesheets.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// This informs the TS compiler about constructed stylesheets.
|
||||
// It can be removed when this is fixed: https://github.com/Microsoft/TypeScript/issues/30022
|
||||
declare interface DocumentOrShadowRoot {
|
||||
adoptedStyleSheets?: CSSStyleSheet[];
|
||||
}
|
||||
|
||||
declare interface CSSStyleSheet {
|
||||
replace?(text: string): Promise<CSSStyleSheet>;
|
||||
replaceSync?(text: string): void;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
|
||||
import type { mutationCallBack } from '../types';
|
||||
import type { StylesheetManager } from './stylesheet-manager';
|
||||
|
||||
export class IframeManager {
|
||||
private iframes: WeakMap<HTMLIFrameElement, true> = new WeakMap();
|
||||
private mutationCb: mutationCallBack;
|
||||
private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
|
||||
private stylesheetManager: StylesheetManager;
|
||||
|
||||
constructor(options: { mutationCb: mutationCallBack }) {
|
||||
constructor(options: {
|
||||
mutationCb: mutationCallBack;
|
||||
stylesheetManager: StylesheetManager;
|
||||
}) {
|
||||
this.mutationCb = options.mutationCb;
|
||||
this.stylesheetManager = options.stylesheetManager;
|
||||
}
|
||||
|
||||
public addIframe(iframeEl: HTMLIFrameElement) {
|
||||
@@ -37,5 +43,15 @@ export class IframeManager {
|
||||
isAttachIframe: true,
|
||||
});
|
||||
this.loadListener?.(iframeEl);
|
||||
|
||||
if (
|
||||
iframeEl.contentDocument &&
|
||||
iframeEl.contentDocument.adoptedStyleSheets &&
|
||||
iframeEl.contentDocument.adoptedStyleSheets.length > 0
|
||||
)
|
||||
this.stylesheetManager.adoptStyleSheets(
|
||||
iframeEl.contentDocument.adoptedStyleSheets,
|
||||
mirror.getId(iframeEl.contentDocument),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
mutationCallbackParam,
|
||||
scrollCallback,
|
||||
canvasMutationParam,
|
||||
adoptedStyleSheetParam,
|
||||
} from '../types';
|
||||
import { IframeManager } from './iframe-manager';
|
||||
import { ShadowDomManager } from './shadow-dom-manager';
|
||||
@@ -225,12 +226,25 @@ function record<T = eventWithTime>(
|
||||
}),
|
||||
);
|
||||
|
||||
const iframeManager = new IframeManager({
|
||||
mutationCb: wrappedMutationEmit,
|
||||
});
|
||||
const wrappedAdoptedStyleSheetEmit = (a: adoptedStyleSheetParam) =>
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
...a,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const stylesheetManager = new StylesheetManager({
|
||||
mutationCb: wrappedMutationEmit,
|
||||
adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit,
|
||||
});
|
||||
|
||||
const iframeManager = new IframeManager({
|
||||
mutationCb: wrappedMutationEmit,
|
||||
stylesheetManager: stylesheetManager,
|
||||
});
|
||||
|
||||
canvasManager = new CanvasManager({
|
||||
@@ -282,6 +296,9 @@ function record<T = eventWithTime>(
|
||||
isCheckout,
|
||||
);
|
||||
|
||||
// When we take a full snapshot, old tracked StyleSheets need to be removed.
|
||||
stylesheetManager.reset();
|
||||
|
||||
mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting
|
||||
const node = snapshot(document, {
|
||||
mirror,
|
||||
@@ -301,7 +318,7 @@ function record<T = eventWithTime>(
|
||||
iframeManager.addIframe(n as HTMLIFrameElement);
|
||||
}
|
||||
if (isSerializedStylesheet(n, mirror)) {
|
||||
stylesheetManager.addStylesheet(n as HTMLLinkElement);
|
||||
stylesheetManager.trackLinkElement(n as HTMLLinkElement);
|
||||
}
|
||||
if (hasShadowRoot(n)) {
|
||||
shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
||||
@@ -312,7 +329,7 @@ function record<T = eventWithTime>(
|
||||
shadowDomManager.observeAttachShadow(iframe);
|
||||
},
|
||||
onStylesheetLoad: (linkEl, childSn) => {
|
||||
stylesheetManager.attachStylesheet(linkEl, childSn, mirror);
|
||||
stylesheetManager.attachLinkElement(linkEl, childSn, mirror);
|
||||
},
|
||||
keepIframeSrcFn,
|
||||
});
|
||||
@@ -332,20 +349,27 @@ function record<T = eventWithTime>(
|
||||
? window.pageXOffset
|
||||
: document?.documentElement.scrollLeft ||
|
||||
document?.body?.parentElement?.scrollLeft ||
|
||||
document?.body.scrollLeft ||
|
||||
document?.body?.scrollLeft ||
|
||||
0,
|
||||
top:
|
||||
window.pageYOffset !== undefined
|
||||
? window.pageYOffset
|
||||
: document?.documentElement.scrollTop ||
|
||||
document?.body?.parentElement?.scrollTop ||
|
||||
document?.body.scrollTop ||
|
||||
document?.body?.scrollTop ||
|
||||
0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror
|
||||
|
||||
// Some old browsers don't support adoptedStyleSheets.
|
||||
if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0)
|
||||
stylesheetManager.adoptStyleSheets(
|
||||
document.adoptedStyleSheets,
|
||||
mirror.getId(document),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -311,7 +311,9 @@ export default class MutationBuffer {
|
||||
this.iframeManager.addIframe(currentN as HTMLIFrameElement);
|
||||
}
|
||||
if (isSerializedStylesheet(currentN, this.mirror)) {
|
||||
this.stylesheetManager.addStylesheet(currentN as HTMLLinkElement);
|
||||
this.stylesheetManager.trackLinkElement(
|
||||
currentN as HTMLLinkElement,
|
||||
);
|
||||
}
|
||||
if (hasShadowRoot(n)) {
|
||||
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
|
||||
@@ -322,7 +324,7 @@ export default class MutationBuffer {
|
||||
this.shadowDomManager.observeAttachShadow(iframe);
|
||||
},
|
||||
onStylesheetLoad: (link, childSn) => {
|
||||
this.stylesheetManager.attachStylesheet(link, childSn, this.mirror);
|
||||
this.stylesheetManager.attachLinkElement(link, childSn, this.mirror);
|
||||
},
|
||||
});
|
||||
if (sn) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaskInputOptions, maskInputValue } from 'rrweb-snapshot';
|
||||
import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot';
|
||||
import type { FontFaceSet } from 'css-font-loading-module';
|
||||
import {
|
||||
throttle,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isBlocked,
|
||||
isTouchEvent,
|
||||
patch,
|
||||
StyleSheetMirror,
|
||||
} from '../utils';
|
||||
import {
|
||||
mutationCallBack,
|
||||
@@ -499,8 +500,8 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] {
|
||||
);
|
||||
const index = rules.indexOf(childRule);
|
||||
pos.unshift(index);
|
||||
} else {
|
||||
const rules = Array.from(childRule.parentStyleSheet!.cssRules);
|
||||
} else if (childRule.parentStyleSheet) {
|
||||
const rules = Array.from(childRule.parentStyleSheet.cssRules);
|
||||
const index = rules.indexOf(childRule);
|
||||
pos.unshift(index);
|
||||
}
|
||||
@@ -509,8 +510,30 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] {
|
||||
return recurse(rule, positions);
|
||||
}
|
||||
|
||||
/**
|
||||
* For StyleSheets in Element, this function retrieves id of its host element.
|
||||
* For adopted StyleSheets, this function retrieves its styleId from a styleMirror.
|
||||
*/
|
||||
function getIdAndStyleId(
|
||||
sheet: CSSStyleSheet | undefined | null,
|
||||
mirror: Mirror,
|
||||
styleMirror: StyleSheetMirror,
|
||||
): {
|
||||
styleId?: number;
|
||||
id?: number;
|
||||
} {
|
||||
let id, styleId;
|
||||
if (!sheet) return {};
|
||||
if (sheet.ownerNode) id = mirror.getId(sheet.ownerNode as Node);
|
||||
else styleId = styleMirror.getId(sheet);
|
||||
return {
|
||||
styleId,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
function initStyleSheetObserver(
|
||||
{ styleSheetRuleCb, mirror }: observerParam,
|
||||
{ styleSheetRuleCb, mirror, stylesheetManager }: observerParam,
|
||||
{ win }: { win: IWindow },
|
||||
): listenerHandler {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
@@ -520,10 +543,16 @@ function initStyleSheetObserver(
|
||||
rule: string,
|
||||
index?: number,
|
||||
) {
|
||||
const id = mirror.getId(this.ownerNode as Node);
|
||||
if (id !== -1) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
adds: [{ rule, index }],
|
||||
});
|
||||
}
|
||||
@@ -536,16 +565,72 @@ function initStyleSheetObserver(
|
||||
this: CSSStyleSheet,
|
||||
index: number,
|
||||
) {
|
||||
const id = mirror.getId(this.ownerNode as Node);
|
||||
if (id !== -1) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
removes: [{ index }],
|
||||
});
|
||||
}
|
||||
return deleteRule.apply(this, [index]);
|
||||
};
|
||||
|
||||
let replace: (text: string) => Promise<CSSStyleSheet>;
|
||||
if (win.CSSStyleSheet.prototype.replace) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
replace = win.CSSStyleSheet.prototype.replace;
|
||||
win.CSSStyleSheet.prototype.replace = function (
|
||||
this: CSSStyleSheet,
|
||||
text: string,
|
||||
) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
replace: text,
|
||||
});
|
||||
}
|
||||
return replace.apply(this, [text]);
|
||||
};
|
||||
}
|
||||
|
||||
let replaceSync: (text: string) => void;
|
||||
if (win.CSSStyleSheet.prototype.replaceSync) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
replaceSync = win.CSSStyleSheet.prototype.replaceSync;
|
||||
win.CSSStyleSheet.prototype.replaceSync = function (
|
||||
this: CSSStyleSheet,
|
||||
text: string,
|
||||
) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
replaceSync: text,
|
||||
});
|
||||
}
|
||||
return replaceSync.apply(this, [text]);
|
||||
};
|
||||
}
|
||||
|
||||
const supportedNestedCSSRuleTypes: {
|
||||
[key: string]: GroupingCSSRuleTypes;
|
||||
} = {};
|
||||
@@ -582,12 +667,21 @@ function initStyleSheetObserver(
|
||||
deleteRule: type.prototype.deleteRule,
|
||||
};
|
||||
|
||||
type.prototype.insertRule = function (rule: string, index?: number) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const id = mirror.getId(this.parentStyleSheet.ownerNode as Node);
|
||||
if (id !== -1) {
|
||||
type.prototype.insertRule = function (
|
||||
this: CSSGroupingRule,
|
||||
rule: string,
|
||||
index?: number,
|
||||
) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
adds: [
|
||||
{
|
||||
rule,
|
||||
@@ -602,12 +696,20 @@ function initStyleSheetObserver(
|
||||
return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]);
|
||||
};
|
||||
|
||||
type.prototype.deleteRule = function (index: number) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const id = mirror.getId(this.parentStyleSheet.ownerNode as Node);
|
||||
if (id !== -1) {
|
||||
type.prototype.deleteRule = function (
|
||||
this: CSSGroupingRule,
|
||||
index: number,
|
||||
) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleSheetRuleCb({
|
||||
id,
|
||||
styleId,
|
||||
removes: [
|
||||
{ index: [...getNestedCSSRulePositions(this as CSSRule), index] },
|
||||
],
|
||||
@@ -620,6 +722,8 @@ function initStyleSheetObserver(
|
||||
return () => {
|
||||
win.CSSStyleSheet.prototype.insertRule = insertRule;
|
||||
win.CSSStyleSheet.prototype.deleteRule = deleteRule;
|
||||
replace && (win.CSSStyleSheet.prototype.replace = replace);
|
||||
replaceSync && (win.CSSStyleSheet.prototype.replaceSync = replaceSync);
|
||||
Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => {
|
||||
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
|
||||
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
|
||||
@@ -627,8 +731,76 @@ function initStyleSheetObserver(
|
||||
};
|
||||
}
|
||||
|
||||
export function initAdoptedStyleSheetObserver(
|
||||
{
|
||||
mirror,
|
||||
stylesheetManager,
|
||||
}: Pick<observerParam, 'mirror' | 'stylesheetManager'>,
|
||||
host: Document | ShadowRoot,
|
||||
): listenerHandler {
|
||||
let hostId: number | null = null;
|
||||
// host of adoptedStyleSheets is outermost document or IFrame's document
|
||||
if (host.nodeName === '#document') hostId = mirror.getId(host);
|
||||
// The host is a ShadowRoot.
|
||||
else hostId = mirror.getId((host as ShadowRoot).host);
|
||||
|
||||
const patchTarget =
|
||||
host.nodeName === '#document'
|
||||
? (host as Document).defaultView?.Document
|
||||
: host.ownerDocument?.defaultView?.ShadowRoot;
|
||||
const originalPropertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
patchTarget?.prototype,
|
||||
'adoptedStyleSheets',
|
||||
);
|
||||
if (
|
||||
hostId === null ||
|
||||
hostId === -1 ||
|
||||
!patchTarget ||
|
||||
!originalPropertyDescriptor
|
||||
)
|
||||
return () => {
|
||||
//
|
||||
};
|
||||
|
||||
// Patch adoptedStyleSheets by overriding the original one.
|
||||
Object.defineProperty(host, 'adoptedStyleSheets', {
|
||||
configurable: originalPropertyDescriptor.configurable,
|
||||
enumerable: originalPropertyDescriptor.enumerable,
|
||||
get(): CSSStyleSheet[] {
|
||||
return originalPropertyDescriptor.get?.call(this) as CSSStyleSheet[];
|
||||
},
|
||||
set(sheets: CSSStyleSheet[]) {
|
||||
const result = originalPropertyDescriptor.set?.call(this, sheets);
|
||||
if (hostId !== null && hostId !== -1) {
|
||||
try {
|
||||
stylesheetManager.adoptStyleSheets(sheets, hostId);
|
||||
} catch (e) {
|
||||
// for safety
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(host, 'adoptedStyleSheets', {
|
||||
configurable: originalPropertyDescriptor.configurable,
|
||||
enumerable: originalPropertyDescriptor.enumerable,
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
get: originalPropertyDescriptor.get,
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
set: originalPropertyDescriptor.set,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function initStyleDeclarationObserver(
|
||||
{ styleDeclarationCb, mirror, ignoreCSSAttributes }: observerParam,
|
||||
{
|
||||
styleDeclarationCb,
|
||||
mirror,
|
||||
ignoreCSSAttributes,
|
||||
stylesheetManager,
|
||||
}: observerParam,
|
||||
{ win }: { win: IWindow },
|
||||
): listenerHandler {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
@@ -643,15 +815,21 @@ function initStyleDeclarationObserver(
|
||||
if (ignoreCSSAttributes.has(property)) {
|
||||
return setProperty.apply(this, [property, value, priority]);
|
||||
}
|
||||
const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode);
|
||||
if (id !== -1) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentRule?.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleDeclarationCb({
|
||||
id,
|
||||
styleId,
|
||||
set: {
|
||||
property,
|
||||
value,
|
||||
priority,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
index: getNestedCSSRulePositions(this.parentRule!),
|
||||
});
|
||||
}
|
||||
@@ -668,13 +846,19 @@ function initStyleDeclarationObserver(
|
||||
if (ignoreCSSAttributes.has(property)) {
|
||||
return removeProperty.apply(this, [property]);
|
||||
}
|
||||
const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode);
|
||||
if (id !== -1) {
|
||||
const { id, styleId } = getIdAndStyleId(
|
||||
this.parentRule?.parentStyleSheet,
|
||||
mirror,
|
||||
stylesheetManager.styleMirror,
|
||||
);
|
||||
if ((id && id !== -1) || (styleId && styleId !== -1)) {
|
||||
styleDeclarationCb({
|
||||
id,
|
||||
styleId,
|
||||
remove: {
|
||||
property,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
index: getNestedCSSRulePositions(this.parentRule!),
|
||||
});
|
||||
}
|
||||
@@ -939,6 +1123,7 @@ export function initObservers(
|
||||
const mediaInteractionHandler = initMediaInteractionObserver(o);
|
||||
|
||||
const styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow });
|
||||
const adoptedStyleSheetObserver = initAdoptedStyleSheetObserver(o, o.doc);
|
||||
const styleDeclarationObserver = initStyleDeclarationObserver(o, {
|
||||
win: currentWindow,
|
||||
});
|
||||
@@ -967,6 +1152,7 @@ export function initObservers(
|
||||
inputHandler();
|
||||
mediaInteractionHandler();
|
||||
styleSheetObserver();
|
||||
adoptedStyleSheetObserver();
|
||||
styleDeclarationObserver();
|
||||
fontObserver();
|
||||
selectionObserver();
|
||||
|
||||
@@ -14,7 +14,6 @@ export default function initCanvas2DMutationObserver(
|
||||
win: IWindow,
|
||||
blockClass: blockClass,
|
||||
blockSelector: string | null,
|
||||
mirror: Mirror,
|
||||
): listenerHandler {
|
||||
const handlers: listenerHandler[] = [];
|
||||
const props2D = Object.getOwnPropertyNames(
|
||||
|
||||
@@ -243,7 +243,6 @@ export class CanvasManager {
|
||||
win,
|
||||
blockClass,
|
||||
blockSelector,
|
||||
this.mirror,
|
||||
);
|
||||
|
||||
const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver(
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
MutationBufferParam,
|
||||
SamplingStrategy,
|
||||
} from '../types';
|
||||
import { initMutationObserver, initScrollObserver } from './observer';
|
||||
import {
|
||||
initMutationObserver,
|
||||
initScrollObserver,
|
||||
initAdoptedStyleSheetObserver,
|
||||
} from './observer';
|
||||
import { patch } from '../utils';
|
||||
import type { Mirror } from 'rrweb-snapshot';
|
||||
import { isNativeShadowDom } from 'rrweb-snapshot';
|
||||
@@ -76,6 +80,24 @@ export class ShadowDomManager {
|
||||
doc: (shadowRoot as unknown) as Document,
|
||||
mirror: this.mirror,
|
||||
});
|
||||
// Defer this to avoid adoptedStyleSheet events being created before the full snapshot is created or attachShadow action is recorded.
|
||||
setTimeout(() => {
|
||||
if (
|
||||
shadowRoot.adoptedStyleSheets &&
|
||||
shadowRoot.adoptedStyleSheets.length > 0
|
||||
)
|
||||
this.bypassOptions.stylesheetManager.adoptStyleSheets(
|
||||
shadowRoot.adoptedStyleSheets,
|
||||
this.mirror.getId(shadowRoot.host),
|
||||
);
|
||||
initAdoptedStyleSheetObserver(
|
||||
{
|
||||
mirror: this.mirror,
|
||||
stylesheetManager: this.bypassOptions.stylesheetManager,
|
||||
},
|
||||
shadowRoot,
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
|
||||
import type { mutationCallBack } from '../types';
|
||||
import { getCssRuleString } from 'rrweb-snapshot';
|
||||
import type {
|
||||
adoptedStyleSheetCallback,
|
||||
adoptedStyleSheetParam,
|
||||
mutationCallBack,
|
||||
} from '../types';
|
||||
import { StyleSheetMirror } from '../utils';
|
||||
|
||||
export class StylesheetManager {
|
||||
private trackedStylesheets: WeakSet<HTMLLinkElement> = new WeakSet();
|
||||
private trackedLinkElements: WeakSet<HTMLLinkElement> = new WeakSet();
|
||||
private mutationCb: mutationCallBack;
|
||||
private adoptedStyleSheetCb: adoptedStyleSheetCallback;
|
||||
public styleMirror = new StyleSheetMirror();
|
||||
|
||||
constructor(options: { mutationCb: mutationCallBack }) {
|
||||
constructor(options: {
|
||||
mutationCb: mutationCallBack;
|
||||
adoptedStyleSheetCb: adoptedStyleSheetCallback;
|
||||
}) {
|
||||
this.mutationCb = options.mutationCb;
|
||||
this.adoptedStyleSheetCb = options.adoptedStyleSheetCb;
|
||||
}
|
||||
|
||||
public addStylesheet(linkEl: HTMLLinkElement) {
|
||||
if (this.trackedStylesheets.has(linkEl)) return;
|
||||
|
||||
this.trackedStylesheets.add(linkEl);
|
||||
this.trackStylesheet(linkEl);
|
||||
}
|
||||
|
||||
// TODO: take snapshot on stylesheet reload by applying event listener
|
||||
private trackStylesheet(linkEl: HTMLLinkElement) {
|
||||
// linkEl.addEventListener('load', () => {
|
||||
// // re-loaded, maybe take another snapshot?
|
||||
// });
|
||||
}
|
||||
|
||||
public attachStylesheet(
|
||||
public attachLinkElement(
|
||||
linkEl: HTMLLinkElement,
|
||||
childSn: serializedNodeWithId,
|
||||
mirror: Mirror,
|
||||
@@ -40,6 +38,54 @@ export class StylesheetManager {
|
||||
texts: [],
|
||||
attributes: [],
|
||||
});
|
||||
this.addStylesheet(linkEl);
|
||||
|
||||
this.trackLinkElement(linkEl);
|
||||
}
|
||||
|
||||
public trackLinkElement(linkEl: HTMLLinkElement) {
|
||||
if (this.trackedLinkElements.has(linkEl)) return;
|
||||
|
||||
this.trackedLinkElements.add(linkEl);
|
||||
this.trackStylesheetInLinkElement(linkEl);
|
||||
}
|
||||
|
||||
public adoptStyleSheets(sheets: CSSStyleSheet[], hostId: number) {
|
||||
if (sheets.length === 0) return;
|
||||
const adoptedStyleSheetData: adoptedStyleSheetParam = {
|
||||
id: hostId,
|
||||
styleIds: [] as number[],
|
||||
};
|
||||
const styles: NonNullable<adoptedStyleSheetParam['styles']> = [];
|
||||
for (const sheet of sheets) {
|
||||
let styleId;
|
||||
if (!this.styleMirror.has(sheet)) {
|
||||
styleId = this.styleMirror.add(sheet);
|
||||
const rules = Array.from(sheet.rules || CSSRule);
|
||||
styles.push({
|
||||
styleId,
|
||||
rules: rules.map((r, index) => {
|
||||
return {
|
||||
rule: getCssRuleString(r),
|
||||
index,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} else styleId = this.styleMirror.getId(sheet);
|
||||
adoptedStyleSheetData.styleIds.push(styleId);
|
||||
}
|
||||
if (styles.length > 0) adoptedStyleSheetData.styles = styles;
|
||||
this.adoptedStyleSheetCb(adoptedStyleSheetData);
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.styleMirror.reset();
|
||||
this.trackedLinkElements = new WeakSet();
|
||||
}
|
||||
|
||||
// TODO: take snapshot on stylesheet reload by applying event listener
|
||||
private trackStylesheetInLinkElement(linkEl: HTMLLinkElement) {
|
||||
// linkEl.addEventListener('load', () => {
|
||||
// // re-loaded, maybe take another snapshot?
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,18 @@ async function getTransparentBlobFor(
|
||||
dataURLOptions: DataURLOptions,
|
||||
): Promise<string> {
|
||||
const id = `${width}-${height}`;
|
||||
if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!;
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
offscreen.getContext('2d'); // creates rendering context for `converToBlob`
|
||||
const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = encode(arrayBuffer); // cpu intensive
|
||||
transparentBlobMap.set(id, base64);
|
||||
return base64;
|
||||
if ('OffscreenCanvas' in globalThis) {
|
||||
if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!;
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
offscreen.getContext('2d'); // creates rendering context for `converToBlob`
|
||||
const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = encode(arrayBuffer); // cpu intensive
|
||||
transparentBlobMap.set(id, base64);
|
||||
return base64;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// `as any` because: https://github.com/Microsoft/TypeScript/issues/20595
|
||||
@@ -44,41 +48,42 @@ const worker: ImageBitmapDataURLResponseWorker = self;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
worker.onmessage = async function (e) {
|
||||
if (!('OffscreenCanvas' in globalThis))
|
||||
return worker.postMessage({ id: e.data.id });
|
||||
if ('OffscreenCanvas' in globalThis) {
|
||||
const { id, bitmap, width, height, dataURLOptions } = e.data;
|
||||
|
||||
const { id, bitmap, width, height, dataURLOptions } = e.data;
|
||||
const transparentBase64 = getTransparentBlobFor(
|
||||
width,
|
||||
height,
|
||||
dataURLOptions,
|
||||
);
|
||||
|
||||
const transparentBase64 = getTransparentBlobFor(
|
||||
width,
|
||||
height,
|
||||
dataURLOptions,
|
||||
);
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
const ctx = offscreen.getContext('2d')!;
|
||||
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
const ctx = offscreen.getContext('2d')!;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while
|
||||
const type = blob.type;
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = encode(arrayBuffer); // cpu intensive
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
const blob = await offscreen.convertToBlob(dataURLOptions); // takes a while
|
||||
const type = blob.type;
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = encode(arrayBuffer); // cpu intensive
|
||||
// on first try we should check if canvas is transparent,
|
||||
// no need to save it's contents in that case
|
||||
if (!lastBlobMap.has(id) && (await transparentBase64) === base64) {
|
||||
lastBlobMap.set(id, base64);
|
||||
return worker.postMessage({ id });
|
||||
}
|
||||
|
||||
// on first try we should check if canvas is transparent,
|
||||
// no need to save it's contents in that case
|
||||
if (!lastBlobMap.has(id) && (await transparentBase64) === base64) {
|
||||
if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged
|
||||
worker.postMessage({
|
||||
id,
|
||||
type,
|
||||
base64,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
lastBlobMap.set(id, base64);
|
||||
return worker.postMessage({ id });
|
||||
} else {
|
||||
return worker.postMessage({ id: e.data.id });
|
||||
}
|
||||
|
||||
if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged
|
||||
worker.postMessage({
|
||||
id,
|
||||
type,
|
||||
base64,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
lastBlobMap.set(id, base64);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'rrweb-snapshot';
|
||||
import {
|
||||
RRDocument,
|
||||
StyleRuleType,
|
||||
createOrGetNode,
|
||||
buildFromNode,
|
||||
buildFromDom,
|
||||
@@ -25,7 +24,6 @@ import type {
|
||||
RRCanvasElement,
|
||||
ReplayerHandler,
|
||||
Mirror as RRDOMMirror,
|
||||
VirtualStyleRules,
|
||||
} from 'rrdom';
|
||||
import * as mittProxy from 'mitt';
|
||||
import { polyfill as smoothscrollPolyfill } from './smoothscroll';
|
||||
@@ -60,6 +58,9 @@ import {
|
||||
canvasMutationParam,
|
||||
canvasEventWithTime,
|
||||
selectionData,
|
||||
styleSheetRuleData,
|
||||
styleDeclarationData,
|
||||
adoptedStyleSheetData,
|
||||
} from '../types';
|
||||
import {
|
||||
polyfill,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
getNestedRule,
|
||||
getPositionsAndIndex,
|
||||
uniqueTextMutations,
|
||||
StyleSheetMirror,
|
||||
} from '../utils';
|
||||
import getInjectStyleRules from './styles/inject-style';
|
||||
import './styles/style.css';
|
||||
@@ -136,6 +138,9 @@ export class Replayer {
|
||||
|
||||
private mirror: Mirror = createMirror();
|
||||
|
||||
// Used to track StyleSheetObjects adopted on multiple document hosts.
|
||||
private styleMirror: StyleSheetMirror = new StyleSheetMirror();
|
||||
|
||||
private firstFullSnapshot: eventWithTime | true | null = null;
|
||||
|
||||
private newDocumentQueue: addedNodeMutation[] = [];
|
||||
@@ -146,6 +151,15 @@ export class Replayer {
|
||||
// In the fast-forward mode, only the last selection data needs to be applied.
|
||||
private lastSelectionData: selectionData | null = null;
|
||||
|
||||
// In the fast-forward mode using VirtualDom optimization, all stylesheetRule, and styleDeclaration events on constructed StyleSheets will be delayed to get applied until the flush stage.
|
||||
private constructedStyleMutations: (
|
||||
| styleSheetRuleData
|
||||
| styleDeclarationData
|
||||
)[] = [];
|
||||
|
||||
// Similar to the reason for constructedStyleMutations.
|
||||
private adoptedStyleSheets: adoptedStyleSheetData[] = [];
|
||||
|
||||
constructor(
|
||||
events: Array<eventWithTime | string>,
|
||||
config?: Partial<playerConfig>,
|
||||
@@ -206,13 +220,23 @@ export class Replayer {
|
||||
},
|
||||
applyInput: this.applyInput.bind(this),
|
||||
applyScroll: this.applyScroll.bind(this),
|
||||
applyStyleSheetMutation: (
|
||||
data: styleDeclarationData | styleSheetRuleData,
|
||||
styleSheet: CSSStyleSheet,
|
||||
) => {
|
||||
if (data.source === IncrementalSource.StyleSheetRule)
|
||||
this.applyStyleSheetRule(data, styleSheet);
|
||||
else if (data.source === IncrementalSource.StyleDeclaration)
|
||||
this.applyStyleDeclaration(data, styleSheet);
|
||||
},
|
||||
};
|
||||
diff(
|
||||
this.iframe.contentDocument!,
|
||||
this.virtualDom,
|
||||
replayerHandler,
|
||||
this.virtualDom.mirror,
|
||||
);
|
||||
this.iframe.contentDocument &&
|
||||
diff(
|
||||
this.iframe.contentDocument,
|
||||
this.virtualDom,
|
||||
replayerHandler,
|
||||
this.virtualDom.mirror,
|
||||
);
|
||||
this.virtualDom.destroyTree();
|
||||
this.usingVirtualDom = false;
|
||||
|
||||
@@ -240,6 +264,16 @@ export class Replayer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.constructedStyleMutations.forEach((data) => {
|
||||
this.applyStyleSheetMutation(data);
|
||||
});
|
||||
this.constructedStyleMutations = [];
|
||||
|
||||
this.adoptedStyleSheets.forEach((data) => {
|
||||
this.applyAdoptedStyleSheet(data);
|
||||
});
|
||||
this.adoptedStyleSheets = [];
|
||||
}
|
||||
|
||||
if (this.mousePos) {
|
||||
@@ -260,6 +294,7 @@ export class Replayer {
|
||||
this.emitter.on(ReplayerEvents.PlayBack, () => {
|
||||
this.firstFullSnapshot = null;
|
||||
this.mirror.reset();
|
||||
this.styleMirror.reset();
|
||||
});
|
||||
|
||||
const timer = new Timer([], {
|
||||
@@ -611,6 +646,7 @@ export class Replayer {
|
||||
}
|
||||
this.rebuildFullSnapshot(event, isSync);
|
||||
this.iframe.contentWindow?.scrollTo(event.data.initialOffset);
|
||||
this.styleMirror.reset();
|
||||
};
|
||||
break;
|
||||
case EventType.IncrementalSnapshot:
|
||||
@@ -772,14 +808,13 @@ export class Replayer {
|
||||
getDefaultSN(styleEl, this.virtualDom.unserializedId),
|
||||
);
|
||||
(documentElement as RRElement).insertBefore(styleEl, head as RRElement);
|
||||
for (let idx = 0; idx < injectStylesRules.length; idx++) {
|
||||
// push virtual styles
|
||||
styleEl.rules.push({
|
||||
cssText: injectStylesRules[idx],
|
||||
type: StyleRuleType.Insert,
|
||||
index: idx,
|
||||
});
|
||||
}
|
||||
styleEl.rules.push({
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: injectStylesRules.map((cssText, index) => ({
|
||||
rule: cssText,
|
||||
index,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
const styleEl = document.createElement('style');
|
||||
(documentElement as HTMLElement).insertBefore(
|
||||
@@ -1204,121 +1239,15 @@ export class Replayer {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case IncrementalSource.StyleSheetRule: {
|
||||
if (this.usingVirtualDom) {
|
||||
const target = this.virtualDom.mirror.getNode(d.id) as RRStyleElement;
|
||||
if (!target) {
|
||||
return this.debugNodeNotFound(d, d.id);
|
||||
}
|
||||
const rules: VirtualStyleRules = target.rules;
|
||||
d.adds?.forEach(({ rule, index: nestedIndex }) =>
|
||||
rules?.push({
|
||||
cssText: rule,
|
||||
index: nestedIndex,
|
||||
type: StyleRuleType.Insert,
|
||||
}),
|
||||
);
|
||||
d.removes?.forEach(({ index: nestedIndex }) =>
|
||||
rules?.push({ index: nestedIndex, type: StyleRuleType.Remove }),
|
||||
);
|
||||
} else {
|
||||
const target = this.mirror.getNode(d.id);
|
||||
if (!target) {
|
||||
return this.debugNodeNotFound(d, d.id);
|
||||
}
|
||||
const styleSheet = (target as HTMLStyleElement).sheet!;
|
||||
d.adds?.forEach(({ rule, index: nestedIndex }) => {
|
||||
try {
|
||||
if (Array.isArray(nestedIndex)) {
|
||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||
const nestedRule = getNestedRule(
|
||||
styleSheet.cssRules,
|
||||
positions,
|
||||
);
|
||||
nestedRule.insertRule(rule, index);
|
||||
} else {
|
||||
const index =
|
||||
nestedIndex === undefined
|
||||
? undefined
|
||||
: Math.min(nestedIndex, styleSheet.cssRules.length);
|
||||
styleSheet.insertRule(rule, index);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* sometimes we may capture rules with browser prefix
|
||||
* insert rule with prefixs in other browsers may cause Error
|
||||
*/
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
});
|
||||
|
||||
d.removes?.forEach(({ index: nestedIndex }) => {
|
||||
try {
|
||||
if (Array.isArray(nestedIndex)) {
|
||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||
const nestedRule = getNestedRule(
|
||||
styleSheet.cssRules,
|
||||
positions,
|
||||
);
|
||||
nestedRule.deleteRule(index || 0);
|
||||
} else {
|
||||
styleSheet?.deleteRule(nestedIndex);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* same as insertRule
|
||||
*/
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case IncrementalSource.StyleSheetRule:
|
||||
case IncrementalSource.StyleDeclaration: {
|
||||
if (this.usingVirtualDom) {
|
||||
const target = this.virtualDom.mirror.getNode(d.id) as RRStyleElement;
|
||||
if (!target) {
|
||||
return this.debugNodeNotFound(d, d.id);
|
||||
}
|
||||
const rules: VirtualStyleRules = target.rules;
|
||||
d.set &&
|
||||
rules.push({
|
||||
type: StyleRuleType.SetProperty,
|
||||
index: d.index,
|
||||
...d.set,
|
||||
});
|
||||
d.remove &&
|
||||
rules.push({
|
||||
type: StyleRuleType.RemoveProperty,
|
||||
index: d.index,
|
||||
...d.remove,
|
||||
});
|
||||
} else {
|
||||
const target = (this.mirror.getNode(
|
||||
d.id,
|
||||
) as Node) as HTMLStyleElement;
|
||||
if (!target) {
|
||||
return this.debugNodeNotFound(d, d.id);
|
||||
}
|
||||
const styleSheet = target.sheet!;
|
||||
if (d.set) {
|
||||
const rule = (getNestedRule(
|
||||
styleSheet.rules,
|
||||
d.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
rule.style.setProperty(d.set.property, d.set.value, d.set.priority);
|
||||
}
|
||||
|
||||
if (d.remove) {
|
||||
const rule = (getNestedRule(
|
||||
styleSheet.rules,
|
||||
d.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
rule.style.removeProperty(d.remove.property);
|
||||
}
|
||||
}
|
||||
if (d.styleId) this.constructedStyleMutations.push(d);
|
||||
else if (d.id)
|
||||
(this.virtualDom.mirror.getNode(
|
||||
d.id,
|
||||
) as RRStyleElement | null)?.rules.push(d);
|
||||
} else this.applyStyleSheetMutation(d);
|
||||
break;
|
||||
}
|
||||
case IncrementalSource.CanvasMutation: {
|
||||
@@ -1377,6 +1306,11 @@ export class Replayer {
|
||||
this.applySelection(d);
|
||||
break;
|
||||
}
|
||||
case IncrementalSource.AdoptedStyleSheet: {
|
||||
if (this.usingVirtualDom) this.adoptedStyleSheets.push(d);
|
||||
else this.applyAdoptedStyleSheet(d);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -1823,6 +1757,166 @@ export class Replayer {
|
||||
}
|
||||
}
|
||||
|
||||
private applyStyleSheetMutation(
|
||||
data: styleDeclarationData | styleSheetRuleData,
|
||||
) {
|
||||
let styleSheet: CSSStyleSheet | null = null;
|
||||
if (data.styleId) styleSheet = this.styleMirror.getStyle(data.styleId);
|
||||
else if (data.id)
|
||||
styleSheet =
|
||||
(this.mirror.getNode(data.id) as HTMLStyleElement)?.sheet || null;
|
||||
if (!styleSheet) return;
|
||||
if (data.source === IncrementalSource.StyleSheetRule)
|
||||
this.applyStyleSheetRule(data, styleSheet);
|
||||
else if (data.source === IncrementalSource.StyleDeclaration)
|
||||
this.applyStyleDeclaration(data, styleSheet);
|
||||
}
|
||||
|
||||
private applyStyleSheetRule(
|
||||
data: styleSheetRuleData,
|
||||
styleSheet: CSSStyleSheet,
|
||||
) {
|
||||
data.adds?.forEach(({ rule, index: nestedIndex }) => {
|
||||
try {
|
||||
if (Array.isArray(nestedIndex)) {
|
||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
||||
nestedRule.insertRule(rule, index);
|
||||
} else {
|
||||
const index =
|
||||
nestedIndex === undefined
|
||||
? undefined
|
||||
: Math.min(nestedIndex, styleSheet.cssRules.length);
|
||||
styleSheet?.insertRule(rule, index);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* sometimes we may capture rules with browser prefix
|
||||
* insert rule with prefixs in other browsers may cause Error
|
||||
*/
|
||||
/**
|
||||
* accessing styleSheet rules may cause SecurityError
|
||||
* for specific access control settings
|
||||
*/
|
||||
}
|
||||
});
|
||||
|
||||
data.removes?.forEach(({ index: nestedIndex }) => {
|
||||
try {
|
||||
if (Array.isArray(nestedIndex)) {
|
||||
const { positions, index } = getPositionsAndIndex(nestedIndex);
|
||||
const nestedRule = getNestedRule(styleSheet.cssRules, positions);
|
||||
nestedRule.deleteRule(index || 0);
|
||||
} else {
|
||||
styleSheet?.deleteRule(nestedIndex);
|
||||
}
|
||||
} catch (e) {
|
||||
/**
|
||||
* same as insertRule
|
||||
*/
|
||||
}
|
||||
});
|
||||
|
||||
if (data.replace)
|
||||
try {
|
||||
void styleSheet.replace?.(data.replace);
|
||||
} catch (e) {
|
||||
// for safety
|
||||
}
|
||||
|
||||
if (data.replaceSync)
|
||||
try {
|
||||
styleSheet.replaceSync?.(data.replaceSync);
|
||||
} catch (e) {
|
||||
// for safety
|
||||
}
|
||||
}
|
||||
|
||||
private applyStyleDeclaration(
|
||||
data: styleDeclarationData,
|
||||
styleSheet: CSSStyleSheet,
|
||||
) {
|
||||
if (data.set) {
|
||||
const rule = (getNestedRule(
|
||||
styleSheet.rules,
|
||||
data.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
rule.style.setProperty(
|
||||
data.set.property,
|
||||
data.set.value,
|
||||
data.set.priority,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.remove) {
|
||||
const rule = (getNestedRule(
|
||||
styleSheet.rules,
|
||||
data.index,
|
||||
) as unknown) as CSSStyleRule;
|
||||
rule.style.removeProperty(data.remove.property);
|
||||
}
|
||||
}
|
||||
|
||||
private applyAdoptedStyleSheet(data: adoptedStyleSheetData) {
|
||||
const targetHost = this.mirror.getNode(data.id);
|
||||
if (!targetHost) return;
|
||||
// Create StyleSheet objects which will be adopted after.
|
||||
data.styles?.forEach((style) => {
|
||||
let newStyleSheet: CSSStyleSheet | null = null;
|
||||
/**
|
||||
* Constructed StyleSheet can't share across multiple documents.
|
||||
* The replayer has to get the correct host window to recreate a StyleSheetObject.
|
||||
*/
|
||||
let hostWindow: IWindow | null = null;
|
||||
if (hasShadowRoot(targetHost))
|
||||
hostWindow = targetHost.ownerDocument?.defaultView || null;
|
||||
else if (targetHost.nodeName === '#document')
|
||||
hostWindow = (targetHost as Document).defaultView;
|
||||
|
||||
if (!hostWindow) return;
|
||||
try {
|
||||
newStyleSheet = new hostWindow.CSSStyleSheet();
|
||||
this.styleMirror.add(newStyleSheet, style.styleId);
|
||||
// To reuse the code of applying stylesheet rules
|
||||
this.applyStyleSheetRule(
|
||||
{
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
adds: style.rules,
|
||||
},
|
||||
newStyleSheet,
|
||||
);
|
||||
} catch (e) {
|
||||
// In case some browsers don't support constructing StyleSheet.
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_RETRY_TIME = 10;
|
||||
let count = 0;
|
||||
const adoptStyleSheets = (targetHost: Node, styleIds: number[]) => {
|
||||
const stylesToAdopt = styleIds
|
||||
.map((styleId) => this.styleMirror.getStyle(styleId))
|
||||
.filter((style) => style !== null) as CSSStyleSheet[];
|
||||
if (hasShadowRoot(targetHost))
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
(targetHost as HTMLElement).shadowRoot!.adoptedStyleSheets = stylesToAdopt;
|
||||
else if (targetHost.nodeName === '#document')
|
||||
(targetHost as Document).adoptedStyleSheets = stylesToAdopt;
|
||||
|
||||
/**
|
||||
* In the live mode where events are transferred over network without strict order guarantee, some newer events are applied before some old events and adopted stylesheets may haven't been created.
|
||||
* This retry mechanism can help resolve this situation.
|
||||
*/
|
||||
if (stylesToAdopt.length !== styleIds.length && count < MAX_RETRY_TIME) {
|
||||
setTimeout(
|
||||
() => adoptStyleSheets(targetHost, styleIds),
|
||||
0 + 100 * count,
|
||||
);
|
||||
count++;
|
||||
}
|
||||
};
|
||||
adoptStyleSheets(targetHost, data.styleIds);
|
||||
}
|
||||
|
||||
private legacy_resolveMissingNode(
|
||||
map: missingNodeMap,
|
||||
parent: Node | RRNode,
|
||||
|
||||
@@ -93,6 +93,7 @@ export enum IncrementalSource {
|
||||
Drag,
|
||||
StyleDeclaration,
|
||||
Selection,
|
||||
AdoptedStyleSheet,
|
||||
}
|
||||
|
||||
export type mutationData = {
|
||||
@@ -148,6 +149,10 @@ export type selectionData = {
|
||||
source: IncrementalSource.Selection;
|
||||
} & selectionParam;
|
||||
|
||||
export type adoptedStyleSheetData = {
|
||||
source: IncrementalSource.AdoptedStyleSheet;
|
||||
} & adoptedStyleSheetParam;
|
||||
|
||||
export type incrementalData =
|
||||
| mutationData
|
||||
| mousemoveData
|
||||
@@ -160,7 +165,8 @@ export type incrementalData =
|
||||
| canvasMutationData
|
||||
| fontData
|
||||
| selectionData
|
||||
| styleDeclarationData;
|
||||
| styleDeclarationData
|
||||
| adoptedStyleSheetData;
|
||||
|
||||
export type event =
|
||||
| domContentLoadedEvent
|
||||
@@ -512,15 +518,33 @@ export type styleSheetDeleteRule = {
|
||||
};
|
||||
|
||||
export type styleSheetRuleParam = {
|
||||
id: number;
|
||||
id?: number;
|
||||
styleId?: number;
|
||||
removes?: styleSheetDeleteRule[];
|
||||
adds?: styleSheetAddRule[];
|
||||
replace?: string;
|
||||
replaceSync?: string;
|
||||
};
|
||||
|
||||
export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
|
||||
|
||||
export type styleDeclarationParam = {
|
||||
export type adoptedStyleSheetParam = {
|
||||
// id indicates the node id of document or shadow DOMs' host element.
|
||||
id: number;
|
||||
// New CSSStyleSheets which have never appeared before.
|
||||
styles?: {
|
||||
styleId: number;
|
||||
rules: styleSheetAddRule[];
|
||||
}[];
|
||||
// StyleSheet ids to be adopted.
|
||||
styleIds: number[];
|
||||
};
|
||||
|
||||
export type adoptedStyleSheetCallback = (a: adoptedStyleSheetParam) => void;
|
||||
|
||||
export type styleDeclarationParam = {
|
||||
id?: number;
|
||||
styleId?: number;
|
||||
index: number[];
|
||||
set?: {
|
||||
property: string;
|
||||
|
||||
@@ -454,3 +454,41 @@ export function uniqueTextMutations(mutations: textMutation[]): textMutation[] {
|
||||
|
||||
return uniqueMutations;
|
||||
}
|
||||
|
||||
export class StyleSheetMirror {
|
||||
private id = 1;
|
||||
private styleIDMap = new WeakMap<CSSStyleSheet, number>();
|
||||
private idStyleMap = new Map<number, CSSStyleSheet>();
|
||||
|
||||
getId(stylesheet: CSSStyleSheet): number {
|
||||
return this.styleIDMap.get(stylesheet) ?? -1;
|
||||
}
|
||||
|
||||
has(stylesheet: CSSStyleSheet): boolean {
|
||||
return this.styleIDMap.has(stylesheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns If the stylesheet is in the mirror, returns the id of the stylesheet. If not, return the new assigned id.
|
||||
*/
|
||||
add(stylesheet: CSSStyleSheet, id?: number): number {
|
||||
if (this.has(stylesheet)) return this.getId(stylesheet);
|
||||
let newId: number;
|
||||
if (id === undefined) {
|
||||
newId = this.id++;
|
||||
} else newId = id;
|
||||
this.styleIDMap.set(stylesheet, newId);
|
||||
this.idStyleMap.set(newId, stylesheet);
|
||||
return newId;
|
||||
}
|
||||
|
||||
getStyle(id: number): CSSStyleSheet | null {
|
||||
return this.idStyleMap.get(id) || null;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.styleIDMap = new WeakMap();
|
||||
this.idStyleMap = new Map();
|
||||
this.id = 1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
314
packages/rrweb/test/events/adopted-style-sheet-modification.ts
Normal file
314
packages/rrweb/test/events/adopted-style-sheet-modification.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
|
||||
|
||||
const now = Date.now();
|
||||
const events: eventWithTime[] = [
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'about:blank',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
timestamp: now,
|
||||
},
|
||||
{
|
||||
type: EventType.FullSnapshot,
|
||||
data: {
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{
|
||||
type: 1,
|
||||
name: 'html',
|
||||
publicId: '',
|
||||
systemId: '',
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 6,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'div in outermost document',
|
||||
id: 8,
|
||||
},
|
||||
],
|
||||
id: 7,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: ' \n ',
|
||||
id: 9,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'iframe',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 10,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 11,
|
||||
},
|
||||
],
|
||||
id: 5,
|
||||
},
|
||||
],
|
||||
id: 3,
|
||||
},
|
||||
],
|
||||
id: 1,
|
||||
},
|
||||
initialOffset: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 1,
|
||||
styleIds: [1],
|
||||
styles: [
|
||||
{
|
||||
styleId: 1,
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 150,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: 0,
|
||||
adds: [
|
||||
{
|
||||
parentId: 10,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
rootId: 12,
|
||||
id: 14,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'h1',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'h1 in iframe',
|
||||
rootId: 12,
|
||||
id: 17,
|
||||
},
|
||||
],
|
||||
rootId: 12,
|
||||
id: 16,
|
||||
},
|
||||
],
|
||||
rootId: 12,
|
||||
id: 15,
|
||||
},
|
||||
],
|
||||
rootId: 12,
|
||||
id: 13,
|
||||
},
|
||||
],
|
||||
compatMode: 'BackCompat',
|
||||
id: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
removes: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
isAttachIframe: true,
|
||||
},
|
||||
timestamp: now + 200,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 12,
|
||||
styleIds: [2],
|
||||
styles: [
|
||||
{
|
||||
rules: [],
|
||||
styleId: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 250,
|
||||
},
|
||||
// use CSSStyleSheet.replace api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 1,
|
||||
replace: 'div { color: yellow; }',
|
||||
},
|
||||
timestamp: now + 300,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 2,
|
||||
replace: 'h1 { color: blue; }',
|
||||
},
|
||||
timestamp: now + 300,
|
||||
},
|
||||
// use CSSStyleSheet.replaceSync api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 1,
|
||||
replaceSync: 'div { display: inline ; }',
|
||||
},
|
||||
timestamp: now + 400,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 2,
|
||||
replaceSync: 'h1 { font-size: large; }',
|
||||
},
|
||||
timestamp: now + 400,
|
||||
},
|
||||
// use StyleDeclaration.setProperty api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleDeclaration,
|
||||
styleId: 1,
|
||||
set: {
|
||||
property: 'color',
|
||||
value: 'green',
|
||||
priority: undefined,
|
||||
},
|
||||
index: [0],
|
||||
},
|
||||
timestamp: now + 500,
|
||||
},
|
||||
// use StyleDeclaration.removeProperty api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleDeclaration,
|
||||
styleId: 1,
|
||||
remove: {
|
||||
property: 'display',
|
||||
},
|
||||
index: [0],
|
||||
},
|
||||
timestamp: now + 500,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleDeclaration,
|
||||
styleId: 2,
|
||||
set: {
|
||||
property: 'font-size',
|
||||
value: 'medium',
|
||||
priority: 'important',
|
||||
},
|
||||
index: [0],
|
||||
},
|
||||
timestamp: now + 500,
|
||||
},
|
||||
// use CSSStyleSheet.insertRule api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 2,
|
||||
adds: [
|
||||
{
|
||||
rule: 'h2 { color: red; }',
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 500,
|
||||
},
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 1,
|
||||
adds: [
|
||||
{
|
||||
rule: 'body { border: 2px solid blue; }',
|
||||
index: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 600,
|
||||
},
|
||||
// use CSSStyleSheet.deleteRule api
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.StyleSheetRule,
|
||||
styleId: 2,
|
||||
removes: [
|
||||
{
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 600,
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
354
packages/rrweb/test/events/adopted-style-sheet.ts
Normal file
354
packages/rrweb/test/events/adopted-style-sheet.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const events: eventWithTime[] = [
|
||||
{ type: EventType.DomContentLoaded, data: {}, timestamp: now },
|
||||
{
|
||||
type: EventType.Meta,
|
||||
data: {
|
||||
href: 'about:blank',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
{
|
||||
type: EventType.FullSnapshot,
|
||||
data: {
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{
|
||||
type: 1,
|
||||
name: 'html',
|
||||
publicId: '',
|
||||
systemId: '',
|
||||
id: 2,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 4,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 6,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'div in outermost document',
|
||||
id: 8,
|
||||
},
|
||||
],
|
||||
id: 7,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 9,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {
|
||||
id: 'shadow-host1',
|
||||
},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'div in shadow dom 1',
|
||||
id: 12,
|
||||
},
|
||||
],
|
||||
id: 11,
|
||||
isShadow: true,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'span',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'span in shadow dom 1',
|
||||
id: 14,
|
||||
},
|
||||
],
|
||||
id: 13,
|
||||
isShadow: true,
|
||||
},
|
||||
],
|
||||
id: 10,
|
||||
isShadowHost: true,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 15,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {
|
||||
id: 'shadow-host2',
|
||||
},
|
||||
childNodes: [],
|
||||
id: 16,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 17,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'iframe',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 18,
|
||||
},
|
||||
{
|
||||
type: 3,
|
||||
textContent: '\n ',
|
||||
id: 19,
|
||||
},
|
||||
],
|
||||
id: 5,
|
||||
},
|
||||
],
|
||||
id: 3,
|
||||
},
|
||||
],
|
||||
id: 1,
|
||||
},
|
||||
initialOffset: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
},
|
||||
timestamp: now + 100,
|
||||
},
|
||||
// Adopt the stylesheet #1 on document at 200ms
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 1,
|
||||
styleIds: [1],
|
||||
styles: [
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
rule: 'div { color: yellow; }',
|
||||
},
|
||||
],
|
||||
styleId: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 200,
|
||||
},
|
||||
// Add an IFrame element
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.Mutation,
|
||||
adds: [
|
||||
{
|
||||
parentId: 18,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 0,
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'html',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'head',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
rootId: 20,
|
||||
id: 22,
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'body',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 2,
|
||||
tagName: 'h1',
|
||||
attributes: {},
|
||||
childNodes: [
|
||||
{
|
||||
type: 3,
|
||||
textContent: 'h1 in iframe',
|
||||
rootId: 20,
|
||||
id: 25,
|
||||
},
|
||||
],
|
||||
rootId: 20,
|
||||
id: 24,
|
||||
},
|
||||
],
|
||||
rootId: 20,
|
||||
id: 23,
|
||||
},
|
||||
],
|
||||
rootId: 20,
|
||||
id: 21,
|
||||
},
|
||||
],
|
||||
compatMode: 'BackCompat',
|
||||
id: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
removes: [],
|
||||
texts: [],
|
||||
attributes: [],
|
||||
isAttachIframe: true,
|
||||
},
|
||||
timestamp: now + 250,
|
||||
},
|
||||
// Adopt the stylesheet #2 on a shadow root at 300ms
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 10,
|
||||
styleIds: [1, 2],
|
||||
styles: [
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
rule: 'span { color: red; }',
|
||||
},
|
||||
],
|
||||
styleId: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 300,
|
||||
},
|
||||
// Adopt the stylesheet #3 on document of the IFrame element
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 20,
|
||||
styleIds: [3],
|
||||
styles: [
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
rule: 'h1 { color: blue; }',
|
||||
},
|
||||
],
|
||||
styleId: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 300,
|
||||
},
|
||||
// create a new shadow dom
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: 0,
|
||||
texts: [],
|
||||
attributes: [],
|
||||
removes: [],
|
||||
adds: [
|
||||
{
|
||||
parentId: 16,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'span',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 26,
|
||||
isShadow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 26,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 3,
|
||||
textContent: 'span in shadow dom 2',
|
||||
id: 27,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 16,
|
||||
nextId: 26,
|
||||
node: {
|
||||
type: 2,
|
||||
tagName: 'div',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 28,
|
||||
isShadow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentId: 28,
|
||||
nextId: null,
|
||||
node: {
|
||||
type: 3,
|
||||
textContent: 'div in shadow dom 2',
|
||||
id: 29,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 500,
|
||||
},
|
||||
// Adopt the stylesheet #4 on the shadow dom
|
||||
{
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.AdoptedStyleSheet,
|
||||
id: 16,
|
||||
styleIds: [4],
|
||||
styles: [
|
||||
{
|
||||
rules: [{ rule: 'span { color: green; }' }],
|
||||
styleId: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
timestamp: now + 550,
|
||||
},
|
||||
];
|
||||
|
||||
export default events;
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type * as puppeteer from 'puppeteer';
|
||||
import 'construct-style-sheets-polyfill';
|
||||
import {
|
||||
recordOptions,
|
||||
listenerHandler,
|
||||
@@ -462,6 +463,113 @@ describe('record', function (this: ISuite) {
|
||||
assertSnapshot(ctx.events);
|
||||
});
|
||||
|
||||
it('captures mutations on adopted stylesheets', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
document.body.innerHTML = `
|
||||
<div>div in outermost document</div>
|
||||
<iframe></iframe>
|
||||
`;
|
||||
|
||||
const sheet = new CSSStyleSheet();
|
||||
// Add stylesheet to a document.
|
||||
|
||||
document.adoptedStyleSheets = [sheet];
|
||||
|
||||
const iframe = document.querySelector('iframe');
|
||||
const sheet2 = new (iframe!.contentWindow! as Window &
|
||||
typeof globalThis).CSSStyleSheet();
|
||||
|
||||
// Add stylesheet to an IFrame document.
|
||||
iframe!.contentDocument!.adoptedStyleSheets = [sheet2];
|
||||
iframe!.contentDocument!.body.innerHTML = '<h1>h1 in iframe</h1>';
|
||||
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
sheet.replace!('div { color: yellow; }');
|
||||
sheet2.replace!('h1 { color: blue; }');
|
||||
}, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
sheet.replaceSync!('div { display: inline ; }');
|
||||
sheet2.replaceSync!('h1 { font-size: large; }');
|
||||
}, 5);
|
||||
|
||||
setTimeout(() => {
|
||||
(sheet.cssRules[0] as CSSStyleRule).style.setProperty('color', 'green');
|
||||
(sheet.cssRules[0] as CSSStyleRule).style.removeProperty('display');
|
||||
(sheet2.cssRules[0] as CSSStyleRule).style.setProperty(
|
||||
'font-size',
|
||||
'medium',
|
||||
'important',
|
||||
);
|
||||
sheet2.insertRule('h2 { color: red; }');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
sheet.insertRule('body { border: 2px solid blue; }', 1);
|
||||
sheet2.deleteRule(0);
|
||||
}, 15);
|
||||
});
|
||||
await waitForRAF(ctx.page);
|
||||
assertSnapshot(ctx.events);
|
||||
});
|
||||
|
||||
it('captures adopted stylesheets in nested shadow doms and iframes', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="shadow-host-1">entry</div>
|
||||
`;
|
||||
|
||||
let shadowHost = document.querySelector('div')!;
|
||||
shadowHost!.attachShadow({ mode: 'open' });
|
||||
let iframeDocument: Document;
|
||||
const NestedDepth = 4;
|
||||
// construct nested shadow doms and iframe elements
|
||||
for (let i = 1; i <= NestedDepth; i++) {
|
||||
const shadowRoot = shadowHost.shadowRoot!;
|
||||
const iframeElement = document.createElement('iframe');
|
||||
shadowRoot.appendChild(iframeElement);
|
||||
iframeElement.id = `iframe-${i}`;
|
||||
iframeDocument = iframeElement.contentDocument!;
|
||||
shadowHost = iframeDocument.createElement('div');
|
||||
shadowHost.id = `shadow-host-${i + 1}`;
|
||||
iframeDocument.body.append(shadowHost);
|
||||
shadowHost!.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
const iframeWin = iframeDocument!.defaultView!;
|
||||
const sheet1 = new iframeWin.CSSStyleSheet();
|
||||
sheet1.replaceSync!('h1 {color: blue;}');
|
||||
iframeDocument!.adoptedStyleSheets = [sheet1];
|
||||
const sheet2 = new iframeWin.CSSStyleSheet();
|
||||
sheet2.replaceSync!('div {font-size: large;}');
|
||||
shadowHost.shadowRoot!.adoptedStyleSheets = [sheet2];
|
||||
|
||||
const { record } = ((window as unknown) as IWindow).rrweb;
|
||||
record({
|
||||
emit: ((window as unknown) as IWindow).emit,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
sheet1.insertRule!('div { display: inline ; }', 1);
|
||||
sheet2.replaceSync!('h1 { font-size: large; }');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
const sheet3 = new iframeWin.CSSStyleSheet();
|
||||
sheet3.replaceSync!('span {background-color: red;}');
|
||||
iframeDocument!.adoptedStyleSheets = [sheet3, sheet2];
|
||||
shadowHost.shadowRoot!.adoptedStyleSheets = [sheet1, sheet3];
|
||||
}, 150);
|
||||
});
|
||||
await ctx.page.waitForTimeout(200);
|
||||
assertSnapshot(ctx.events);
|
||||
});
|
||||
|
||||
it('captures stylesheets in iframes with `blob:` url', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
const iframe = document.createElement('iframe');
|
||||
@@ -579,6 +687,73 @@ describe('record', function (this: ISuite) {
|
||||
|
||||
assertSnapshot(ctx.events);
|
||||
});
|
||||
|
||||
it('captures adopted stylesheets in shadow doms and iframe', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
document.body.innerHTML = `
|
||||
<div>div in outermost document</div>
|
||||
<div id="shadow-host1"></div>
|
||||
<div id="shadow-host2"></div>
|
||||
<iframe></iframe>
|
||||
`;
|
||||
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync!(
|
||||
'div { color: yellow; } h2 { color: orange; } h3 { font-size: larger;}',
|
||||
);
|
||||
// Add stylesheet to a document.
|
||||
|
||||
document.adoptedStyleSheets = [sheet];
|
||||
|
||||
// Add stylesheet to a shadow host.
|
||||
const host = document.querySelector('#shadow-host1');
|
||||
const shadow = host!.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML =
|
||||
'<div>div in shadow dom 1</div><span>span in shadow dom 1</span>';
|
||||
const sheet2 = new CSSStyleSheet();
|
||||
|
||||
sheet2.replaceSync!('span { color: red; }');
|
||||
|
||||
shadow.adoptedStyleSheets = [sheet, sheet2];
|
||||
|
||||
// Add stylesheet to an IFrame document.
|
||||
const iframe = document.querySelector('iframe');
|
||||
const sheet3 = new (iframe!.contentWindow! as IWindow &
|
||||
typeof globalThis).CSSStyleSheet();
|
||||
sheet3.replaceSync!('h1 { color: blue; }');
|
||||
|
||||
iframe!.contentDocument!.adoptedStyleSheets = [sheet3];
|
||||
|
||||
const ele = iframe!.contentDocument!.createElement('h1');
|
||||
ele.innerText = 'h1 in iframe';
|
||||
iframe!.contentDocument!.body.appendChild(ele);
|
||||
|
||||
((window as unknown) as IWindow).rrweb.record({
|
||||
emit: ((window.top as unknown) as IWindow).emit,
|
||||
});
|
||||
|
||||
// Make incremental changes to shadow dom.
|
||||
setTimeout(() => {
|
||||
const host = document.querySelector('#shadow-host2');
|
||||
const shadow = host!.attachShadow({ mode: 'open' });
|
||||
shadow.innerHTML =
|
||||
'<div>div in shadow dom 2</div><span>span in shadow dom 2</span>';
|
||||
const sheet4 = new CSSStyleSheet();
|
||||
sheet4.replaceSync!('span { color: green; }');
|
||||
shadow.adoptedStyleSheets = [sheet, sheet4];
|
||||
|
||||
document.adoptedStyleSheets = [sheet4, sheet, sheet2];
|
||||
|
||||
const sheet5 = new (iframe!.contentWindow! as IWindow &
|
||||
typeof globalThis).CSSStyleSheet();
|
||||
sheet5.replaceSync!('h2 { color: purple; }');
|
||||
iframe!.contentDocument!.adoptedStyleSheets = [sheet5, sheet3];
|
||||
}, 10);
|
||||
});
|
||||
await waitForRAF(ctx.page); // wait till events get sent
|
||||
|
||||
assertSnapshot(ctx.events);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record iframes', function (this: ISuite) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type * as puppeteer from 'puppeteer';
|
||||
import 'construct-style-sheets-polyfill';
|
||||
import {
|
||||
assertDomSnapshot,
|
||||
launchPuppeteer,
|
||||
@@ -17,6 +18,8 @@ import selectionEvents from './events/selection';
|
||||
import shadowDomEvents from './events/shadow-dom';
|
||||
import StyleSheetTextMutation from './events/style-sheet-text-mutation';
|
||||
import canvasInIframe from './events/canvas-in-iframe';
|
||||
import adoptedStyleSheet from './events/adopted-style-sheet';
|
||||
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
|
||||
|
||||
interface ISuite {
|
||||
code: string;
|
||||
@@ -718,4 +721,232 @@ describe('replayer', function () {
|
||||
wrapper = await page.$(`.${replayerWrapperClassName}`);
|
||||
expect(wrapper).toBeNull();
|
||||
});
|
||||
|
||||
it('can replay adopted stylesheet events', async () => {
|
||||
await page.evaluate(`
|
||||
events = ${JSON.stringify(adoptedStyleSheet)};
|
||||
const { Replayer } = rrweb;
|
||||
var replayer = new Replayer(events,{showDebug:true});
|
||||
replayer.play();
|
||||
`);
|
||||
await page.waitForTimeout(600);
|
||||
const iframe = await page.$('iframe');
|
||||
const contentDocument = await iframe!.contentFrame()!;
|
||||
const colorRGBMap = {
|
||||
yellow: 'rgb(255, 255, 0)',
|
||||
red: 'rgb(255, 0, 0)',
|
||||
blue: 'rgb(0, 0, 255)',
|
||||
green: 'rgb(0, 128, 0)',
|
||||
};
|
||||
const checkCorrectness = async () => {
|
||||
// check the adopted stylesheet is applied on the outermost document
|
||||
expect(
|
||||
await contentDocument!.$eval(
|
||||
'div',
|
||||
(element) => window.getComputedStyle(element).color,
|
||||
),
|
||||
).toEqual(colorRGBMap.yellow);
|
||||
|
||||
// check the adopted stylesheet is applied on the shadow dom #1's root
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
window.getComputedStyle(
|
||||
document
|
||||
.querySelector('#shadow-host1')!
|
||||
.shadowRoot!.querySelector('span')!,
|
||||
).color,
|
||||
),
|
||||
).toEqual(colorRGBMap.red);
|
||||
|
||||
// check the adopted stylesheet is applied on document of the IFrame element
|
||||
expect(
|
||||
await contentDocument!.$eval(
|
||||
'iframe',
|
||||
(element) =>
|
||||
window.getComputedStyle(
|
||||
(element as HTMLIFrameElement).contentDocument!.querySelector(
|
||||
'h1',
|
||||
)!,
|
||||
).color,
|
||||
),
|
||||
).toEqual(colorRGBMap.blue);
|
||||
|
||||
// check the adopted stylesheet is applied on the shadow dom #2's root
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
window.getComputedStyle(
|
||||
document
|
||||
.querySelector('#shadow-host2')!
|
||||
.shadowRoot!.querySelector('span')!,
|
||||
).color,
|
||||
),
|
||||
).toEqual(colorRGBMap.green);
|
||||
};
|
||||
await checkCorrectness();
|
||||
|
||||
// To test the correctness of replaying adopted stylesheet events in the fast-forward mode.
|
||||
await page.evaluate('replayer.play(0);');
|
||||
await waitForRAF(page);
|
||||
await page.evaluate('replayer.pause(600);');
|
||||
await checkCorrectness();
|
||||
});
|
||||
|
||||
it('can replay modification events for adoptedStyleSheet', async () => {
|
||||
await page.evaluate(`
|
||||
events = ${JSON.stringify(adoptedStyleSheetModification)};
|
||||
const { Replayer } = rrweb;
|
||||
var replayer = new Replayer(events,{showDebug:true});
|
||||
replayer.play();
|
||||
`);
|
||||
const iframe = await page.$('iframe');
|
||||
const contentDocument = await iframe!.contentFrame()!;
|
||||
|
||||
// At 250ms, the adopted stylesheet is still empty.
|
||||
const check250ms = async () => {
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.adoptedStyleSheets.length === 1 &&
|
||||
document.adoptedStyleSheets[0].cssRules.length === 0,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets.length === 1 &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules.length === 0,
|
||||
),
|
||||
).toBeTruthy();
|
||||
};
|
||||
|
||||
// At 300ms, the adopted stylesheet is replaced with new content.
|
||||
const check300ms = async () => {
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'div { color: yellow; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'h1 { color: blue; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
};
|
||||
|
||||
// At 400ms, check replaceSync API.
|
||||
const check400ms = async () => {
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'div { display: inline; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'h1 { font-size: large; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
};
|
||||
|
||||
// At 500ms, check CSSStyleDeclaration API.
|
||||
const check500ms = async () => {
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'div { color: green; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules.length === 2 &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'h2 { color: red; }' &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules[1].cssText ===
|
||||
'h1 { font-size: medium !important; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
};
|
||||
|
||||
// At 600ms, check insertRule and deleteRule API.
|
||||
const check600ms = async () => {
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.adoptedStyleSheets[0].cssRules.length === 2 &&
|
||||
document.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'div { color: green; }' &&
|
||||
document.adoptedStyleSheets[0].cssRules[1].cssText ===
|
||||
'body { border: 2px solid blue; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await contentDocument!.evaluate(
|
||||
() =>
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules.length === 1 &&
|
||||
document.querySelector('iframe')!.contentDocument!
|
||||
.adoptedStyleSheets[0].cssRules[0].cssText ===
|
||||
'h1 { font-size: medium !important; }',
|
||||
),
|
||||
).toBeTruthy();
|
||||
};
|
||||
|
||||
await page.waitForTimeout(235);
|
||||
await check250ms();
|
||||
|
||||
await page.waitForTimeout(50);
|
||||
await check300ms();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await check400ms();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await check500ms();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await check600ms();
|
||||
|
||||
// To test the correctness of replaying adopted stylesheet mutation events in the fast-forward mode.
|
||||
await page.evaluate('replayer.play(0);');
|
||||
await waitForRAF(page);
|
||||
await page.evaluate('replayer.pause(280);');
|
||||
await check250ms();
|
||||
|
||||
await page.evaluate('replayer.pause(330);');
|
||||
await check300ms();
|
||||
|
||||
await page.evaluate('replayer.pause(430);');
|
||||
await check400ms();
|
||||
|
||||
await page.evaluate('replayer.pause(530);');
|
||||
await check500ms();
|
||||
|
||||
await page.evaluate('replayer.pause(630);');
|
||||
await check600ms();
|
||||
});
|
||||
});
|
||||
|
||||
78
packages/rrweb/test/util.test.ts
Normal file
78
packages/rrweb/test/util.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { StyleSheetMirror } from '../src/utils';
|
||||
|
||||
describe('Utilities for other modules', () => {
|
||||
describe('StyleSheetMirror', () => {
|
||||
it('should create a StyleSheetMirror', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
expect(mirror).toBeDefined();
|
||||
expect(mirror.add).toBeDefined();
|
||||
expect(mirror.has).toBeDefined();
|
||||
expect(mirror.reset).toBeDefined();
|
||||
expect(mirror.getId).toBeDefined();
|
||||
});
|
||||
|
||||
it('can add CSSStyleSheet into the mirror without ID parameter', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
expect(mirror.has(styleSheet)).toBeFalsy();
|
||||
expect(mirror.add(styleSheet)).toEqual(1);
|
||||
expect(mirror.has(styleSheet)).toBeTruthy();
|
||||
// This stylesheet has been added before so just return its assigned id.
|
||||
expect(mirror.add(styleSheet)).toEqual(1);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
expect(mirror.has(styleSheet)).toBeFalsy();
|
||||
expect(mirror.add(styleSheet)).toEqual(i + 2);
|
||||
expect(mirror.has(styleSheet)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('can add CSSStyleSheet into the mirror with ID parameter', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
expect(mirror.has(styleSheet)).toBeFalsy();
|
||||
expect(mirror.add(styleSheet, i)).toEqual(i);
|
||||
expect(mirror.has(styleSheet)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('can get the id from the mirror', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
mirror.add(styleSheet);
|
||||
expect(mirror.getId(styleSheet)).toBe(i + 1);
|
||||
}
|
||||
expect(mirror.getId(new CSSStyleSheet())).toBe(-1);
|
||||
});
|
||||
|
||||
it('can get CSSStyleSheet objects with id', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
mirror.add(styleSheet);
|
||||
expect(mirror.getStyle(i + 1)).toBe(styleSheet);
|
||||
}
|
||||
});
|
||||
|
||||
it('can reset the mirror', () => {
|
||||
const mirror = new StyleSheetMirror();
|
||||
const styleList: CSSStyleSheet[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
mirror.add(styleSheet);
|
||||
expect(mirror.getId(styleSheet)).toBe(i + 1);
|
||||
styleList.push(styleSheet);
|
||||
}
|
||||
expect(mirror.reset()).toBeUndefined();
|
||||
for (let s of styleList) expect(mirror.has(s)).toBeFalsy();
|
||||
for (let i = 0; i < 10; i++) expect(mirror.getStyle(i + 1)).toBeNull();
|
||||
expect(mirror.add(new CSSStyleSheet())).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
yarn.lock
91
yarn.lock
@@ -1618,6 +1618,16 @@
|
||||
npmlog "^4.1.2"
|
||||
write-file-atomic "^3.0.3"
|
||||
|
||||
"@mdn/browser-compat-data@^3.3.14":
|
||||
version "3.3.14"
|
||||
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-3.3.14.tgz#b72a37c654e598f9ae6f8335faaee182bebc6b28"
|
||||
integrity sha512-n2RC9d6XatVbWFdHLimzzUJxJ1KY8LdjqrW6YvGPiRmsHkhOUx74/Ct10x5Yo7bC/Jvqx7cDEW8IMPv/+vwEzA==
|
||||
|
||||
"@mdn/browser-compat-data@^4.1.5":
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
|
||||
integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
|
||||
|
||||
"@microsoft/tsdoc-config@0.16.1":
|
||||
version "0.16.1"
|
||||
resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.16.1.tgz#4de11976c1202854c4618f364bf499b4be33e657"
|
||||
@@ -2766,6 +2776,13 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz"
|
||||
integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
|
||||
|
||||
ast-metadata-inferer@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/ast-metadata-inferer/-/ast-metadata-inferer-0.7.0.tgz#c45d874cbdecabea26dc5de11fc6fa1919807c66"
|
||||
integrity sha512-OkMLzd8xelb3gmnp6ToFvvsHLtS6CbagTkFQvQ+ZYFe3/AIl9iKikNR9G7pY3GfOR/2Xc222hwBjzI7HLkE76Q==
|
||||
dependencies:
|
||||
"@mdn/browser-compat-data" "^3.3.14"
|
||||
|
||||
async-limiter@~1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz"
|
||||
@@ -3052,6 +3069,16 @@ browserslist@^4.16.6:
|
||||
node-releases "^1.1.77"
|
||||
picocolors "^0.2.1"
|
||||
|
||||
browserslist@^4.16.8, browserslist@^4.21.4:
|
||||
version "4.21.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.4.tgz#e7496bbc67b9e39dd0f98565feccdcb0d4ff6987"
|
||||
integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001400"
|
||||
electron-to-chromium "^1.4.251"
|
||||
node-releases "^2.0.6"
|
||||
update-browserslist-db "^1.0.9"
|
||||
|
||||
browserslist@^4.17.5:
|
||||
version "4.19.1"
|
||||
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz"
|
||||
@@ -3217,20 +3244,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219:
|
||||
version "1.0.30001246"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001246.tgz"
|
||||
integrity sha512-Tc+ff0Co/nFNbLOrziBXmMVtpt9S2c2Y+Z9Nk9Khj09J+0zR9ejvIW5qkZAErCbOrVODCx/MN+GpB5FNBs5GFA==
|
||||
|
||||
caniuse-lite@^1.0.30001264:
|
||||
version "1.0.30001265"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001265.tgz"
|
||||
integrity sha512-YzBnspggWV5hep1m9Z6sZVLOt7vrju8xWooFAgN6BA5qvy98qPAPb7vNUzypFaoh2pb3vlfzbDO8tB57UPGbtw==
|
||||
|
||||
caniuse-lite@^1.0.30001286:
|
||||
version "1.0.30001297"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001297.tgz"
|
||||
integrity sha512-6bbIbowYG8vFs/Lk4hU9jFt7NknGDleVAciK916tp6ft1j+D//ZwwL6LbF1wXMQ32DMSjeuUV8suhh6dlmFjcA==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001264, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001400:
|
||||
version "1.0.30001402"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001402.tgz"
|
||||
integrity sha512-Mx4MlhXO5NwuvXGgVb+hg65HZ+bhUYsz8QtDGDo2QmaJS2GBX47Xfi2koL86lc8K+l+htXeTEB/Aeqvezoo6Ew==
|
||||
|
||||
caseless@~0.12.0:
|
||||
version "0.12.0"
|
||||
@@ -3607,6 +3624,11 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
|
||||
resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz"
|
||||
integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
|
||||
|
||||
construct-style-sheets-polyfill@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/construct-style-sheets-polyfill/-/construct-style-sheets-polyfill-3.1.0.tgz#c490abd79efdb359fafa62ec14ea55232be0eecf"
|
||||
integrity sha512-HBLKP0chz8BAY6rBdzda11c3wAZeCZ+kIG4weVC2NM3AXzxx09nhe8t0SQNdloAvg5GLuHwq/0SPOOSPvtCcKw==
|
||||
|
||||
content-disposition@0.5.3:
|
||||
version "0.5.3"
|
||||
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz"
|
||||
@@ -3724,6 +3746,11 @@ core-js@^2.4.0:
|
||||
resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz"
|
||||
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
|
||||
|
||||
core-js@^3.16.2:
|
||||
version "3.25.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.1.tgz#5818e09de0db8956e16bf10e2a7141e931b7c69c"
|
||||
integrity sha512-sr0FY4lnO1hkQ4gLDr24K0DGnweGO1QwSj5BpfQjpSJPdqWalja4cTps29Y/PJVG/P7FYlPDkH3hO+Tr0CvDgQ==
|
||||
|
||||
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
|
||||
@@ -4291,6 +4318,11 @@ electron-to-chromium@^1.4.17:
|
||||
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.37.tgz"
|
||||
integrity sha512-XIvFB1omSAxYgHYX48sC+HR8i/p7lx7R+0cX9faElg1g++h9IilCrJ12+bQuY+d96Wp7zkBiJwMOv+AhLtLrTg==
|
||||
|
||||
electron-to-chromium@^1.4.251:
|
||||
version "1.4.254"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.254.tgz#c6203583890abf88dfc0be046cd72d3b48f8beb6"
|
||||
integrity sha512-Sh/7YsHqQYkA6ZHuHMy24e6TE4eX6KZVsZb9E/DvU1nQRIrH4BflO/4k+83tfdYvDl+MObvlqHPRICzEdC9c6Q==
|
||||
|
||||
emittery@^0.8.1:
|
||||
version "0.8.1"
|
||||
resolved "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz"
|
||||
@@ -4614,6 +4646,20 @@ eslint-config-google@^0.14.0:
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a"
|
||||
integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
|
||||
|
||||
eslint-plugin-compat@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-compat/-/eslint-plugin-compat-4.0.2.tgz#b058627a7d25d352adf0ec16dca8fcf92d9c7af7"
|
||||
integrity sha512-xqvoO54CLTVaEYGMzhu35Wzwk/As7rCvz/2dqwnFiWi0OJccEtGIn+5qq3zqIu9nboXlpdBN579fZcItC73Ycg==
|
||||
dependencies:
|
||||
"@mdn/browser-compat-data" "^4.1.5"
|
||||
ast-metadata-inferer "^0.7.0"
|
||||
browserslist "^4.16.8"
|
||||
caniuse-lite "^1.0.30001304"
|
||||
core-js "^3.16.2"
|
||||
find-up "^5.0.0"
|
||||
lodash.memoize "4.1.2"
|
||||
semver "7.3.5"
|
||||
|
||||
eslint-plugin-jest@^26.5.3:
|
||||
version "26.5.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-26.5.3.tgz#a3ceeaf4a757878342b8b00eca92379b246e5505"
|
||||
@@ -7677,7 +7723,7 @@ lodash.ismatch@^4.4.0:
|
||||
resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz"
|
||||
integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=
|
||||
|
||||
lodash.memoize@4.x, lodash.memoize@^4.1.2:
|
||||
lodash.memoize@4.1.2, lodash.memoize@4.x, lodash.memoize@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz"
|
||||
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
|
||||
@@ -8270,6 +8316,11 @@ node-releases@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz"
|
||||
integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==
|
||||
|
||||
node-releases@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
|
||||
integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
|
||||
|
||||
nopt@^4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz"
|
||||
@@ -10117,7 +10168,7 @@ semver-match@0.1.1:
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
|
||||
semver@7.x, semver@^7.1.1, semver@^7.1.3, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5:
|
||||
semver@7.3.5, semver@7.x, semver@^7.1.1, semver@^7.1.3, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5:
|
||||
version "7.3.5"
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz"
|
||||
integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
|
||||
@@ -11301,6 +11352,14 @@ upath@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz"
|
||||
integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==
|
||||
|
||||
update-browserslist-db@^1.0.9:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18"
|
||||
integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==
|
||||
dependencies:
|
||||
escalade "^3.1.1"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user