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:
Andrey Babushkin 2025-04-22 17:59:25 +02:00 committed by GitHub
parent ee71625499
commit a26411f2a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 421 additions and 39 deletions

View file

@ -352,25 +352,25 @@ const DOMMessages = [
function brokenDomSorter(m1: PlayerMsg, m2: PlayerMsg) {
if (m1.time !== m2.time) return m1.time - m2.time;
if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
return -1;
if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
return 1;
// if (m1.tp === MType.CreateDocument && m2.tp !== MType.CreateDocument)
// return -1;
// if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
// return 1;
if (m1.tp === MType.RemoveNode)
return 1;
if (m2.tp === MType.RemoveNode)
return -1;
// if (m1.tp === MType.RemoveNode)
// return 1;
// if (m2.tp === MType.RemoveNode)
// return -1;
const m1IsDOM = DOMMessages.includes(m1.tp);
const m2IsDOM = DOMMessages.includes(m2.tp);
// const m1IsDOM = DOMMessages.includes(m1.tp);
// const m2IsDOM = DOMMessages.includes(m2.tp);
// if (m1IsDOM && m2IsDOM) {
// // @ts-ignore DOM msg has id but checking for 'id' in m is expensive
// 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;
}

View file

@ -52,6 +52,8 @@ export default class DOMManager extends ListWalker<Message> {
/** required to keep track of iframes, frameId : vnodeId */
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
* as well as <style> tag owned StyleSheets
*/
@ -163,6 +165,11 @@ export default class DOMManager extends ListWalker<Message> {
}
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);
}
@ -171,24 +178,21 @@ export default class DOMManager extends ListWalker<Message> {
id: number;
index: number;
}): 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);
if (!child) {
logger.error('Insert error. Node not found', id);
return;
}
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) {
logger.error(
`${id} Insert error. Parent vNode ${parentID} not found`,
@ -305,9 +309,14 @@ export default class DOMManager extends ListWalker<Message> {
this.removeAutocomplete(vElem);
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);
return;
}
case MType.RemoveNode: {
const vChild = this.vElements.get(msg.id) || this.vTexts.get(msg.id);
if (!vChild) {
@ -440,6 +449,19 @@ export default class DOMManager extends ListWalker<Message> {
logger.error('CreateIFrameDocument: Node not found', msg);
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)) {
this.olVRoots.delete(this.iframeRoots[msg.frameID]);
}
@ -452,7 +474,7 @@ export default class DOMManager extends ListWalker<Message> {
case MType.AdoptedSsInsertRule: {
const styleSheet = this.olStyleSheets.get(msg.sheetID);
if (!styleSheet) {
logger.warn('No stylesheet was created for ', msg);
logger.warn('No stylesheet was created for ', msg, this.olStyleSheets);
return;
}
insertRule(styleSheet, msg);

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "16.1.4",
"version": "16.2.0",
"keywords": [
"logging",
"replay"

View file

@ -319,13 +319,14 @@ export default class App {
__save_canvas_locally: false,
localStorage: null,
sessionStorage: null,
disableStringDict: false,
disableStringDict: true,
forceSingleTab: false,
assistSocketHost: '',
fixedCanvasScaling: false,
disableCanvas: false,
captureIFrames: true,
disableSprites: false,
inlineRemoteCss: true,
obscureTextEmails: true,
obscureTextNumbers: false,
crossdomain: {
@ -820,7 +821,7 @@ export default class App {
this.debug.error('OpenReplay error: ', context, e)
}
send(message: Message, urgent = false): void {
send = (message: Message, urgent = false): void => {
if (this.activityState === ActivityState.NotActive) {
return
}

View 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);
});
}
}

View file

@ -10,6 +10,8 @@ import {
RemoveNode,
UnbindNodes,
SetNodeAttribute,
AdoptedSSInsertRuleURLBased,
AdoptedSSAddOwner
} from '../messages.gen.js'
import App from '../index.js'
import {
@ -21,6 +23,8 @@ import {
hasTag,
isCommentNode,
} from '../guards.js'
import { inlineRemoteCss } from './cssInliner.js'
import { nextID } from "../../modules/constructedStyleSheets.js";
const iconCache = {}
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'}">
${symbol.innerHTML}
</svg>
`.trim()
`
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'}">
${symbol.innerHTML}
</svg>
`.trim()
`
iconCache[symbolId] = inlineSvg
@ -188,13 +192,20 @@ export default abstract class Observer {
private readonly attributesMap: Map<number, Set<string>> = new Map()
private readonly textSet: Set<number> = new Set()
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()
constructor(
protected readonly app: App,
protected readonly isTopContext = false,
options: { disableSprites: boolean } = { disableSprites: false },
options: { disableSprites: boolean, inlineRemoteCss: boolean } = { disableSprites: false, inlineRemoteCss: false },
) {
this.disableSprites = options.disableSprites
this.inlineRemoteCss = options.inlineRemoteCss
this.observer = createMutationObserver(
this.app.safe((mutations) => {
for (const mutation of mutations) {
@ -280,10 +291,12 @@ export default abstract class Observer {
let removed = 0
const totalBeforeRemove = this.app.nodes.getNodeCount()
const contentDocument = iframe.contentDocument;
const nodesUnregister = this.app.nodes.unregisterNode.bind(this.app.nodes);
while (walker.nextNode()) {
if (!iframe.contentDocument.contains(walker.currentNode)) {
if (!contentDocument.contains(walker.currentNode)) {
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 {
if (isSVGElement(node)) {
if (name.substring(0, 6) === 'xlink:') {
if (name.startsWith('xlink:')) {
name = name.substring(6)
}
if (value === null) {
@ -351,6 +364,24 @@ export default abstract class Observer {
return
}
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()))
return
}
@ -506,8 +537,11 @@ export default abstract class Observer {
;(el as HTMLElement | SVGElement).style.width = `${width}px`
;(el as HTMLElement | SVGElement).style.height = `${height}px`
}
this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
if ('rel' in el && el.rel === 'stylesheet' && this.inlineRemoteCss) {
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++) {
const attr = el.attributes[i]
@ -554,12 +588,12 @@ export default abstract class Observer {
}
private commitNodes(isStart = false): void {
let node
this.recents.forEach((type, id) => {
for (const [id, type] of this.recents.entries()) {
this.commitNode(id)
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
this.app.nodes.callNodeCallbacks(node, isStart)
}
})
}
this.clear()
}

View file

@ -12,6 +12,14 @@ import { IN_BROWSER, hasOpenreplayAttribute, canAccessIframe } from '../../utils
export interface Options {
captureIFrames: 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
@ -29,6 +37,7 @@ export default class TopObserver extends Observer {
{
captureIFrames: true,
disableSprites: false,
inlineRemoteCss: false,
},
params.options,
)

View 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'
);
});
});