openreplay/frontend/app/player/web/managers/DOM/DOMManager.ts
Andrey Babushkin 53f3623481
Css inliner tuning (#3337)
* tracker: don't send double sheets

* tracker: don't send double sheets

* tracker: slot checker

* add slot tag to custom elements

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>
2025-04-25 17:45:21 +02:00

626 lines
20 KiB
TypeScript

import logger from 'App/logger';
import { resolveCSS } from '../../messages/rewriter/urlResolve';
import type Screen from '../../Screen/Screen';
import type { Message, SetNodeScroll } from '../../messages';
import { MType } from '../../messages';
import ListWalker from '../../../common/ListWalker';
import StylesManager from './StylesManager';
import FocusManager from './FocusManager';
import SelectionManager from './SelectionManager';
import {
StyleElement,
VSpriteMap,
OnloadStyleSheet,
VDocument,
VElement,
VHTMLElement,
VShadowRoot,
VText,
OnloadVRoot,
} from './VirtualDOM';
import { deleteRule, insertRule } from './safeCSSRules';
function isStyleVElement(
vElem: VElement,
): vElem is VElement & { node: StyleElement } {
return vElem.tagName.toLowerCase() === 'style';
}
function setupWindowLogging(
vTexts: Map<number, VText>,
vElements: Map<number, VElement>,
olVRoots: Map<number, OnloadVRoot>,
) {
// @ts-ignore
window.checkVElements = () => vElements;
// @ts-ignore
window.checkVTexts = () => vTexts;
// @ts-ignore
window.checkVRoots = () => olVRoots;
}
const IGNORED_ATTRS = ['autocomplete'];
const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/;
export default class DOMManager extends ListWalker<Message> {
private readonly vTexts: Map<number, VText> = new Map(); // map vs object here?
private readonly vElements: Map<number, VElement> = new Map();
private readonly olVRoots: Map<number, OnloadVRoot> = new Map();
/** 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
*/
private olStyleSheets: Map<number, OnloadStyleSheet> = new Map();
/** @depreacted since tracker 4.0.2 Mapping by nodeID */
private olStyleSheetsDeprecated: Map<number, OnloadStyleSheet> = new Map();
private upperBodyId: number = -1;
private nodeScrollManagers: Map<number, ListWalker<SetNodeScroll>> =
new Map();
private stylesManager: StylesManager;
private focusManager: FocusManager = new FocusManager(this.vElements);
private selectionManager: SelectionManager;
private readonly screen: Screen;
private readonly isMobile: boolean;
private readonly stringDict: Record<number, string>;
private readonly globalDict: {
get: (key: string) => string | undefined;
all: () => Record<string, string>;
};
public readonly time: number;
constructor(params: {
screen: Screen;
isMobile: boolean;
setCssLoading: ConstructorParameters<typeof StylesManager>[1];
time: number;
stringDict: Record<number, string>;
globalDict: {
get: (key: string) => string | undefined;
all: () => Record<string, string>;
};
}) {
super();
this.screen = params.screen;
this.isMobile = params.isMobile;
this.time = params.time;
this.stringDict = params.stringDict;
this.globalDict = params.globalDict;
this.selectionManager = new SelectionManager(this.vElements, params.screen);
this.stylesManager = new StylesManager(params.screen, params.setCssLoading);
setupWindowLogging(this.vTexts, this.vElements, this.olVRoots);
}
public clearSelectionManager() {
this.selectionManager.clearSelection();
}
append(m: Message): void {
if (m.tp === MType.SetNodeScroll) {
let scrollManager = this.nodeScrollManagers.get(m.id);
if (!scrollManager) {
scrollManager = new ListWalker();
this.nodeScrollManagers.set(m.id, scrollManager);
}
scrollManager.append(m);
return;
}
if (m.tp === MType.SetNodeFocus) {
this.focusManager.append(m);
return;
}
if (m.tp === MType.SelectionChange) {
this.selectionManager.append(m);
return;
}
if (m.tp === MType.CreateElementNode) {
if (m.tag === 'BODY' && this.upperBodyId === -1) {
this.upperBodyId = m.id;
}
} else if (
m.tp === MType.SetNodeAttribute &&
(IGNORED_ATTRS.includes(m.name) || !ATTR_NAME_REGEXP.test(m.name))
) {
logger.log('Ignorring message: ', m);
return; // Ignoring
}
super.append(m);
}
private removeBodyScroll(id: number, vElem: VElement): void {
if (this.isMobile && this.upperBodyId === id) {
// Need more type safety!
(vElem.node as HTMLBodyElement).style.overflow = 'hidden';
}
}
private removeAutocomplete(vElem: VElement): boolean {
const tag = vElem.tagName;
if (['FORM', 'TEXTAREA', 'SELECT'].includes(tag)) {
vElem.setAttribute('autocomplete', 'off');
return true;
}
if (tag === 'INPUT') {
vElem.setAttribute('autocomplete', 'new-password');
return true;
}
return false;
}
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);
}
private insertNode(msg: {
parentID: number;
id: number;
index: number;
}): void {
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 (!parent) {
logger.error(
`${id} Insert error. Parent vNode ${parentID} not found`,
msg,
this.vElements,
this.olVRoots,
);
return;
}
if (parent instanceof VElement && isStyleVElement(parent)) {
// TODO: if this ever happens? ; Maybe do not send empty TextNodes in tracker
const styleNode = parent.node;
if (
styleNode.sheet &&
styleNode.sheet.cssRules &&
styleNode.sheet.cssRules.length > 0 &&
styleNode.textContent &&
styleNode.textContent.trim().length === 0
) {
logger.log(
'Trying to insert child to a style tag with virtual rules: ',
parent,
child,
);
return;
}
}
parent.insertChildAt(child, index);
}
private setNodeAttribute(msg: { id: number; name: string; value: string }) {
let { name, value } = msg;
const vn = this.vElements.get(msg.id);
if (!vn) {
logger.error('SetNodeAttribute: Node not found', msg);
return;
}
if (vn.tagName === 'INPUT' && name === 'name') {
// Otherwise binds local autocomplete values (maybe should ignore on the tracker level?)
return;
}
if (name === 'href' && vn.tagName === 'LINK') {
// @ts-ignore ?global ENV type // It've been done on backend (remove after testing in saas)
// if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) {
// value = value.replace("?", "%3F");
// }
if (!value.startsWith('http')) {
/* blob:... value can happen here for some reason.
* which will result in that link being unable to load and having 4sec timeout in the below function.
*/
return;
}
// TODOTODO: check if node actually exists on the page, not just in memory
this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value);
}
if (vn.isSVG && value.startsWith('url(')) {
/* SVG shape ID-s for masks etc. Sometimes referred with the full-page url, which we don't have in replay */
value = `url(#${value.split('#')[1] || ')'}`;
}
vn.setAttribute(name, value);
this.removeBodyScroll(msg.id, vn);
}
private applyMessage = (msg: Message): Promise<any> | undefined => {
switch (msg.tp) {
case MType.CreateDocument: {
const doc = this.screen.document;
if (!doc) {
logger.error('No root iframe document found', msg, this.screen);
return;
}
doc.open();
doc.write('<!DOCTYPE html><html></html>');
doc.close();
const fRoot = doc.documentElement;
fRoot.innerText = '';
const vHTMLElement = new VHTMLElement(fRoot);
this.vElements.clear();
this.vElements.set(0, vHTMLElement);
const vDoc = OnloadVRoot.fromDocumentNode(doc);
vDoc.insertChildAt(vHTMLElement, 0);
this.olVRoots.clear();
this.olVRoots.set(0, vDoc); // watchout: id==0 for both Document and documentElement
// this is done for the AdoptedCSS logic
// Maybetodo: start Document as 0-node in tracker
this.vTexts.clear();
this.stylesManager.reset();
return;
}
case MType.CreateTextNode: {
const vText = new VText();
this.vTexts.set(msg.id, vText);
this.insertNode(msg);
return;
}
case MType.CreateElementNode: {
// if (msg.tag.toLowerCase() === 'canvas') msg.tag = 'video'
const vElem = new VElement(msg.tag, msg.svg, msg.index, msg.id);
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
vElem.prioritized = true;
}
if (this.vElements.has(msg.id)) {
logger.error('CreateElementNode: Node already exists', msg);
return;
}
this.vElements.set(msg.id, vElem);
this.insertNode(msg);
this.removeBodyScroll(msg.id, vElem);
this.removeAutocomplete(vElem);
return;
}
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) {
logger.error('RemoveNode: Node not found', msg);
return;
}
if (!vChild.parentNode) {
logger.error('RemoveNode: Parent node not found', msg);
return;
}
vChild.parentNode.removeChild(vChild);
this.vElements.delete(msg.id);
this.vTexts.delete(msg.id);
return;
}
case MType.SetNodeAttribute:
this.setNodeAttribute(msg);
return;
case MType.SetNodeAttributeDictGlobal:
case MType.SetNodeAttributeDict:
const name = this.globalDict.get(msg.name);
const value = this.globalDict.get(msg.value);
if (name === undefined) {
logger.error(
"No dictionary key for msg 'name': ",
msg,
this.globalDict.all(),
);
return;
}
if (value === undefined) {
logger.error(
"No dictionary key for msg 'value': ",
msg,
this.globalDict.all(),
);
return;
}
this.setNodeAttribute({
id: msg.id,
name,
value,
});
return;
case MType.SetNodeAttributeDictDeprecated:
this.stringDict[msg.nameKey] === undefined &&
logger.error(
"No local dictionary key for msg 'name': ",
msg,
this.stringDict,
);
this.stringDict[msg.valueKey] === undefined &&
logger.error(
"No local dictionary key for msg 'value': ",
msg,
this.stringDict,
);
if (
this.stringDict[msg.nameKey] === undefined ||
this.stringDict[msg.valueKey] === undefined
) {
return;
}
this.setNodeAttribute({
id: msg.id,
name: this.stringDict[msg.nameKey],
value: this.stringDict[msg.valueKey],
});
return;
case MType.RemoveNodeAttribute: {
const vElem = this.vElements.get(msg.id);
if (!vElem) {
logger.error('RemoveNodeAttribute: Node not found', msg);
return;
}
vElem.removeAttribute(msg.name);
return;
}
case MType.SetInputValue: {
const vElem = this.vElements.get(msg.id);
if (!vElem) {
logger.error('SetInoputValue: Node not found', msg);
return;
}
const nodeWithValue = vElem.node;
if (
!(
nodeWithValue instanceof HTMLInputElement ||
nodeWithValue instanceof HTMLTextAreaElement ||
nodeWithValue instanceof HTMLSelectElement
)
) {
logger.error('Trying to set value of non-Input element', msg);
return;
}
const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value;
const doc = this.screen.document;
if (doc && nodeWithValue === doc.activeElement) {
// For the case of Remote Control
nodeWithValue.onblur = () => {
nodeWithValue.value = val;
};
return;
}
nodeWithValue.value = val; // Maybe make special VInputValueElement type for lazy value update
return;
}
case MType.SetInputChecked: {
const vElem = this.vElements.get(msg.id);
if (!vElem) {
logger.error('SetInputChecked: Node not found', msg);
return;
}
(vElem.node as HTMLInputElement).checked = msg.checked; // Maybe make special VCheckableElement type for lazy checking
return;
}
case MType.SetNodeData:
case MType.SetCssData: {
const vText = this.vTexts.get(msg.id);
if (!vText) {
logger.error('SetNodeData/SetCssData: Node not found', msg);
return;
}
vText.setData(msg.data);
return;
}
case MType.CreateIFrameDocument: {
const vElem = this.vElements.get(msg.frameID);
if (!vElem) {
logger.error('CreateIFrameDocument: Node not found', msg);
return;
}
// shadow DOM for a custom element + SALESFORCE (<slot>)
const isCustomElement = vElem.tagName.includes('-') || vElem.tagName === 'SLOT';
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]);
}
this.iframeRoots[msg.frameID] = msg.id;
const vRoot = OnloadVRoot.fromVElement(vElem);
vRoot.catch((e) => logger.warn(e, msg));
this.olVRoots.set(msg.id, vRoot);
return;
}
case MType.AdoptedSsInsertRule: {
const styleSheet = this.olStyleSheets.get(msg.sheetID);
if (!styleSheet) {
logger.warn('No stylesheet was created for ', msg, this.olStyleSheets);
return;
}
insertRule(styleSheet, msg);
return;
}
case MType.AdoptedSsDeleteRule: {
const styleSheet = this.olStyleSheets.get(msg.sheetID);
if (!styleSheet) {
logger.warn('No stylesheet was created for ', msg);
return;
}
deleteRule(styleSheet, msg);
return;
}
case MType.AdoptedSsReplace: {
const styleSheet = this.olStyleSheets.get(msg.sheetID);
if (!styleSheet) {
logger.warn('No stylesheet was created for ', msg);
return;
}
// @ts-ignore (configure ts with recent WebaAPI)
styleSheet.replaceSync(msg.text);
return;
}
case MType.AdoptedSsAddOwner: {
const vRoot = this.olVRoots.get(msg.id);
if (!vRoot) {
/* <style> tag case */
const vElem = this.vElements.get(msg.id);
if (!vElem) {
logger.error('AdoptedSsAddOwner: Node not found', msg);
return;
}
if (!isStyleVElement(vElem)) {
logger.error('Non-style owner', msg);
return;
}
this.olStyleSheets.set(
msg.sheetID,
OnloadStyleSheet.fromStyleElement(vElem.node),
);
return;
}
/* Constructed StyleSheet case */
let olStyleSheet = this.olStyleSheets.get(msg.sheetID);
if (!olStyleSheet) {
olStyleSheet = OnloadStyleSheet.fromVRootContext(vRoot);
this.olStyleSheets.set(msg.sheetID, olStyleSheet);
}
olStyleSheet.whenReady((styleSheet) => {
vRoot.onNode((node) => {
// @ts-ignore
node.adoptedStyleSheets = [...node.adoptedStyleSheets, styleSheet];
});
});
return;
}
case MType.AdoptedSsRemoveOwner: {
const olStyleSheet = this.olStyleSheets.get(msg.sheetID);
if (!olStyleSheet) {
logger.warn(
'AdoptedSsRemoveOwner: No stylesheet was created for ',
msg,
);
return;
}
const vRoot = this.olVRoots.get(msg.id);
if (!vRoot) {
logger.error('AdoptedSsRemoveOwner: Owner node not found', msg);
return;
}
olStyleSheet.whenReady((styleSheet) => {
vRoot.onNode((node) => {
// @ts-ignore
node.adoptedStyleSheets = [...vRoot.node.adoptedStyleSheets].filter(
(s) => s !== styleSheet,
);
});
});
return;
}
case MType.LoadFontFace: {
const vRoot = this.olVRoots.get(msg.parentID);
if (!vRoot) {
logger.error('LoadFontFace: Node not found', msg);
return;
}
vRoot.whenReady((vNode) => {
if (vNode instanceof VShadowRoot) {
logger.error(`Node ${vNode} expected to be a Document`, msg);
return;
}
let descr: object | undefined;
if (msg.descriptors) {
try {
descr = JSON.parse(msg.descriptors);
descr = typeof descr === 'object' ? descr : undefined;
} catch {
logger.warn("Can't parse font-face descriptors: ", msg);
}
}
const ff = new FontFace(msg.family, msg.source, descr);
vNode.node.fonts.add(ff);
void ff.load();
});
}
}
};
/**
* Moves and applies all the messages from the current (or from the beginning, if t < current.time)
* to the one with msg[time] >= `t`
*
* This function autoresets pointer if necessary (better name?)
*
* @returns Promise that fulfills when necessary changes get applied
* (the async part exists mostly due to styles loading)
*/
async moveReady(t: number): Promise<void> {
this.moveApply(t, this.applyMessage);
this.olVRoots.forEach((rt) => rt.applyChanges());
// Thinkabout (read): css preload
// What if we go back before it is ready? We'll have two handlres?
return this.stylesManager.moveReady().then(() => {
/* Waiting for styles to be applied first */
/* Applying focus */
this.focusManager.move(t);
/* Applying text selections */
this.selectionManager.move(t);
/* Applying all scrolls */
this.nodeScrollManagers.forEach((manager) => {
const msg = manager.moveGetLast(t);
if (msg) {
let scrollVHost: VElement | OnloadVRoot | undefined;
if ((scrollVHost = this.vElements.get(msg.id))) {
scrollVHost.node.scrollLeft = msg.x;
scrollVHost.node.scrollTop = msg.y;
} else if ((scrollVHost = this.olVRoots.get(msg.id))) {
scrollVHost.whenReady((vNode) => {
if (vNode instanceof VDocument) {
vNode.node.defaultView?.scrollTo(msg.x, msg.y);
}
});
}
}
});
});
}
}