tracker css batching/inlining (#3334)

* tracker: initial css inlining functionality

* tracker: add tests, adjust sheet id, stagger rule sending

* removed sorting

* upgrade css inliner

* ui: better logging for ocunter

* tracker: force-fetch mode for cssInliner

* tracker: fix ts warns

* tracker: use debug opts

* tracker: 16.2.0 changelogs, inliner opts

* tracker: remove debug options

---------

Co-authored-by: Андрей Бабушкин <andreybabushkin2000@gmail.com>
This commit is contained in:
Delirium 2025-04-24 12:16:51 +02:00 committed by Delirium
parent 0360e3726e
commit 85e30b3692
9 changed files with 103 additions and 35 deletions

View file

@ -348,19 +348,19 @@ export default class TabSessionManager {
break;
case MType.CreateTextNode:
case MType.CreateElementNode:
this.windowNodeCounter.addNode(msg.id, msg.parentID);
this.windowNodeCounter.addNode(msg);
this.performanceTrackManager.setCurrentNodesCount(
this.windowNodeCounter.count,
);
break;
case MType.MoveNode:
this.windowNodeCounter.moveNode(msg.id, msg.parentID);
this.windowNodeCounter.moveNode(msg);
this.performanceTrackManager.setCurrentNodesCount(
this.windowNodeCounter.count,
);
break;
case MType.RemoveNode:
this.windowNodeCounter.removeNode(msg.id);
this.windowNodeCounter.removeNode(msg);
this.performanceTrackManager.setCurrentNodesCount(
this.windowNodeCounter.count,
);

View file

@ -257,6 +257,7 @@ export class VElement extends VParent<Element> {
applyChanges() {
this.prioritized && this.applyPrioritizedChanges();
this.node.data = this.data;
this.applyAttributeChanges();
super.applyChanges();
}

View file

@ -54,40 +54,45 @@ export default class WindowNodeCounter {
this.nodes = [this.root];
}
addNode(id: number, parentID: number) {
addNode(msg: { id: number, parentID: number, time: number }): boolean {
const { id, parentID } = msg;
if (!this.nodes[parentID]) {
// TODO: iframe case
// console.error(`Wrong! Node with id ${ parentID } (parentId) not found.`);
return;
return false;
}
if (this.nodes[id]) {
// console.error(`Wrong! Node with id ${ id } already exists.`);
return;
return false;
}
this.nodes[id] = this.nodes[parentID].newChild();
return true;
}
removeNode(id: number) {
removeNode({ id }: { id: number }) {
if (!this.nodes[id]) {
// Might be text node
// console.error(`Wrong! Node with id ${ id } not found.`);
return;
return false;
}
this.nodes[id].removeNode();
return true;
}
moveNode(id: number, parentId: number) {
moveNode(msg: { id: number, parentID: number, time: number }) {
const { id, parentID, time } = msg;
if (!this.nodes[id]) {
console.warn(`Node Counter: Node with id ${id} not found.`);
return;
console.warn(`Node Counter: Node with id ${id} (parent: ${parentID}) not found. time: ${time}`);
return false;
}
if (!this.nodes[parentId]) {
if (!this.nodes[parentID]) {
console.warn(
`Node Counter: Node with id ${parentId} (parentId) not found.`,
`Node Counter: Node with id ${parentID} (parentId) not found. time: ${time}`,
);
return;
return false;
}
this.nodes[id].moveNode(this.nodes[parentId]);
this.nodes[id].moveNode(this.nodes[parentID]);
return true;
}
get count() {

View file

@ -1,3 +1,46 @@
## 16.2.0
- css batching and inlining via (!plain mode will cause fake text nodes in style tags occupying 99*10^6 id space, can conflict with crossdomain iframes!)
```
inlineRemoteCss: boolean
inlinerOptions?: {
forceFetch?: boolean,
forcePlain?: boolean,
}
```
## 16.1.4
- bump proxy version to .3
## 16.1.3
- same as previous, more strict checks for body obj
## 16.1.2
- bump networkProxy version (prevent reusage of body streams, fix for sanitizer network body checks)
## 16.1.1
- fixing debug logs from 16.1.0
## 16.1.0
- new `privateMode` option to hide all possible data from tracking
- update `networkProxy` to 1.1.0 (auto sanitizer for sensitive parameters in network requests)
- reduced the frequency of performance tracker calls
- reduced the number of events when the user is idle
## 16.0.3
- better handling for local svg spritemaps
## 16.0.2
- fix attributeSender key generation to prevent calling native methods on objects
## 16.0.1
- drop computing ts digits
@ -36,7 +79,7 @@ tracker.start()
## 15.0.5
- update medv/finder to 4.0.2 for better support of css-in-js libs
- fixes for single tab recording
- fixes for single tab recording
- add option to disable network completely `{ network: { disabled: true } }`
- fix for batching during offline recording syncs

View file

@ -318,16 +318,14 @@ export default class App {
__save_canvas_locally: false,
localStorage: null,
sessionStorage: null,
disableStringDict: true,
forceSingleTab: false,
assistSocketHost: '',
fixedCanvasScaling: false,
disableCanvas: false,
captureIFrames: true,
disableSprites: false,
inlineRemoteCss: true,
obscureTextEmails: true,
obscureTextEmails: false,
obscureTextNumbers: false,
disableStringDict: false,
crossdomain: {
parentDomain: '*',
},
@ -338,6 +336,12 @@ export default class App {
useAnimationFrame: false,
},
forceNgOff: false,
inlineRemoteCss: false,
disableSprites: false,
inlinerOptions: {
forceFetch: false,
forcePlain: false,
}
}
this.options = simpleMerge(defaultOptions, options)

View file

@ -11,10 +11,8 @@ export function inlineRemoteCss(
sendPlain?: boolean,
onPlain?: (cssText: string, id: number) => void,
) {
const sheetId = sendPlain ? null : getNextID();
if (!sendPlain) {
addOwner(sheetId!, id);
}
const sheetId = getNextID();
addOwner(sheetId, id);
const sheet = node.sheet;
@ -27,7 +25,7 @@ export function inlineRemoteCss(
return;
}
} catch (e) {
// console.warn("Could not stringify sheet, falling back to fetch:", e);
console.warn("Could not stringify sheet, falling back to fetch:", e);
}
}
@ -36,19 +34,18 @@ export function inlineRemoteCss(
fetch(node.href)
.then(response => {
if (!response.ok) {
throw new Error(`response status ${response.status}`);
throw new Error(`Failed to fetch CSS: ${response.status}`);
}
return response.text();
})
.then(cssText => {
if (sendPlain && onPlain) {
onPlain(cssText, fakeIdHolder++);
} else {
processCssText(cssText);
}
processCssText(cssText);
})
.catch(error => {
console.error(`OpenReplay: Failed to fetch CSS from ${node.href}:`, error);
console.error(`Failed to fetch CSS from ${node.href}:`, error);
});
}
@ -60,7 +57,7 @@ export function inlineRemoteCss(
const ruleTexts = parseCSS(cssText);
for (let i = 0; i < ruleTexts.length; i++) {
insertRule(sheetId!, ruleTexts[i], i, baseHref);
insertRule(sheetId, ruleTexts[i], i, baseHref);
}
}

View file

@ -394,7 +394,7 @@ export default abstract class Observer {
(cssText: string, fakeTextId: number) => {
this.app.send(CreateTextNode(fakeTextId, id, 0))
setTimeout(() => {
this.app.send(SetCSSDataURLBased(fakeTextId, cssText, this.app.getBaseHref()))
this.app.send(SetNodeData(fakeTextId, cssText))
}, 10)
}
)

View file

@ -20,6 +20,10 @@ export interface Options {
* @default false
* */
inlineRemoteCss: boolean
inlinerOptions?: {
forceFetch?: boolean,
forcePlain?: boolean,
}
}
type Context = Window & typeof globalThis
@ -95,7 +99,7 @@ export default class TopObserver extends Observer {
this.app.debug.info('doc already observed for', id)
return
}
const observer = new IFrameObserver(this.app)
const observer = new IFrameObserver(this.app, false, {})
this.iframeObservers.set(iframe, observer)
this.docObservers.set(currentDoc, observer)
this.iframeObserversArr.push(observer)

View file

@ -53,7 +53,7 @@ describe('inlineRemoteCss', () => {
jest.runAllTimers();
expect(mockNextID).toHaveBeenCalled();
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(456, 'body { color: red; }', 0, 'http://example.com');
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(123, 'body { color: red; }', 0, 'http://example.com');
jest.useRealTimers();
});
@ -73,6 +73,12 @@ describe('inlineRemoteCss', () => {
test('should handle successful fetch and process CSS text', async () => {
mockNode.href = 'http://example.com/style.css';
const mockSheet = {};
global.fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
text: () => Promise.resolve('body { color: red; }')
})
);
Object.defineProperty(mockSheet, 'cssRules', {
get: () => { throw new Error('CORS error'); }
});
@ -80,18 +86,26 @@ describe('inlineRemoteCss', () => {
get: () => mockSheet
});
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
await new Promise(process.nextTick);
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockNextID).toHaveBeenCalled();
expect(mockAdoptedSSAddOwner).toHaveBeenCalledWith(123, 456);
expect(mockAdoptedSSInsertRuleURLBased).toHaveBeenCalledWith(123, 'body { color: red; }', 0, 'http://example.com');
jest.useRealTimers();
});
test('should fetch CSS if node has no sheet but has href', () => {
test('should fetch CSS if node has no sheet but has href', async () => {
Object.defineProperty(mockNode, 'sheet', {
get: () => null
});
global.fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
text: () => Promise.resolve('body { color: red; }')
})
);
mockNode.href = 'http://example.com/style.css';
inlineRemoteCss(mockNode, 456, 'http://example.com',mockNextID,mockAdoptedSSInsertRuleURLBased, mockAdoptedSSAddOwner);
await new Promise(resolve => setTimeout(resolve, 0));
expect(global.fetch).toHaveBeenCalledWith('http://example.com/style.css');
});