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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`] = `
|
||||
"[
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user