Record and replay nested stylesheet rules (#666)

* fix typo

* record nested style rules

* Replay nested style rules

* handle index array in replayer

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
yz-yu
2021-08-15 15:48:17 +08:00
committed by GitHub
parent 8d881b1783
commit e6f1810144
10 changed files with 452 additions and 20 deletions

View File

@@ -53,7 +53,7 @@
"ignore-styles": "^5.0.1",
"inquirer": "^6.2.1",
"jest-snapshot": "^23.6.0",
"jsdom": "^16.6.0",
"jsdom": "^17.0.0",
"jsdom-global": "^3.0.2",
"mocha": "^5.2.0",
"prettier": "2.2.1",

View File

@@ -472,6 +472,23 @@ function initInputObserver(
};
}
function getNestedCSSRulePositions(rule: CSSStyleRule): number[] {
const positions: Array<number> = [];
function recurse(rule: CSSRule, pos: number[]) {
if (rule.parentRule instanceof CSSGroupingRule) {
const rules = Array.from((rule.parentRule as CSSGroupingRule).cssRules);
const index = rules.indexOf(rule);
pos.unshift(index);
} else {
const rules = Array.from(rule.parentStyleSheet!.cssRules);
const index = rules.indexOf(rule);
pos.unshift(index);
}
return pos;
}
return recurse(rule, positions);
}
function initStyleSheetObserver(
cb: styleSheetRuleCallback,
mirror: Mirror,
@@ -500,9 +517,46 @@ function initStyleSheetObserver(
return deleteRule.apply(this, arguments);
};
const groupingInsertRule = CSSGroupingRule.prototype.insertRule;
CSSGroupingRule.prototype.insertRule = function (
rule: string,
index?: number,
) {
const id = mirror.getId(this.parentStyleSheet.ownerNode as INode);
if (id !== -1) {
cb({
id,
adds: [
{
rule,
index: [
...getNestedCSSRulePositions(this),
index || 0, // defaults to 0
],
},
],
});
}
return groupingInsertRule.apply(this, arguments);
};
const groupingDeleteRule = CSSGroupingRule.prototype.deleteRule;
CSSGroupingRule.prototype.deleteRule = function (index: number) {
const id = mirror.getId(this.parentStyleSheet.ownerNode as INode);
if (id !== -1) {
cb({
id,
removes: [{ index: [...getNestedCSSRulePositions(this), index] }],
});
}
return groupingDeleteRule.apply(this, arguments);
};
return () => {
CSSStyleSheet.prototype.insertRule = insertRule;
CSSStyleSheet.prototype.deleteRule = deleteRule;
CSSGroupingRule.prototype.insertRule = groupingInsertRule;
CSSGroupingRule.prototype.deleteRule = groupingDeleteRule;
};
}

View File

@@ -57,6 +57,7 @@ import {
StyleRuleType,
VirtualStyleRules,
VirtualStyleRulesMap,
getNestedRule,
} from './virtual-styles';
const SKIP_TIME_THRESHOLD = 10 * 1000;
@@ -978,19 +979,26 @@ export class Replayer {
d.adds.forEach(({ rule, index }) => {
if (styleSheet) {
try {
const _index =
if (Array.isArray(index)) {
const positions = [...index];
const insertAt = positions.pop();
const nestedRule = getNestedRule(
styleSheet.cssRules,
positions,
);
nestedRule.insertRule(rule, insertAt);
} else {
const _index =
index === undefined
? undefined
: Math.min(index, styleSheet.cssRules.length);
try {
styleSheet.insertRule(rule, _index);
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
} 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
@@ -1008,7 +1016,17 @@ export class Replayer {
rules?.push({ index, type: StyleRuleType.Remove });
} else {
try {
styleSheet?.deleteRule(index);
if (Array.isArray(index)) {
const positions = [...index];
const deleteAt = positions.pop();
const nestedRule = getNestedRule(
styleSheet!.cssRules,
positions,
);
nestedRule.deleteRule(deleteAt || 0);
} else {
styleSheet?.deleteRule(index);
}
} catch (e) {
/**
* same as insertRule

View File

@@ -9,11 +9,11 @@ export enum StyleRuleType {
type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number;
index?: number | number[];
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number;
index: number | number[];
};
type SnapshotRule = {
type: StyleRuleType.Snapshot;
@@ -23,6 +23,22 @@ type SnapshotRule = {
export type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
export type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;
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 as CSSGroupingRule).cssRules[position[1]] as CSSGroupingRule)
.cssRules,
position.slice(2),
);
}
}
export function applyVirtualStyleRulesToNode(
storedRules: VirtualStyleRules,
styleNode: HTMLStyleElement,
@@ -30,7 +46,17 @@ export function applyVirtualStyleRulesToNode(
storedRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
styleNode.sheet?.insertRule(rule.cssText, rule.index);
if (Array.isArray(rule.index)) {
const positions = [...rule.index];
const insertAt = positions.pop();
const nestedRule = getNestedRule(
styleNode.sheet!.cssRules,
positions,
);
nestedRule.insertRule(rule.cssText, insertAt);
} else {
styleNode.sheet?.insertRule(rule.cssText, rule.index);
}
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
@@ -39,7 +65,17 @@ export function applyVirtualStyleRulesToNode(
}
} else if (rule.type === StyleRuleType.Remove) {
try {
styleNode.sheet?.deleteRule(rule.index);
if (Array.isArray(rule.index)) {
const positions = [...rule.index];
const deleteAt = positions.pop();
const nestedRule = getNestedRule(
styleNode.sheet!.cssRules,
positions,
);
nestedRule.deleteRule(deleteAt || 0);
} else {
styleNode.sheet?.deleteRule(rule.index);
}
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError

View File

@@ -295,7 +295,7 @@ export type textMutation = {
};
export type styleAttributeValue = {
[key:string]: styleValueWithPriority | string | false;
[key: string]: styleValueWithPriority | string | false;
};
export type styleValueWithPriority = [string, string];
@@ -384,11 +384,11 @@ export type scrollCallback = (p: scrollPosition) => void;
export type styleSheetAddRule = {
rule: string;
index?: number;
index?: number | number[];
};
export type styleSheetDeleteRule = {
index: number;
index: number | number[];
};
export type styleSheetRuleParam = {

View File

@@ -329,6 +329,148 @@ exports[`custom-event 1`] = `
]"
`;
exports[`nested-stylesheet-rules 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 7
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 3,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {
\\"_cssText\\": \\"@media {\\\\n body { background: rgb(0, 0, 0); }\\\\n}\\"
},
\\"childNodes\\": [],
\\"id\\": 8
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"adds\\": [
{
\\"rule\\": \\"body { color: #fff; }\\",
\\"index\\": [
0,
0
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"removes\\": [
{
\\"index\\": [
0,
0
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"adds\\": [
{
\\"rule\\": \\"body { color: #ccc; }\\",
\\"index\\": [
0,
0
]
}
]
}
}
]"
`;
exports[`stylesheet-rules 1`] = `
"[
{

View File

@@ -250,6 +250,52 @@ describe('record', function (this: ISuite) {
expect(removeRuleCount).to.equal(1);
assertSnapshot(this.events, __filename, 'stylesheet-rules');
});
it('captures nested stylesheet rules', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
const styleSheet = <CSSStyleSheet>styleElement.sheet;
styleSheet.insertRule('@media {}');
const atMediaRule = styleSheet.cssRules[0] as CSSMediaRule;
const ruleIdx0 = atMediaRule.insertRule('body { background: #000; }', 0);
const ruleIdx1 = atMediaRule.insertRule('body { background: #111; }', 0);
atMediaRule.deleteRule(ruleIdx1);
setTimeout(() => {
atMediaRule.insertRule('body { color: #fff; }', 0);
}, 0);
setTimeout(() => {
atMediaRule.deleteRule(ruleIdx0);
}, 5);
setTimeout(() => {
atMediaRule.insertRule('body { color: #ccc; }', 0);
}, 10);
});
await this.page.waitForTimeout(50);
const styleSheetRuleEvents = this.events.filter(
(e) =>
e.type === EventType.IncrementalSnapshot &&
e.data.source === IncrementalSource.StyleSheetRule,
);
const addRuleCount = styleSheetRuleEvents.filter((e) =>
Boolean((e.data as styleSheetRuleData).adds),
).length;
const removeRuleCount = styleSheetRuleEvents.filter((e) =>
Boolean((e.data as styleSheetRuleData).removes),
).length;
// sync insert/delete should be ignored
expect(addRuleCount).to.equal(2);
expect(removeRuleCount).to.equal(1);
assertSnapshot(this.events, __filename, 'nested-stylesheet-rules');
});
});
describe('record iframes', function (this: ISuite) {

View File

@@ -44,7 +44,7 @@ describe('virtual styles', () => {
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(cssText);
});
it('should delete rule at index 1', () => {
it('should delete rule at index 0', () => {
const dom = new JSDOM(`
<style>
a {color: blue;}
@@ -109,5 +109,60 @@ describe('virtual styles', () => {
Array.from(styleEl.sheet?.cssRules || []).map((rule) => rule.cssText),
).to.have.ordered.members(cssTexts);
});
// JSDOM/CSSOM is currently broken for this test
// remove '.skip' once https://github.com/NV/CSSOM/pull/113#issue-712485075 is merged
it.skip('should insert rule at index [0,0] and keep exsisting rules', () => {
const dom = new JSDOM(`
<style>
@media {
a {color: blue}
div {color: black}
}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const cssText = '.added-rule {border: 1px solid yellow;}';
const virtualStyleRules: VirtualStyleRules = [
{ cssText, index: [0, 0], type: StyleRuleType.Insert },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
console.log(
Array.from((styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules),
);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
).to.equal(3);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules[0].cssText,
).to.equal(cssText);
});
it('should delete rule at index [0,1]', () => {
const dom = new JSDOM(`
<style>
@media {
a {color: blue;}
div {color: black;}
}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const virtualStyleRules: VirtualStyleRules = [
{ index: [0, 1], type: StyleRuleType.Remove },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules?.length,
).to.equal(1);
expect(
(styleEl.sheet?.cssRules[0] as CSSMediaRule).cssRules[0].cssText,
).to.equal('a {color: blue;}');
});
});
});