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 GitHub
parent 7217959992
commit 53797500bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 360 additions and 96 deletions

View file

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

View file

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

View file

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

View file

@ -1,3 +1,15 @@
## 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 ## 16.1.4
- bump proxy version to .3 - bump proxy version to .3

View file

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

View file

@ -1,3 +1,5 @@
let fakeIdHolder = 1000000 * 99;
export function inlineRemoteCss( export function inlineRemoteCss(
node: HTMLLinkElement, node: HTMLLinkElement,
id: number, id: number,
@ -5,83 +7,286 @@ export function inlineRemoteCss(
getNextID: () => number, getNextID: () => number,
insertRule: (id: number, cssText: string, index: number, baseHref: string) => any[], insertRule: (id: number, cssText: string, index: number, baseHref: string) => any[],
addOwner: (sheetId: number, ownerId: number) => any[], addOwner: (sheetId: number, ownerId: number) => any[],
forceFetch?: boolean,
sendPlain?: boolean,
onPlain?: (cssText: string, id: number) => void,
) { ) {
const sheet = node.sheet; const sheetId = getNextID();
const sheetId = getNextID()
addOwner(sheetId, id); addOwner(sheetId, id);
const processRules = (rules: CSSRuleList) => { const sheet = node.sheet;
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) => { if (sheet && !forceFetch) {
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 { try {
const rules = sheet.cssRules; const cssText = stringifyStylesheet(sheet);
processRules(rules);
} catch (e) { if (cssText) {
const href = node.href; processCssText(cssText);
if (href) { return;
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);
});
} }
} catch (e) {
console.warn("Could not stringify sheet, falling back to fetch:", e);
} }
} else if (node.href) { }
// Fall back to fetching if we couldn't get or stringify the sheet
if (node.href) {
fetch(node.href) fetch(node.href)
.then(response => response.text()) .then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch CSS: ${response.status}`);
}
return response.text();
})
.then(cssText => { .then(cssText => {
if (sendPlain && onPlain) {
onPlain(cssText, fakeIdHolder++);
}
processCssText(cssText); processCssText(cssText);
}) })
.catch(error => { .catch(error => {
console.error(`Failed to fetch CSS from ${node.href}:`, error); console.error(`Failed to fetch CSS from ${node.href}:`, error);
}); });
} }
}
function processCssText(cssText: string) {
// Remove comments
cssText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
// Parse and process the CSS text to extract rules
const ruleTexts = parseCSS(cssText);
for (let i = 0; i < ruleTexts.length; i++) {
insertRule(sheetId, ruleTexts[i], i, baseHref);
}
}
function parseCSS(cssText: string): string[] {
const rules: string[] = [];
let inComment = false;
let inString = false;
let stringChar = '';
let braceLevel = 0;
let currentRule = '';
for (let i = 0; i < cssText.length; i++) {
const char = cssText[i];
const nextChar = cssText[i + 1] || '';
// comments
if (!inString && char === '/' && nextChar === '*') {
inComment = true;
i++; // Skip the next character
continue;
}
if (inComment) {
if (char === '*' && nextChar === '/') {
inComment = false;
i++; // Skip the next character
}
continue;
}
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
currentRule += char;
continue;
}
if (inString) {
currentRule += char;
if (char === stringChar && cssText[i - 1] !== '\\') {
inString = false;
}
continue;
}
currentRule += char;
if (char === '{') {
braceLevel++;
} else if (char === '}') {
braceLevel--;
if (braceLevel === 0) {
// End of a top-level rule
rules.push(currentRule.trim());
currentRule = '';
}
}
}
// Handle any remaining text (should be rare)
if (currentRule.trim()) {
rules.push(currentRule.trim());
}
return rules;
}
function stringifyStylesheet(s: CSSStyleSheet): string | null {
try {
const rules = s.rules || s.cssRules;
if (!rules) {
return null;
}
let sheetHref = s.href;
if (!sheetHref && s.ownerNode && (s.ownerNode as HTMLElement).ownerDocument) {
// an inline <style> element
sheetHref = (s.ownerNode as HTMLElement).ownerDocument.location.href;
}
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
stringifyRule(rule, sheetHref)
).join('');
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
} catch (error) {
return null;
}
}
function stringifyRule(rule: CSSRule, sheetHref: string | null): string {
if (isCSSImportRule(rule)) {
let importStringified;
try {
importStringified =
// for same-origin stylesheets,
// we can access the imported stylesheet rules directly
stringifyStylesheet((rule as any).styleSheet) ||
// work around browser issues with the raw string `@import url(...)` statement
escapeImportStatement(rule as any);
} catch (error) {
importStringified = rule.cssText;
}
if ((rule as any).styleSheet.href) {
// url()s within the imported stylesheet are relative to _that_ sheet's href
return absolutifyURLs(importStringified, (rule as any).styleSheet.href);
}
return importStringified;
} else {
let ruleStringified = rule.cssText;
if (isCSSStyleRule(rule) && (rule as any).selectorText.includes(':')) {
// Safari does not escape selectors with : properly
ruleStringified = fixSafariColons(ruleStringified);
}
if (sheetHref) {
return absolutifyURLs(ruleStringified, sheetHref);
}
return ruleStringified;
}
}
function fixBrowserCompatibilityIssuesInCSS(cssText: string): string {
// Fix for Chrome's handling of webkit-background-clip
if (
cssText.includes(' background-clip: text;') &&
!cssText.includes(' -webkit-background-clip: text;')
) {
cssText = cssText.replace(
/\sbackground-clip:\s*text;/g,
' -webkit-background-clip: text; background-clip: text;',
);
}
return cssText;
}
function escapeImportStatement(rule: any): string {
const { cssText } = rule;
if (cssText.split('"').length < 3) return cssText;
const statement = ['@import', `url(${JSON.stringify(rule.href)})`];
if (rule.layerName === '') {
statement.push(`layer`);
} else if (rule.layerName) {
statement.push(`layer(${rule.layerName})`);
}
if (rule.supportsText) {
statement.push(`supports(${rule.supportsText})`);
}
if (rule.media.length) {
statement.push(rule.media.mediaText);
}
return statement.join(' ') + ';';
}
function fixSafariColons(cssStringified: string): string {
const regex = /(\[(?:[\w-]+)[^\\])(:(?:[\w-]+)\])/gm;
return cssStringified.replace(regex, '$1\\$2');
}
function isCSSImportRule(rule: CSSRule): boolean {
return 'styleSheet' in rule;
}
function isCSSStyleRule(rule: CSSRule): boolean {
return 'selectorText' in rule;
}
function absolutifyURLs(cssText: string | null, href: string): string {
if (!cssText) return '';
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
const URL_WWW_MATCH = /^www\..*/i;
const DATA_URI = /^(data:)([^,]*),(.*)/i;
return cssText.replace(
URL_IN_CSS_REF,
(
origin: string,
quote1: string,
path1: string,
quote2: string,
path2: string,
path3: string,
) => {
const filePath = path1 || path2 || path3;
const maybeQuote = quote1 || quote2 || '';
if (!filePath) {
return origin;
}
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
}
if (DATA_URI.test(filePath)) {
return `url(${maybeQuote}${filePath}${maybeQuote})`;
}
if (filePath[0] === '/') {
return `url(${maybeQuote}${
extractOrigin(href) + filePath
}${maybeQuote})`;
}
const stack = href.split('/');
const parts = filePath.split('/');
stack.pop();
for (const part of parts) {
if (part === '.') {
continue;
} else if (part === '..') {
stack.pop();
} else {
stack.push(part);
}
}
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
},
);
}
function extractOrigin(url: string): string {
let origin = '';
if (url.indexOf('//') > -1) {
origin = url.split('/').slice(0, 3).join('/');
} else {
origin = url.split('/')[0];
}
origin = origin.split('?')[0];
return origin;
}
}

View file

@ -184,6 +184,15 @@ enum RecentsType {
Changed, Changed,
} }
interface Options {
inlineRemoteCss?: boolean,
disableSprites?: boolean,
inlinerOptions?: {
forceFetch?: boolean,
forcePlain?: boolean,
}
}
export default abstract class Observer { export default abstract class Observer {
private readonly observer: MutationObserver private readonly observer: MutationObserver
private readonly commited: Array<boolean | undefined> = [] private readonly commited: Array<boolean | undefined> = []
@ -198,14 +207,16 @@ export default abstract class Observer {
* can (and will) affect performance * can (and will) affect performance
* */ * */
private readonly inlineRemoteCss: boolean = false private readonly inlineRemoteCss: boolean = false
private readonly inlinerOptions: Options['inlinerOptions'] = undefined
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: boolean = false,
options: { disableSprites: boolean, inlineRemoteCss: boolean } = { disableSprites: false, inlineRemoteCss: false }, options: Options = {},
) { ) {
this.disableSprites = options.disableSprites this.disableSprites = Boolean(options.disableSprites)
this.inlineRemoteCss = options.inlineRemoteCss this.inlineRemoteCss = Boolean(options.inlineRemoteCss)
this.inlinerOptions = options.inlinerOptions
this.observer = createMutationObserver( this.observer = createMutationObserver(
this.app.safe((mutations) => { this.app.safe((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
@ -368,15 +379,23 @@ export default abstract class Observer {
setTimeout(() => { setTimeout(() => {
inlineRemoteCss( inlineRemoteCss(
// @ts-ignore // @ts-ignore
node, node,
id, id,
this.app.getBaseHref(), this.app.getBaseHref(),
nextID, nextID,
(id: number, cssText: string, index: number, baseHref: string) => { (id: number, cssText: string, index: number, baseHref: string) => {
this.app.send(AdoptedSSInsertRuleURLBased(id, cssText, index, baseHref)) this.app.send(AdoptedSSInsertRuleURLBased(id, cssText, index, baseHref))
}, },
(sheetId: number, ownerId: number) => { (sheetId: number, ownerId: number) => {
this.app.send(AdoptedSSAddOwner(sheetId, ownerId)) this.app.send(AdoptedSSAddOwner(sheetId, ownerId))
},
this.inlinerOptions?.forceFetch,
this.inlinerOptions?.forcePlain,
(cssText: string, fakeTextId: number) => {
this.app.send(CreateTextNode(fakeTextId, id, 0))
setTimeout(() => {
this.app.send(SetNodeData(fakeTextId, cssText))
}, 10)
} }
) )
}, 0) }, 0)

View file

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

View file

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