Css batching (#3326)
* tracker: initial css inlining functionality * tracker: add tests, adjust sheet id, stagger rule sending * ui: rereoute custom html component fragments * removed sorting --------- Co-authored-by: nick-delirium <nikita@openreplay.com>
This commit is contained in:
parent
ee71625499
commit
a26411f2a6
8 changed files with 421 additions and 39 deletions
|
|
@ -352,25 +352,25 @@ const DOMMessages = [
|
||||||
function brokenDomSorter(m1: PlayerMsg, m2: PlayerMsg) {
|
function brokenDomSorter(m1: PlayerMsg, m2: PlayerMsg) {
|
||||||
if (m1.time !== m2.time) return m1.time - m2.time;
|
if (m1.time !== m2.time) return m1.time - m2.time;
|
||||||
|
|
||||||
if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
|
// if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
|
||||||
return -1;
|
// return -1;
|
||||||
if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
// if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
||||||
return 1;
|
// return 1;
|
||||||
|
|
||||||
if (m1.tp === MType.RemoveNode)
|
// if (m1.tp === MType.RemoveNode)
|
||||||
return 1;
|
// return 1;
|
||||||
if (m2.tp === MType.RemoveNode)
|
// if (m2.tp === MType.RemoveNode)
|
||||||
return -1;
|
// return -1;
|
||||||
|
|
||||||
const m1IsDOM = DOMMessages.includes(m1.tp);
|
// const m1IsDOM = DOMMessages.includes(m1.tp);
|
||||||
const m2IsDOM = DOMMessages.includes(m2.tp);
|
// const m2IsDOM = DOMMessages.includes(m2.tp);
|
||||||
// if (m1IsDOM && m2IsDOM) {
|
// if (m1IsDOM && m2IsDOM) {
|
||||||
// // @ts-ignore DOM msg has id but checking for 'id' in m is expensive
|
// // @ts-ignore DOM msg has id but checking for 'id' in m is expensive
|
||||||
// return m1.id - m2.id;
|
// return m1.id - m2.id;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (m1IsDOM && !m2IsDOM) return -1;
|
// if (m1IsDOM && !m2IsDOM) return -1;
|
||||||
if (!m1IsDOM && m2IsDOM) return 1;
|
// if (!m1IsDOM && m2IsDOM) return 1;
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
/** required to keep track of iframes, frameId : vnodeId */
|
/** required to keep track of iframes, frameId : vnodeId */
|
||||||
private readonly iframeRoots: Record<number, number> = {};
|
private readonly iframeRoots: Record<number, number> = {};
|
||||||
|
|
||||||
|
private shadowRootParentMap: Map<number, number> = new Map();
|
||||||
|
|
||||||
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
|
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
|
||||||
* as well as <style> tag owned StyleSheets
|
* as well as <style> tag owned StyleSheets
|
||||||
*/
|
*/
|
||||||
|
|
@ -163,6 +165,11 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getNode(id: number) {
|
public getNode(id: number) {
|
||||||
|
const mappedId = this.shadowRootParentMap.get(id);
|
||||||
|
if (mappedId !== undefined) {
|
||||||
|
// If this is a shadow root ID, return the parent element instead
|
||||||
|
return this.vElements.get(mappedId);
|
||||||
|
}
|
||||||
return this.vElements.get(id) || this.vTexts.get(id);
|
return this.vElements.get(id) || this.vTexts.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,24 +178,21 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
id: number;
|
id: number;
|
||||||
index: number;
|
index: number;
|
||||||
}): void {
|
}): void {
|
||||||
const { parentID, id, index } = msg;
|
let { parentID, id, index } = msg;
|
||||||
|
|
||||||
|
// Check if parentID is a shadow root, and get the real parent element if so
|
||||||
|
const actualParentID = this.shadowRootParentMap.get(parentID);
|
||||||
|
if (actualParentID !== undefined) {
|
||||||
|
parentID = actualParentID;
|
||||||
|
}
|
||||||
|
|
||||||
const child = this.vElements.get(id) || this.vTexts.get(id);
|
const child = this.vElements.get(id) || this.vTexts.get(id);
|
||||||
if (!child) {
|
if (!child) {
|
||||||
logger.error('Insert error. Node not found', id);
|
logger.error('Insert error. Node not found', id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID);
|
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID);
|
||||||
if ('tagName' in child && child.tagName === 'BODY') {
|
|
||||||
const spriteMap = new VSpriteMap(
|
|
||||||
'svg',
|
|
||||||
true,
|
|
||||||
Number.MAX_SAFE_INTEGER - 100,
|
|
||||||
Number.MAX_SAFE_INTEGER - 100,
|
|
||||||
);
|
|
||||||
spriteMap.node.setAttribute('id', 'OPENREPLAY_SPRITES_MAP');
|
|
||||||
spriteMap.node.setAttribute('style', 'display: none;');
|
|
||||||
child.insertChildAt(spriteMap, Number.MAX_SAFE_INTEGER - 100);
|
|
||||||
}
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`${id} Insert error. Parent vNode ${parentID} not found`,
|
`${id} Insert error. Parent vNode ${parentID} not found`,
|
||||||
|
|
@ -305,9 +309,14 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
this.removeAutocomplete(vElem);
|
this.removeAutocomplete(vElem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case MType.MoveNode:
|
case MType.MoveNode: {
|
||||||
|
// if the parent ID is in shadow root map -> custom elements case
|
||||||
|
if (this.shadowRootParentMap.has(msg.parentID)) {
|
||||||
|
msg.parentID = this.shadowRootParentMap.get(msg.parentID)!;
|
||||||
|
}
|
||||||
this.insertNode(msg);
|
this.insertNode(msg);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case MType.RemoveNode: {
|
case MType.RemoveNode: {
|
||||||
const vChild = this.vElements.get(msg.id) || this.vTexts.get(msg.id);
|
const vChild = this.vElements.get(msg.id) || this.vTexts.get(msg.id);
|
||||||
if (!vChild) {
|
if (!vChild) {
|
||||||
|
|
@ -440,6 +449,19 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
logger.error('CreateIFrameDocument: Node not found', msg);
|
logger.error('CreateIFrameDocument: Node not found', msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shadow DOM for a custom element
|
||||||
|
const isCustomElement = vElem.tagName.includes('-');
|
||||||
|
const isNotActualIframe = !["IFRAME", "FRAME"].includes(vElem.tagName.toUpperCase());
|
||||||
|
const isLikelyShadowRoot = isCustomElement && isNotActualIframe;
|
||||||
|
|
||||||
|
if (isLikelyShadowRoot) {
|
||||||
|
// Store the mapping but don't create the actual shadow root
|
||||||
|
this.shadowRootParentMap.set(msg.id, msg.frameID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real iframes
|
||||||
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
|
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
|
||||||
this.olVRoots.delete(this.iframeRoots[msg.frameID]);
|
this.olVRoots.delete(this.iframeRoots[msg.frameID]);
|
||||||
}
|
}
|
||||||
|
|
@ -452,7 +474,7 @@ export default class DOMManager extends ListWalker<Message> {
|
||||||
case MType.AdoptedSsInsertRule: {
|
case MType.AdoptedSsInsertRule: {
|
||||||
const styleSheet = this.olStyleSheets.get(msg.sheetID);
|
const styleSheet = this.olStyleSheets.get(msg.sheetID);
|
||||||
if (!styleSheet) {
|
if (!styleSheet) {
|
||||||
logger.warn('No stylesheet was created for ', msg);
|
logger.warn('No stylesheet was created for ', msg, this.olStyleSheets);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertRule(styleSheet, msg);
|
insertRule(styleSheet, msg);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@openreplay/tracker",
|
"name": "@openreplay/tracker",
|
||||||
"description": "The OpenReplay tracker main package",
|
"description": "The OpenReplay tracker main package",
|
||||||
"version": "16.1.4",
|
"version": "16.2.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"logging",
|
"logging",
|
||||||
"replay"
|
"replay"
|
||||||
|
|
|
||||||
|
|
@ -319,13 +319,14 @@ export default class App {
|
||||||
__save_canvas_locally: false,
|
__save_canvas_locally: false,
|
||||||
localStorage: null,
|
localStorage: null,
|
||||||
sessionStorage: null,
|
sessionStorage: null,
|
||||||
disableStringDict: false,
|
disableStringDict: true,
|
||||||
forceSingleTab: false,
|
forceSingleTab: false,
|
||||||
assistSocketHost: '',
|
assistSocketHost: '',
|
||||||
fixedCanvasScaling: false,
|
fixedCanvasScaling: false,
|
||||||
disableCanvas: false,
|
disableCanvas: false,
|
||||||
captureIFrames: true,
|
captureIFrames: true,
|
||||||
disableSprites: false,
|
disableSprites: false,
|
||||||
|
inlineRemoteCss: true,
|
||||||
obscureTextEmails: true,
|
obscureTextEmails: true,
|
||||||
obscureTextNumbers: false,
|
obscureTextNumbers: false,
|
||||||
crossdomain: {
|
crossdomain: {
|
||||||
|
|
@ -820,7 +821,7 @@ export default class App {
|
||||||
this.debug.error('OpenReplay error: ', context, e)
|
this.debug.error('OpenReplay error: ', context, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
send(message: Message, urgent = false): void {
|
send = (message: Message, urgent = false): void => {
|
||||||
if (this.activityState === ActivityState.NotActive) {
|
if (this.activityState === ActivityState.NotActive) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
tracker/tracker/src/main/app/observer/cssInliner.ts
Normal file
87
tracker/tracker/src/main/app/observer/cssInliner.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
export function inlineRemoteCss(
|
||||||
|
node: HTMLLinkElement,
|
||||||
|
id: number,
|
||||||
|
baseHref: string,
|
||||||
|
getNextID: () => number,
|
||||||
|
insertRule: (id: number, cssText: string, index: number, baseHref: string) => any[],
|
||||||
|
addOwner: (sheetId: number, ownerId: number) => any[],
|
||||||
|
) {
|
||||||
|
const sheet = node.sheet;
|
||||||
|
const sheetId = getNextID()
|
||||||
|
addOwner(sheetId, id);
|
||||||
|
|
||||||
|
const processRules = (rules: CSSRuleList) => {
|
||||||
|
if (rules.length) {
|
||||||
|
setTimeout(() => {
|
||||||
|
for (let i = 0; i < rules.length; i++) {
|
||||||
|
const rule = rules[i];
|
||||||
|
insertRule(sheetId, rule.cssText, i, baseHref);
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCssText = (cssText: string) => {
|
||||||
|
cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
|
||||||
|
const ruleTexts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let currentRule = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < cssText.length; i++) {
|
||||||
|
const char = cssText[i];
|
||||||
|
|
||||||
|
if (char === '{') {
|
||||||
|
depth++;
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
currentRule += char;
|
||||||
|
ruleTexts.push(currentRule.trim());
|
||||||
|
currentRule = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRule += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < ruleTexts.length; i++) {
|
||||||
|
const ruleText = ruleTexts[i];
|
||||||
|
insertRule(sheetId, ruleText, i, baseHref);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sheet) {
|
||||||
|
try {
|
||||||
|
const rules = sheet.cssRules;
|
||||||
|
processRules(rules);
|
||||||
|
} catch (e) {
|
||||||
|
const href = node.href;
|
||||||
|
if (href) {
|
||||||
|
fetch(href)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch CSS: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(cssText => {
|
||||||
|
processCssText(cssText);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Failed to fetch or process CSS from ${href}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.href) {
|
||||||
|
fetch(node.href)
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(cssText => {
|
||||||
|
processCssText(cssText);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Failed to fetch CSS from ${node.href}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
RemoveNode,
|
RemoveNode,
|
||||||
UnbindNodes,
|
UnbindNodes,
|
||||||
SetNodeAttribute,
|
SetNodeAttribute,
|
||||||
|
AdoptedSSInsertRuleURLBased,
|
||||||
|
AdoptedSSAddOwner
|
||||||
} from '../messages.gen.js'
|
} from '../messages.gen.js'
|
||||||
import App from '../index.js'
|
import App from '../index.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,6 +23,8 @@ import {
|
||||||
hasTag,
|
hasTag,
|
||||||
isCommentNode,
|
isCommentNode,
|
||||||
} from '../guards.js'
|
} from '../guards.js'
|
||||||
|
import { inlineRemoteCss } from './cssInliner.js'
|
||||||
|
import { nextID } from "../../modules/constructedStyleSheets.js";
|
||||||
|
|
||||||
const iconCache = {}
|
const iconCache = {}
|
||||||
const svgUrlCache = {}
|
const svgUrlCache = {}
|
||||||
|
|
@ -47,7 +51,7 @@ async function parseUseEl(
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
||||||
${symbol.innerHTML}
|
${symbol.innerHTML}
|
||||||
</svg>
|
</svg>
|
||||||
`.trim()
|
`
|
||||||
|
|
||||||
iconCache[symbolId] = inlineSvg
|
iconCache[symbolId] = inlineSvg
|
||||||
|
|
||||||
|
|
@ -115,7 +119,7 @@ async function parseUseEl(
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
||||||
${symbol.innerHTML}
|
${symbol.innerHTML}
|
||||||
</svg>
|
</svg>
|
||||||
`.trim()
|
`
|
||||||
|
|
||||||
iconCache[symbolId] = inlineSvg
|
iconCache[symbolId] = inlineSvg
|
||||||
|
|
||||||
|
|
@ -188,13 +192,20 @@ export default abstract class Observer {
|
||||||
private readonly attributesMap: Map<number, Set<string>> = new Map()
|
private readonly attributesMap: Map<number, Set<string>> = new Map()
|
||||||
private readonly textSet: Set<number> = new Set()
|
private readonly textSet: Set<number> = new Set()
|
||||||
private readonly disableSprites: boolean = false
|
private readonly disableSprites: boolean = false
|
||||||
|
/**
|
||||||
|
* this option means that, instead of using link element with href to load css,
|
||||||
|
* we will try to parse the css text instead and send it as css rules set
|
||||||
|
* can (and will) affect performance
|
||||||
|
* */
|
||||||
|
private readonly inlineRemoteCss: boolean = false
|
||||||
private readonly domParser = new DOMParser()
|
private readonly domParser = new DOMParser()
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly app: App,
|
protected readonly app: App,
|
||||||
protected readonly isTopContext = false,
|
protected readonly isTopContext = false,
|
||||||
options: { disableSprites: boolean } = { disableSprites: false },
|
options: { disableSprites: boolean, inlineRemoteCss: boolean } = { disableSprites: false, inlineRemoteCss: false },
|
||||||
) {
|
) {
|
||||||
this.disableSprites = options.disableSprites
|
this.disableSprites = options.disableSprites
|
||||||
|
this.inlineRemoteCss = options.inlineRemoteCss
|
||||||
this.observer = createMutationObserver(
|
this.observer = createMutationObserver(
|
||||||
this.app.safe((mutations) => {
|
this.app.safe((mutations) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
|
|
@ -280,10 +291,12 @@ export default abstract class Observer {
|
||||||
let removed = 0
|
let removed = 0
|
||||||
const totalBeforeRemove = this.app.nodes.getNodeCount()
|
const totalBeforeRemove = this.app.nodes.getNodeCount()
|
||||||
|
|
||||||
|
const contentDocument = iframe.contentDocument;
|
||||||
|
const nodesUnregister = this.app.nodes.unregisterNode.bind(this.app.nodes);
|
||||||
while (walker.nextNode()) {
|
while (walker.nextNode()) {
|
||||||
if (!iframe.contentDocument.contains(walker.currentNode)) {
|
if (!contentDocument.contains(walker.currentNode)) {
|
||||||
removed += 1
|
removed += 1
|
||||||
this.app.nodes.unregisterNode(walker.currentNode)
|
nodesUnregister(walker.currentNode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,7 +310,7 @@ export default abstract class Observer {
|
||||||
|
|
||||||
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
|
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
|
||||||
if (isSVGElement(node)) {
|
if (isSVGElement(node)) {
|
||||||
if (name.substring(0, 6) === 'xlink:') {
|
if (name.startsWith('xlink:')) {
|
||||||
name = name.substring(6)
|
name = name.substring(6)
|
||||||
}
|
}
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
|
|
@ -351,6 +364,24 @@ export default abstract class Observer {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'style' || (name === 'href' && hasTag(node, 'link'))) {
|
if (name === 'style' || (name === 'href' && hasTag(node, 'link'))) {
|
||||||
|
if ('rel' in node && node.rel === 'stylesheet' && this.inlineRemoteCss) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inlineRemoteCss(
|
||||||
|
// @ts-ignore
|
||||||
|
node,
|
||||||
|
id,
|
||||||
|
this.app.getBaseHref(),
|
||||||
|
nextID,
|
||||||
|
(id: number, cssText: string, index: number, baseHref: string) => {
|
||||||
|
this.app.send(AdoptedSSInsertRuleURLBased(id, cssText, index, baseHref))
|
||||||
|
},
|
||||||
|
(sheetId: number, ownerId: number) => {
|
||||||
|
this.app.send(AdoptedSSAddOwner(sheetId, ownerId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
|
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -506,8 +537,11 @@ export default abstract class Observer {
|
||||||
;(el as HTMLElement | SVGElement).style.width = `${width}px`
|
;(el as HTMLElement | SVGElement).style.width = `${width}px`
|
||||||
;(el as HTMLElement | SVGElement).style.height = `${height}px`
|
;(el as HTMLElement | SVGElement).style.height = `${height}px`
|
||||||
}
|
}
|
||||||
|
if ('rel' in el && el.rel === 'stylesheet' && this.inlineRemoteCss) {
|
||||||
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
|
this.app.send(CreateElementNode(id, parentID, index, 'STYLE', false))
|
||||||
|
} else {
|
||||||
|
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (let i = 0; i < el.attributes.length; i++) {
|
for (let i = 0; i < el.attributes.length; i++) {
|
||||||
const attr = el.attributes[i]
|
const attr = el.attributes[i]
|
||||||
|
|
@ -554,12 +588,12 @@ export default abstract class Observer {
|
||||||
}
|
}
|
||||||
private commitNodes(isStart = false): void {
|
private commitNodes(isStart = false): void {
|
||||||
let node
|
let node
|
||||||
this.recents.forEach((type, id) => {
|
for (const [id, type] of this.recents.entries()) {
|
||||||
this.commitNode(id)
|
this.commitNode(id)
|
||||||
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
|
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
|
||||||
this.app.nodes.callNodeCallbacks(node, isStart)
|
this.app.nodes.callNodeCallbacks(node, isStart)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
this.clear()
|
this.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@ import { IN_BROWSER, hasOpenreplayAttribute, canAccessIframe } from '../../utils
|
||||||
export interface Options {
|
export interface Options {
|
||||||
captureIFrames: boolean
|
captureIFrames: boolean
|
||||||
disableSprites: boolean
|
disableSprites: boolean
|
||||||
|
/**
|
||||||
|
* with this option instead of using link element with href to load css,
|
||||||
|
* we will try to parse the css text instead and send it as css rules set
|
||||||
|
* can (and probably will) affect performance to certain degree,
|
||||||
|
* especially if the css itself is crossdomain
|
||||||
|
* @default false
|
||||||
|
* */
|
||||||
|
inlineRemoteCss: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Context = Window & typeof globalThis
|
type Context = Window & typeof globalThis
|
||||||
|
|
@ -29,6 +37,7 @@ export default class TopObserver extends Observer {
|
||||||
{
|
{
|
||||||
captureIFrames: true,
|
captureIFrames: true,
|
||||||
disableSprites: false,
|
disableSprites: false,
|
||||||
|
inlineRemoteCss: false,
|
||||||
},
|
},
|
||||||
params.options,
|
params.options,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
229
tracker/tracker/src/tests/cssInliner.test.ts
Normal file
229
tracker/tracker/src/tests/cssInliner.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
const mockNextID = jest.fn().mockReturnValue(123);
|
||||||
|
const mockAdoptedSSInsertRuleURLBased = jest.fn();
|
||||||
|
const mockAdoptedSSAddOwner = jest.fn();
|
||||||
|
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
import { inlineRemoteCss } from '../main/app/observer/cssInliner';
|
||||||
|
import { describe, test, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
|
||||||
|
describe('inlineRemoteCss', () => {
|
||||||
|
let mockNode;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockAdoptedSSInsertRuleURLBased.mockImplementation((id, cssText, index, baseHref) => ({
|
||||||
|
type: "AdoptedSSInsertRuleURLBased",
|
||||||
|
id,
|
||||||
|
cssText,
|
||||||
|
index,
|
||||||
|
baseHref
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockAdoptedSSAddOwner.mockImplementation((sheetId, ownerId) => ({
|
||||||
|
type: "AdoptedSSAddOwner",
|
||||||
|
sheetId,
|
||||||
|
ownerId
|
||||||
|
}));
|
||||||
|
mockNode = document.createElement('link');
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
configurable: true,
|
||||||
|
value: null,
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve('body { color: red; }')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should process rules directly if node has a sheet with accessible rules', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const mockRule = { cssText: 'body { color: red; }' };
|
||||||
|
const mockRules = [mockRule];
|
||||||
|
Object.defineProperty(mockRules, 'length', { value: 1 });
|
||||||
|
|
||||||
|
const mockSheet = { cssRules: mockRules };
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => mockSheet
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com', mockNextID,mockAdoptedSSInsertRuleURLBased,mockAdoptedSSAddOwner);
|
||||||
|
jest.runAllTimers();
|
||||||
|
expect(mockNextID).toHaveBeenCalled();
|
||||||
|
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(456, 'body { color: red; }', 0, 'http://example.com');
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch CSS if accessing rules throws an error', () => {
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
const mockSheet = {};
|
||||||
|
Object.defineProperty(mockSheet, 'cssRules', {
|
||||||
|
get: () => { throw new Error('CORS error'); }
|
||||||
|
});
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => mockSheet
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('http://example.com/style.css');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle successful fetch and process CSS text', async () => {
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
const mockSheet = {};
|
||||||
|
Object.defineProperty(mockSheet, 'cssRules', {
|
||||||
|
get: () => { throw new Error('CORS error'); }
|
||||||
|
});
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => mockSheet
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect(mockNextID).toHaveBeenCalled();
|
||||||
|
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(123, 'body { color: red; }', 0, 'http://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch CSS if node has no sheet but has href', () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('http://example.com/style.css');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle complex CSS with multiple rules', async () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
const complexCss = `
|
||||||
|
body { color: red; }
|
||||||
|
.class { background: blue; }
|
||||||
|
@media (max-width: 600px) { body { font-size: 14px; } }
|
||||||
|
`;
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(complexCss)
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect(mockNextID).toHaveBeenCalled();
|
||||||
|
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123, 'body { color: red; }', 0, 'http://example.com'
|
||||||
|
);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123, '.class { background: blue; }', 1, 'http://example.com'
|
||||||
|
);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123, '@media (max-width: 600px) { body { font-size: 14px; } }', 2, 'http://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle CSS with comments', async () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
const cssWithComments = `
|
||||||
|
/* This is a comment */
|
||||||
|
body { color: red; }
|
||||||
|
/* Another comment */
|
||||||
|
.class { background: blue; }
|
||||||
|
`;
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(cssWithComments)
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect(mockNextID).toHaveBeenCalled();
|
||||||
|
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123, 'body { color: red; }', 0, 'http://example.com'
|
||||||
|
);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123, '.class { background: blue; }', 1, 'http://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle failed fetch', async () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
global.fetch.mockRejectedValue(new Error('Network error'));
|
||||||
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Failed to fetch CSS from http://example.com/style.css:'),
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle non-OK response from fetch', async () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: () => Promise.resolve('')
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect.any(Error)
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle nested CSS rules correctly', async () => {
|
||||||
|
Object.defineProperty(mockNode, 'sheet', {
|
||||||
|
get: () => null
|
||||||
|
});
|
||||||
|
mockNode.href = 'http://example.com/style.css';
|
||||||
|
const nestedCss = `
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(nestedCss)
|
||||||
|
});
|
||||||
|
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
expect(mockNextID).toHaveBeenCalled();
|
||||||
|
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123,
|
||||||
|
'@media (max-width: 600px) {\n .header {\n font-size: 14px;\n }\n .footer {\n font-size: 12px;\n }\n }',
|
||||||
|
0,
|
||||||
|
'http://example.com'
|
||||||
|
);
|
||||||
|
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(
|
||||||
|
123,
|
||||||
|
'.container {\n padding: 20px;\n }',
|
||||||
|
1,
|
||||||
|
'http://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue