From 41797730d3cbc4914d7dff7dfeaa70a0c394d85f Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 29 Jun 2022 10:44:14 +0200 Subject: [PATCH 01/12] fix(frontend/player):add Select to set_input_value --- .../app/player/MessageDistributor/managers/DOMManager.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOMManager.ts index 43c7a274c..37d24aa44 100644 --- a/frontend/app/player/MessageDistributor/managers/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOMManager.ts @@ -209,9 +209,12 @@ export default class DOMManager extends ListWalker { case "set_input_value": node = this.nl[ msg.id ] if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement)) { - logger.error("Trying to set value of non-Input element", msg) - return + if (!(node instanceof HTMLInputElement + || node instanceof HTMLTextAreaElement + || node instanceof HTMLSelectElement) + ) { + logger.error("Trying to set value of non-Input element", msg) + return } const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value doc = this.screen.document From 8b00543d8bfa0deb7083967963478d9409b2d37f Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Tue, 5 Jul 2022 18:33:52 +0200 Subject: [PATCH 02/12] feat(frontend/player): virtualisation of the DOM operations --- .../managers/DOM/DOMManager.ts | 305 ++++++++++++++++ .../managers/{ => DOM}/StylesManager.ts | 12 +- .../managers/DOM/VirtualDOM.ts | 143 ++++++++ .../MessageDistributor/managers/DOMManager.ts | 325 ------------------ .../managers/PagesManager.ts | 2 +- 5 files changed, 455 insertions(+), 332 deletions(-) create mode 100644 frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts rename frontend/app/player/MessageDistributor/managers/{ => DOM}/StylesManager.ts (90%) create mode 100644 frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts delete mode 100644 frontend/app/player/MessageDistributor/managers/DOMManager.ts diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts new file mode 100644 index 000000000..278e97ba4 --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -0,0 +1,305 @@ +import logger from 'App/logger'; + +import type StatedScreen from '../../StatedScreen'; +import type { Message, SetNodeScroll, CreateElementNode } from '../../messages'; + +import ListWalker from '../ListWalker'; +import StylesManager, { rewriteNodeStyleSheet } from './StylesManager'; +import { VElement, VText, VFragment, VDocument, VNode, VStyleElement } from './VirtualDOM'; +import type { StyleElement } from './VirtualDOM'; + + +type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + +const IGNORED_ATTRS = [ "autocomplete", "name" ]; +const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ + +export default class DOMManager extends ListWalker { + private isMobile: boolean; + private screen: StatedScreen; + private vTexts: Map = new Map() // map vs object here? + private vElements: Map = new Map() + private vRoots: Map = new Map() + + + private upperBodyId: number = -1; + private nodeScrollManagers: Array> = [] + private stylesManager: StylesManager + + + constructor(screen: StatedScreen, isMobile: boolean, public readonly time: number) { + super(); + this.isMobile = isMobile; + this.screen = screen; + this.stylesManager = new StylesManager(screen); + } + + append(m: Message): void { + switch (m.tp) { + case "set_node_scroll": + if (!this.nodeScrollManagers[ m.id ]) { + this.nodeScrollManagers[ m.id ] = new ListWalker(); + } + this.nodeScrollManagers[ m.id ].append(m); + return; + default: + if (m.tp === "create_element_node") { + if(m.tag === "BODY" && this.upperBodyId === -1) { + this.upperBodyId = m.id + } + } else if (m.tp === "set_node_attribute" && + (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, vn: VElement): void { + if (this.isMobile && this.upperBodyId === id) { // Need more type safety! + (vn.node as HTMLBodyElement).style.overflow = "hidden" + } + } + + // May be make it as a message on message add? + private removeAutocomplete(node: Element): boolean { + const tag = node.tagName + if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) { + node.setAttribute("autocomplete", "off"); + return true; + } + if (tag === "INPUT") { + node.setAttribute("autocomplete", "new-password"); + return true; + } + return false; + } + + private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { + 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.vRoots.get(parentID) + if (!parent) { + logger.error("Insert error. Parent node not found", parentID); + return; + } + + const pNode = parent.node + if ((pNode instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker + pNode.sheet && + pNode.sheet.cssRules && + pNode.sheet.cssRules.length > 0 && + pNode.innerText.trim().length === 0 + ) { + logger.log("Trying to insert child to a style tag with virtual rules: ", parent, child); + return; + } + + parent.insertChildAt(child, index) + } + + private applyMessage = (msg: Message): void => { + let node: Node | undefined + let vn: VNode | undefined + let doc: Document | null + switch (msg.tp) { + case "create_document": + doc = this.screen.document; + if (!doc) { + logger.error("No iframe document found", msg) + return; + } + doc.open(); + doc.write(""); + doc.close(); + const fRoot = doc.documentElement; + fRoot.innerText = ''; + + vn = new VElement(fRoot) + this.vElements = new Map([[0, vn ]]) + this.stylesManager.reset(); + return + case "create_text_node": + vn = new VText() + this.vTexts.set(msg.id, vn) + this.insertNode(msg) + return + case "create_element_node": + let element: Element + if (msg.svg) { + element = document.createElementNS('http://www.w3.org/2000/svg', msg.tag) + } else { + element = document.createElement(msg.tag) + } + if (msg.tag === "STYLE" || msg.tag === "style") { + vn = new VStyleElement(element as StyleElement) + } else { + vn = new VElement(element) + } + this.vElements.set(msg.id, vn) + this.insertNode(msg) + this.removeBodyScroll(msg.id, vn) + this.removeAutocomplete(element) + return + case "move_node": + this.insertNode(msg); + return + case "remove_node": + vn = this.vElements.get(msg.id) || this.vTexts.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!vn.parentNode) { logger.error("Parent node not found", msg); return } + vn.parentNode.removeChild(vn) + return + case "set_node_attribute": + let { name, value } = msg; + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (name === "href" && vn.node.tagName === "LINK") { + // @ts-ignore TODO: global ENV type // Hack for queries in rewrited urls (don't we do that in backend?) + if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { + value = value.replace("?", "%3F"); + } + this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value); + } + if (vn.node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { + value = "url(#" + (value.split("#")[1] ||")") + } + vn.setAttribute(name, value) + this.removeBodyScroll(msg.id, vn) + return + case "remove_node_attribute": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + vn.removeAttribute(msg.name) + return + case "set_input_value": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + const nodeWithValue = vn.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 + doc = this.screen.document + if (doc && nodeWithValue === doc.activeElement) { + // For the case of Remote Control + nodeWithValue.onblur = () => { nodeWithValue.value = val } + return + } + nodeWithValue.value = val + return + case "set_input_checked": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + (vn.node as HTMLInputElement).checked = msg.checked + return + case "set_node_data": + case "set_css_data": // TODO: remove css transitions when timeflow is not natural (on jumps) + vn = this.vTexts.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + vn.setData(msg.data) + if (vn.node instanceof HTMLStyleElement) { + doc = this.screen.document + // TODO: move to message parsing + doc && rewriteNodeStyleSheet(doc, vn.node) + } + return + case "css_insert_rule": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!(vn instanceof VStyleElement)) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, node.sheet); + return + } + vn.onStyleSheet(sheet => { + try { + sheet.insertRule(msg.rule, msg.index) + } catch (e) { + logger.warn(e, msg) + try { + sheet.insertRule(msg.rule) + } catch (e) { + logger.warn("Cannot insert rule.", e, msg) + } + } + }) + return + case "css_delete_rule": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!(vn instanceof VStyleElement)) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, node.sheet); + return + } + vn.onStyleSheet(sheet => { + try { + sheet.deleteRule(msg.index) + } catch (e) { + logger.warn(e, msg) + } + }) + return + case "create_i_frame_document": + vn = this.vElements.get(msg.frameID) + if (!vn) { logger.error("Node not found", msg); return } + const host = vn.node + if (host instanceof HTMLIFrameElement) { + const vDoc = new VDocument() + this.vRoots.set(msg.id, vDoc) + host.onload = () => { + const doc = host.contentDocument + if (!doc) { + logger.warn("No iframe doc onload", msg, host) + return + } + vDoc.setDocument(doc) + vDoc.applyChanges() + } + return; + } else if (host instanceof Element) { // shadow DOM + try { + const shadowRoot = host.attachShadow({ mode: 'open' }) + vn = new VFragment(shadowRoot) + this.vRoots.set(msg.id, vn) + } catch(e) { + logger.warn("Can not attach shadow dom", e, msg) + } + } else { + logger.warn("Context message host is not Element", msg) + } + return + } + } + + moveReady(t: number): Promise { + this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) + + // @ts-ignore + this.vElements.get(0).applyChanges() + this.vRoots.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(t).then(() => { + // Apply all scrolls after the styles got applied + this.nodeScrollManagers.forEach(manager => { + const msg = manager.moveGetLast(t) + if (msg) { + const vElm = this.vElements.get(msg.id) + if (vElm) { + vElm.node.scrollLeft = msg.x + vElm.node.scrollTop = msg.y + } + } + }) + }) + } +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/StylesManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts similarity index 90% rename from frontend/app/player/MessageDistributor/managers/StylesManager.ts rename to frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts index 3f5ee1b86..139ebd95c 100644 --- a/frontend/app/player/MessageDistributor/managers/StylesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts @@ -1,10 +1,10 @@ -import type StatedScreen from '../StatedScreen'; -import type { CssInsertRule, CssDeleteRule } from '../messages'; +import type StatedScreen from '../../StatedScreen'; +import type { CssInsertRule, CssDeleteRule } from '../../messages'; type CSSRuleMessage = CssInsertRule | CssDeleteRule; import logger from 'App/logger'; -import ListWalker from './ListWalker'; +import ListWalker from '../ListWalker'; const HOVER_CN = "-openreplay-hover"; @@ -40,7 +40,7 @@ export default class StylesManager extends ListWalker { } setStyleHandlers(node: HTMLLinkElement, value: string): void { - let timeoutId; + let timeoutId: ReturnType | undefined; const promise = new Promise((resolve) => { if (this.skipCSSLinks.includes(value)) resolve(null); this.linkLoadingCount++; @@ -49,8 +49,8 @@ export default class StylesManager extends ListWalker { this.skipCSSLinks.push(value); // watch out resolve(null); } - timeoutId = setTimeout(addSkipAndResolve, 4000); - + timeoutId = setTimeout(addSkipAndResolve, 4000000); + console.log(node.getAttribute("href")) node.onload = () => { const doc = this.screen.document; doc && rewriteNodeStyleSheet(doc, node); diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts new file mode 100644 index 000000000..3df1b5a3e --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -0,0 +1,143 @@ +type VChild = VElement | VText + +export type VNode = VDocument | VFragment | VElement | VText + +abstract class VParent { + abstract node: Node | null + protected children: VChild[] = [] + insertChildAt(child: VChild, index: number) { + if (child.parentNode) { + child.parentNode.removeChild(child) + } + this.children.splice(index, 0, child) + child.parentNode = this + } + + removeChild(child: VChild) { + this.children = this.children.filter(ch => ch !== child) + child.parentNode = null + } + applyChanges() { + const node = this.node + if (!node) { + // log err + console.error("No node found", this) + return + } + const realChildren = node.childNodes + for (let i = 0; i < this.children.length; i++) { + const ch = this.children[i] + ch.applyChanges() + if (ch.node.parentNode !== node) { + const nextSibling = realChildren[i] + node.insertBefore(ch.node, nextSibling || null) + } + if (realChildren[i] !== ch.node) { + node.removeChild(realChildren[i]) + } + } + } +} + +export class VDocument extends VParent { + constructor(public node: Document | null = null) { super() } + setDocument(doc: Document) { + this.node = doc + } + applyChanges() { + if (this.children.length > 1) { + // log err + } + if (!this.node) { + // iframe not mounted yet + return + } + const htmlNode = this.children[0].node + if (htmlNode.parentNode !== this.node) { + this.node.replaceChild(htmlNode, this.node.documentElement) + } + } +} + +export class VFragment extends VParent { + constructor(public readonly node: DocumentFragment) { super() } +} + +export class VElement extends VParent { + parentNode: VParent | null = null + private newAttributes: Record = {} + //private props: Record + constructor(public readonly node: Element) { super() } + setAttribute(name: string, value: string) { + this.newAttributes[name] = value + } + removeAttribute(name: string) { + this.newAttributes[name] = false + } + + applyChanges() { + Object.entries(this.newAttributes).forEach(([key, value]) => { + if (value === false) { + this.node.removeAttribute(key) + } else { + try { + this.node.setAttribute(key, value) + } catch { + // log err + } + } + }) + this.newAttributes = {} + super.applyChanges() + } +} + + +type StyleSheetCallback = (s: CSSStyleSheet) => void +export type StyleElement = HTMLStyleElement | SVGStyleElement +export class VStyleElement extends VElement { + private loaded = false + private stylesheetCallbacks: StyleSheetCallback[] = [] + constructor(public readonly node: StyleElement) { + super(node) // Is it compiled correctly or with 2 node assignments? + node.onload = () => { + const sheet = node.sheet + if (sheet) { + this.stylesheetCallbacks.forEach(cb => cb(sheet)) + } else { + console.warn("Style onload: sheet is null") + } + this.loaded = true + } + } + + onStyleSheet(cb: StyleSheetCallback) { + if (this.loaded) { + if (!this.node.sheet) { + console.warn("Style tag is loaded, but sheet is null") + return + } + cb(this.node.sheet) + } else { + this.stylesheetCallbacks.push(cb) + } + } +} + +export class VText { + parentNode: VParent | null = null + constructor(public readonly node: Text = new Text()) {} + private data: string = "" + private changed: boolean = false + setData(data: string) { + this.data = data + this.changed = true + } + applyChanges() { + if (this.changed) { + this.node.data = this.data + this.changed = false + } + } +} + diff --git a/frontend/app/player/MessageDistributor/managers/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOMManager.ts deleted file mode 100644 index 37d24aa44..000000000 --- a/frontend/app/player/MessageDistributor/managers/DOMManager.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type StatedScreen from '../StatedScreen'; -import type { Message, SetNodeScroll, CreateElementNode } from '../messages'; - -import logger from 'App/logger'; -import StylesManager, { rewriteNodeStyleSheet } from './StylesManager'; -import ListWalker from './ListWalker'; - -const IGNORED_ATTRS = [ "autocomplete", "name" ]; - -const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ - -export default class DOMManager extends ListWalker { - private isMobile: boolean; - private screen: StatedScreen; - private nl: Array = []; - private isLink: Array = []; // Optimisations - private bodyId: number = -1; - private postponedBodyMessage: CreateElementNode | null = null; - private nodeScrollManagers: Array> = []; - - private stylesManager: StylesManager; - - private startTime: number; - - constructor(screen: StatedScreen, isMobile: boolean, startTime: number) { - super(); - this.startTime = startTime; - this.isMobile = isMobile; - this.screen = screen; - this.stylesManager = new StylesManager(screen); - } - - get time(): number { - return this.startTime; - } - - append(m: Message): void { - switch (m.tp) { - case "set_node_scroll": - if (!this.nodeScrollManagers[ m.id ]) { - this.nodeScrollManagers[ m.id ] = new ListWalker(); - } - this.nodeScrollManagers[ m.id ].append(m); - return; - //case "css_insert_rule": // || //set_css_data ??? - //case "css_delete_rule": - // (m.tp === "set_node_attribute" && this.isLink[ m.id ] && m.key === "href")) { - // this.stylesManager.append(m); - // return; - default: - if (m.tp === "create_element_node") { - switch(m.tag) { - case "LINK": - this.isLink[ m.id ] = true; - break; - case "BODY": - this.bodyId = m.id; // Can be several body nodes at one document session? - break; - } - } else if (m.tp === "set_node_attribute" && - (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): void { - if (this.isMobile && this.bodyId === id) { - (this.nl[ id ] as HTMLBodyElement).style.overflow = "hidden"; - } - } - - // May be make it as a message on message add? - private removeAutocomplete({ id, tag }: CreateElementNode): boolean { - const node = this.nl[ id ] as HTMLElement; - if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) { - node.setAttribute("autocomplete", "off"); - return true; - } - if (tag === "INPUT") { - node.setAttribute("autocomplete", "new-password"); - return true; - } - return false; - } - - // type = NodeMessage ? - private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { - if (!this.nl[ id ]) { - logger.error("Insert error. Node not found", id); - return; - } - if (!this.nl[ parentID ]) { - logger.error("Insert error. Parent node not found", parentID); - return; - } - // WHAT if text info contains some rules and the ordering is just wrong??? - const el = this.nl[ parentID ] - if ((el instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker - el.sheet && - el.sheet.cssRules && - el.sheet.cssRules.length > 0 && - el.innerText.trim().length === 0) { - logger.log("Trying to insert child to a style tag with virtual rules: ", this.nl[ parentID ], this.nl[ id ]); - return; - } - - const childNodes = this.nl[ parentID ].childNodes; - if (!childNodes) { - logger.error("Node has no childNodes", this.nl[ parentID ]); - return; - } - - if (this.nl[ id ] instanceof HTMLHtmlElement) { - // What if some exotic cases? - this.nl[ parentID ].replaceChild(this.nl[ id ], childNodes[childNodes.length-1]) - return - } - - this.nl[ parentID ] - .insertBefore(this.nl[ id ], childNodes[ index ]) - } - - private applyMessage = (msg: Message): void => { - let node; - let doc: Document | null; - switch (msg.tp) { - case "create_document": - doc = this.screen.document; - if (!doc) { - logger.error("No iframe document found", msg) - return; - } - doc.open(); - doc.write(""); - doc.close(); - const fRoot = doc.documentElement; - fRoot.innerText = ''; - this.nl = [ fRoot ]; - - // the last load event I can control - //if (this.document.fonts) { - // this.document.fonts.onloadingerror = () => this.marker.redraw(); - // this.document.fonts.onloadingdone = () => this.marker.redraw(); - //} - - //this.screen.setDisconnected(false); - this.stylesManager.reset(); - return - case "create_text_node": - this.nl[ msg.id ] = document.createTextNode(''); - this.insertNode(msg); - return - case "create_element_node": - if (msg.svg) { - this.nl[ msg.id ] = document.createElementNS('http://www.w3.org/2000/svg', msg.tag); - } else { - this.nl[ msg.id ] = document.createElement(msg.tag); - } - if (this.bodyId === msg.id) { // there are several bodies in iframes TODO: optimise & cache prebuild - this.postponedBodyMessage = msg; - } else { - this.insertNode(msg); - } - this.removeBodyScroll(msg.id); - this.removeAutocomplete(msg); - return - case "move_node": - this.insertNode(msg); - return - case "remove_node": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - if (!node.parentElement) { logger.error("Parent node not found", msg); return } - node.parentElement.removeChild(node); - return - case "set_node_attribute": - let { id, name, value } = msg; - node = this.nl[ id ]; - if (!node) { logger.error("Node not found", msg); return } - if (this.isLink[ id ] && name === "href") { - // @ts-ignore TODO: global ENV type - if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { // Hack for queries in rewrited urls - value = value.replace("?", "%3F"); - } - this.stylesManager.setStyleHandlers(node, value); - } - if (node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { - value = "url(#" + (value.split("#")[1] ||")") - } - try { - node.setAttribute(name, value); - } catch(e) { - logger.error(e, msg); - } - this.removeBodyScroll(msg.id); - return - case "remove_node_attribute": - if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); return } - try { - (this.nl[ msg.id ] as HTMLElement).removeAttribute(msg.name); - } catch(e) { - logger.error(e, msg); - } - return - case "set_input_value": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLInputElement - || node instanceof HTMLTextAreaElement - || node instanceof HTMLSelectElement) - ) { - logger.error("Trying to set value of non-Input element", msg) - return - } - const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value - doc = this.screen.document - if (doc && node === doc.activeElement) { - // For the case of Remote Control - node.onblur = () => { node.value = val } - return - } - node.value = val - return - case "set_input_checked": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - (node as HTMLInputElement).checked = msg.checked; - return - case "set_node_data": - case "set_css_data": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - // @ts-ignore - node.data = msg.data; - if (node instanceof HTMLStyleElement) { - doc = this.screen.document - doc && rewriteNodeStyleSheet(doc, node) - } - return - case "css_insert_rule": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLStyleElement) // link or null - || node.sheet == null) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); - return - } - try { - node.sheet.insertRule(msg.rule, msg.index) - } catch (e) { - logger.warn(e, msg) - try { - node.sheet.insertRule(msg.rule) - } catch (e) { - logger.warn("Cannot insert rule.", e, msg) - } - } - return - case "css_delete_rule": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLStyleElement) // link or null - || node.sheet == null) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); - return - } - try { - node.sheet.deleteRule(msg.index) - } catch (e) { - logger.warn(e, msg) - } - return - case "create_i_frame_document": - node = this.nl[ msg.frameID ]; - // console.log('ifr', msg, node) - - if (node instanceof HTMLIFrameElement) { - doc = node.contentDocument; - if (!doc) { - logger.warn("No iframe doc", msg, node, node.contentDocument); - return; - } - this.nl[ msg.id ] = doc.documentElement - return; - } else if (node instanceof Element) { // shadow DOM - try { - this.nl[ msg.id ] = node.attachShadow({ mode: 'open' }) - } catch(e) { - logger.warn("Can not attach shadow dom", e, msg) - } - } else { - logger.warn("Context message host is not Element", msg) - } - return - } - } - - moveReady(t: number): Promise { - this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) - - /* Mount body as late as possible */ - if (this.postponedBodyMessage != null) { - this.insertNode(this.postponedBodyMessage) - this.postponedBodyMessage = null - } - - // Thinkabout (read): css preload - // What if we go back before it is ready? We'll have two handlres? - return this.stylesManager.moveReady(t).then(() => { - // Apply all scrolls after the styles got applied - this.nodeScrollManagers.forEach(manager => { - const msg = manager.moveGetLast(t) - if (!!msg && !!this.nl[msg.id]) { - const node = this.nl[msg.id] as HTMLElement - node.scrollLeft = msg.x - node.scrollTop = msg.y - } - }) - }) - } -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/PagesManager.ts b/frontend/app/player/MessageDistributor/managers/PagesManager.ts index 0a463fe97..9a4398246 100644 --- a/frontend/app/player/MessageDistributor/managers/PagesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/PagesManager.ts @@ -2,7 +2,7 @@ import type StatedScreen from '../StatedScreen'; import type { Message } from '../messages'; import ListWalker from './ListWalker'; -import DOMManager from './DOMManager'; +import DOMManager from './DOM/DOMManager'; export default class PagesManager extends ListWalker { From e74fb2051b00aea41a59d2f371d42397b69ea6cc Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Tue, 5 Jul 2022 18:43:23 +0200 Subject: [PATCH 03/12] fix(frontend/player): non-dev style timeout --- .../player/MessageDistributor/managers/DOM/StylesManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts index 139ebd95c..14a2ee8a4 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts @@ -49,8 +49,8 @@ export default class StylesManager extends ListWalker { this.skipCSSLinks.push(value); // watch out resolve(null); } - timeoutId = setTimeout(addSkipAndResolve, 4000000); - console.log(node.getAttribute("href")) + timeoutId = setTimeout(addSkipAndResolve, 4000); + node.onload = () => { const doc = this.screen.document; doc && rewriteNodeStyleSheet(doc, node); From 7cd514eb23951cf2869cb7db9dc00641c3c00253 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 6 Jul 2022 16:40:52 +0200 Subject: [PATCH 04/12] style(frontend/player): codefixes --- .../managers/DOM/DOMManager.ts | 58 +++++++++---------- .../managers/DOM/StylesManager.ts | 8 +-- .../managers/DOM/VirtualDOM.ts | 10 ++-- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 278e97ba4..924333eea 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -15,45 +15,45 @@ const IGNORED_ATTRS = [ "autocomplete", "name" ]; const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ export default class DOMManager extends ListWalker { - private isMobile: boolean; - private screen: StatedScreen; private vTexts: Map = new Map() // map vs object here? private vElements: Map = new Map() private vRoots: Map = new Map() private upperBodyId: number = -1; - private nodeScrollManagers: Array> = [] + private nodeScrollManagers: Map> = new Map() private stylesManager: StylesManager - constructor(screen: StatedScreen, isMobile: boolean, public readonly time: number) { - super(); - this.isMobile = isMobile; - this.screen = screen; - this.stylesManager = new StylesManager(screen); + constructor( + private readonly screen: StatedScreen, + private readonly isMobile: boolean, + public readonly time: number + ) { + super() + this.stylesManager = new StylesManager(screen) } append(m: Message): void { - switch (m.tp) { - case "set_node_scroll": - if (!this.nodeScrollManagers[ m.id ]) { - this.nodeScrollManagers[ m.id ] = new ListWalker(); + if (m.tp === "set_node_scroll") { + let scrollManager = this.nodeScrollManagers.get(m.id) + if (!scrollManager) { + scrollManager = new ListWalker() + this.nodeScrollManagers.set(m.id, scrollManager) } - this.nodeScrollManagers[ m.id ].append(m); - return; - default: - if (m.tp === "create_element_node") { - if(m.tag === "BODY" && this.upperBodyId === -1) { - this.upperBodyId = m.id - } - } else if (m.tp === "set_node_attribute" && - (IGNORED_ATTRS.includes(m.name) || !ATTR_NAME_REGEXP.test(m.name))) { - logger.log("Ignorring message: ", m) - return; // Ignoring - } - super.append(m); + scrollManager.append(m) + return } + if (m.tp === "create_element_node") { + if(m.tag === "BODY" && this.upperBodyId === -1) { + this.upperBodyId = m.id + } + } else if (m.tp === "set_node_attribute" && + (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, vn: VElement): void { @@ -160,9 +160,9 @@ export default class DOMManager extends ListWalker { if (!vn) { logger.error("Node not found", msg); return } if (name === "href" && vn.node.tagName === "LINK") { // @ts-ignore TODO: global ENV type // Hack for queries in rewrited urls (don't we do that in backend?) - if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { - value = value.replace("?", "%3F"); - } + // if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { + // value = value.replace("?", "%3F"); + // } this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value); } if (vn.node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { @@ -236,7 +236,7 @@ export default class DOMManager extends ListWalker { vn = this.vElements.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } if (!(vn instanceof VStyleElement)) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, node.sheet); + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn); return } vn.onStyleSheet(sheet => { diff --git a/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts index 14a2ee8a4..c6b674c4e 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts @@ -41,20 +41,20 @@ export default class StylesManager extends ListWalker { setStyleHandlers(node: HTMLLinkElement, value: string): void { let timeoutId: ReturnType | undefined; - const promise = new Promise((resolve) => { - if (this.skipCSSLinks.includes(value)) resolve(null); + const promise = new Promise((resolve) => { + if (this.skipCSSLinks.includes(value)) resolve(); this.linkLoadingCount++; this.screen.setCSSLoading(true); const addSkipAndResolve = () => { this.skipCSSLinks.push(value); // watch out - resolve(null); + resolve() } timeoutId = setTimeout(addSkipAndResolve, 4000); node.onload = () => { const doc = this.screen.document; doc && rewriteNodeStyleSheet(doc, node); - resolve(null); + resolve(); } node.onerror = addSkipAndResolve; }).then(() => { diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts index 3df1b5a3e..12009c21c 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -65,18 +65,18 @@ export class VFragment extends VParent { export class VElement extends VParent { parentNode: VParent | null = null - private newAttributes: Record = {} + private newAttributes: Map = new Map() //private props: Record constructor(public readonly node: Element) { super() } setAttribute(name: string, value: string) { - this.newAttributes[name] = value + this.newAttributes.set(name, value) } removeAttribute(name: string) { - this.newAttributes[name] = false + this.newAttributes.set(name, false) } applyChanges() { - Object.entries(this.newAttributes).forEach(([key, value]) => { + this.newAttributes.forEach((value, key) => { if (value === false) { this.node.removeAttribute(key) } else { @@ -87,7 +87,7 @@ export class VElement extends VParent { } } }) - this.newAttributes = {} + this.newAttributes.clear() super.applyChanges() } } From 3c719f4839cdb160333c8d071551d741066f2b03 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 6 Jul 2022 17:34:07 +0200 Subject: [PATCH 05/12] fix(frontend-player): ignore non-http urls in links --- .../app/player/MessageDistributor/managers/DOM/DOMManager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 924333eea..005713e15 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -159,10 +159,13 @@ export default class DOMManager extends ListWalker { vn = this.vElements.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } if (name === "href" && vn.node.tagName === "LINK") { - // @ts-ignore TODO: global ENV type // Hack for queries in rewrited urls (don't we do that in backend?) + // @ts-ignore TODO: global ENV type // It'd 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")) { return } + // blob:... value happened here. https://foss.openreplay.com/3/session/7013553567419137 + // that resulted in that link being unable to load and having 4sec timeout in the below function. this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value); } if (vn.node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { From 5366f2b79876879aef4bf7134a7c9f6a9008c639 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 17:18:46 +0200 Subject: [PATCH 06/12] fix(frontend): log error in API middleware --- frontend/app/api_middleware.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/api_middleware.js b/frontend/app/api_middleware.js index a29a22eb6..8f9965ec5 100644 --- a/frontend/app/api_middleware.js +++ b/frontend/app/api_middleware.js @@ -1,3 +1,4 @@ +import logger from 'App/logger'; import APIClient from './api_client'; import { UPDATE, DELETE } from './duck/jwt'; @@ -28,8 +29,9 @@ export default store => next => (action) => { next({ type: UPDATE, data: jwt }); } }) - .catch(() => { - return next({ type: FAILURE, errors: [ 'Connection error' ] }); + .catch((e) => { + logger.error("Error during API request. ", e) + return next({ type: FAILURE, errors: [ "Connection error", String(e) ] }); }); }; From 683b0b3ceb21d8736db3f0d9e31f1c90b67017dd Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 17:20:39 +0200 Subject: [PATCH 07/12] fix(frontend): uncomment ussue types, don't break on unknown --- frontend/app/types/session/issue.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/app/types/session/issue.js b/frontend/app/types/session/issue.js index db220f8cc..673467fd2 100644 --- a/frontend/app/types/session/issue.js +++ b/frontend/app/types/session/issue.js @@ -10,11 +10,11 @@ export const issues_types = List([ { 'type': 'memory', 'visible': true, 'order': 4, 'name': 'High Memory', 'icon': 'funnel/sd-card' }, { 'type': 'vault', 'visible': true, 'order': 5, 'name': 'Vault', 'icon': 'safe' }, { 'type': 'bookmark', 'visible': true, 'order': 5, 'name': 'Bookmarks', 'icon': 'safe' }, - // { 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' }, - // { 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' }, - // { 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' }, - // { 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' }, - // { 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' } + { 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' }, + { 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' }, + { 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' }, + { 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' }, + { 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' } ]).map(Watchdog) export const issues_types_map = {} @@ -40,7 +40,7 @@ export default Record({ fromJS: ({ type, ...rest }) => ({ ...rest, type, - icon: issues_types_map[type].icon, - name: issues_types_map[type].name, + icon: issues_types_map[type]?.icon, + name: issues_types_map[type]?.name, }), }); From dabe7f0e444441eede2f9bc5b2ecaae3977373f5 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 17:28:51 +0200 Subject: [PATCH 08/12] fix (frontend/player): instant insertion of style elements & correct children deletion --- .../managers/DOM/DOMManager.ts | 15 ++++- .../managers/DOM/VirtualDOM.ts | 63 +++++++++++-------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 005713e15..19e35d8f6 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -14,6 +14,16 @@ type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectE const IGNORED_ATTRS = [ "autocomplete", "name" ]; const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ + +// TODO: filter out non-relevant prefixes +// function replaceCSSPrefixes(css: string) { +// return css +// .replace(/\-ms\-/g, "") +// .replace(/\-webkit\-/g, "") +// .replace(/\-moz\-/g, "") +// .replace(/\-webkit\-/g, "") +// } + export default class DOMManager extends ListWalker { private vTexts: Map = new Map() // map vs object here? private vElements: Map = new Map() @@ -144,6 +154,9 @@ export default class DOMManager extends ListWalker { this.insertNode(msg) this.removeBodyScroll(msg.id, vn) this.removeAutocomplete(element) + if (['STYLE', 'style', 'link'].includes(msg.tag)) { + vn.enforceInsertion() + } return case "move_node": this.insertNode(msg); @@ -287,7 +300,7 @@ export default class DOMManager extends ListWalker { // @ts-ignore this.vElements.get(0).applyChanges() - this.vRoots.forEach(rt => rt.applyChanges()) + this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set // Thinkabout (read): css preload // What if we go back before it is ready? We'll have two handlres? diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts index 12009c21c..c25f07caf 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -17,6 +17,7 @@ abstract class VParent { this.children = this.children.filter(ch => ch !== child) child.parentNode = null } + applyChanges() { const node = this.node if (!node) { @@ -25,17 +26,20 @@ abstract class VParent { return } const realChildren = node.childNodes - for (let i = 0; i < this.children.length; i++) { - const ch = this.children[i] - ch.applyChanges() - if (ch.node.parentNode !== node) { - const nextSibling = realChildren[i] - node.insertBefore(ch.node, nextSibling || null) - } - if (realChildren[i] !== ch.node) { - node.removeChild(realChildren[i]) + let i: number + // apply correct children order + for (i = 0; i < this.children.length; i++) { + const child = this.children[i] + child.applyChanges() + //while (realChildren[i] shouldn't be there) remove it //optimal way + if (realChildren[i] !== child.node) { + node.insertBefore(child.node, realChildren[i] || null) } } + // remove rest + while(realChildren[i]) { + node.removeChild(realChildren[i]) + } } } @@ -52,7 +56,9 @@ export class VDocument extends VParent { // iframe not mounted yet return } - const htmlNode = this.children[0].node + const child = this.children[0] + child.applyChanges() + const htmlNode = child.node if (htmlNode.parentNode !== this.node) { this.node.replaceChild(htmlNode, this.node.documentElement) } @@ -66,7 +72,6 @@ export class VFragment extends VParent { export class VElement extends VParent { parentNode: VParent | null = null private newAttributes: Map = new Map() - //private props: Record constructor(public readonly node: Element) { super() } setAttribute(name: string, value: string) { this.newAttributes.set(name, value) @@ -75,6 +80,14 @@ export class VElement extends VParent { this.newAttributes.set(name, false) } + enforceInsertion() { + let vNode: VElement = this + while (vNode.parentNode instanceof VElement) { + vNode = vNode.parentNode + } + (vNode.parentNode || vNode).applyChanges() + } + applyChanges() { this.newAttributes.forEach((value, key) => { if (value === false) { @@ -96,31 +109,31 @@ export class VElement extends VParent { type StyleSheetCallback = (s: CSSStyleSheet) => void export type StyleElement = HTMLStyleElement | SVGStyleElement export class VStyleElement extends VElement { - private loaded = false + // private loaded = false private stylesheetCallbacks: StyleSheetCallback[] = [] constructor(public readonly node: StyleElement) { super(node) // Is it compiled correctly or with 2 node assignments? - node.onload = () => { - const sheet = node.sheet - if (sheet) { - this.stylesheetCallbacks.forEach(cb => cb(sheet)) - } else { - console.warn("Style onload: sheet is null") - } - this.loaded = true - } + // node.onload = () => { + // const sheet = node.sheet + // if (sheet) { + // this.stylesheetCallbacks.forEach(cb => cb(sheet)) + // } else { + // console.warn("Style onload: sheet is null") + // } + // this.loaded = true + // } } onStyleSheet(cb: StyleSheetCallback) { - if (this.loaded) { + // if (this.loaded) { if (!this.node.sheet) { console.warn("Style tag is loaded, but sheet is null") return } cb(this.node.sheet) - } else { - this.stylesheetCallbacks.push(cb) - } + // } else { + // this.stylesheetCallbacks.push(cb) + // } } } From cdcabf5f0883acb21f202ff4bda77c5003db1f86 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 17:37:42 +0200 Subject: [PATCH 09/12] fix(frontend/player): store main vDoc inside the vRoots Map --- .../player/MessageDistributor/managers/DOM/DOMManager.ts | 9 +++++---- .../player/MessageDistributor/managers/DOM/VirtualDOM.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 19e35d8f6..9e7b4a5f3 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -130,8 +130,11 @@ export default class DOMManager extends ListWalker { fRoot.innerText = ''; vn = new VElement(fRoot) - this.vElements = new Map([[0, vn ]]) - this.stylesManager.reset(); + this.vElements = new Map([[0, vn]]) + const vDoc = new VDocument(doc) + vDoc.insertChildAt(vn, 0) + this.vRoots = new Map([[-1, vDoc]]) // todo: start from 0 (sync logic with tracker) + this.stylesManager.reset() return case "create_text_node": vn = new VText() @@ -298,8 +301,6 @@ export default class DOMManager extends ListWalker { moveReady(t: number): Promise { this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) - // @ts-ignore - this.vElements.get(0).applyChanges() this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set // Thinkabout (read): css preload diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts index c25f07caf..76f2cdf67 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -80,7 +80,7 @@ export class VElement extends VParent { this.newAttributes.set(name, false) } - enforceInsertion() { + enforceInsertion() { // mbtodo: priority insertion instead let vNode: VElement = this while (vNode.parentNode instanceof VElement) { vNode = vNode.parentNode From 2bcc1f3f1e92e63325f34f3f28b874ebaad6704a Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 17:46:39 +0200 Subject: [PATCH 10/12] fix(frontend/player): styles in priority (style tag text as well) --- .../MessageDistributor/managers/DOM/DOMManager.ts | 11 +++++++---- .../MessageDistributor/managers/DOM/VirtualDOM.ts | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index 9e7b4a5f3..b44e8ac4e 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -157,7 +157,7 @@ export default class DOMManager extends ListWalker { this.insertNode(msg) this.removeBodyScroll(msg.id, vn) this.removeAutocomplete(element) - if (['STYLE', 'style', 'link'].includes(msg.tag)) { + if (['STYLE', 'style', 'LINK'].includes(msg.tag)) { // Styles in priority vn.enforceInsertion() } return @@ -175,7 +175,7 @@ export default class DOMManager extends ListWalker { vn = this.vElements.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } if (name === "href" && vn.node.tagName === "LINK") { - // @ts-ignore TODO: global ENV type // It'd done on backend (remove after testing in saas) + // @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"); // } @@ -221,7 +221,7 @@ export default class DOMManager extends ListWalker { (vn.node as HTMLInputElement).checked = msg.checked return case "set_node_data": - case "set_css_data": // TODO: remove css transitions when timeflow is not natural (on jumps) + case "set_css_data": // mbtodo: remove css transitions when timeflow is not natural (on jumps) vn = this.vTexts.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } vn.setData(msg.data) @@ -230,12 +230,15 @@ export default class DOMManager extends ListWalker { // TODO: move to message parsing doc && rewriteNodeStyleSheet(doc, vn.node) } + if (msg.tp === "set_css_data") { // Styles in priority (do we need inlines as well?) + vn.applyChanges() + } return case "css_insert_rule": vn = this.vElements.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } if (!(vn instanceof VStyleElement)) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, node.sheet); + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn); return } vn.onStyleSheet(sheet => { diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts index 76f2cdf67..256b92bb4 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -80,7 +80,10 @@ export class VElement extends VParent { this.newAttributes.set(name, false) } - enforceInsertion() { // mbtodo: priority insertion instead + // mbtodo: priority insertion instead. + // rn this is for styles that should be inserted as prior, + // otherwise it will show visual styling lag if there is a transition CSS property) + enforceInsertion() { let vNode: VElement = this while (vNode.parentNode instanceof VElement) { vNode = vNode.parentNode From a1d8848e01fc12dbc901cc1e6aa02ad1ba89f5b3 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 27 Jul 2022 18:48:02 +0200 Subject: [PATCH 11/12] style(player):future optimisation comments --- .../app/player/MessageDistributor/managers/DOM/DOMManager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts index b44e8ac4e..378021203 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -302,6 +302,9 @@ export default class DOMManager extends ListWalker { } moveReady(t: number): Promise { + // MBTODO (back jump optimisation): + // - store intemediate virtual dom state + // - cancel previous moveReady tasks (is it possible?) if new timestamp is less this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set From eb48f14a19d46c73e2e33681090092804adbb2fc Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Thu, 28 Jul 2022 22:24:55 +0200 Subject: [PATCH 12/12] fix(frontend/player): (re)insert only necessary children --- .../managers/DOM/VirtualDOM.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts index 256b92bb4..9fa151e61 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -5,16 +5,20 @@ export type VNode = VDocument | VFragment | VElement | VText abstract class VParent { abstract node: Node | null protected children: VChild[] = [] + private insertedChildren: Set = new Set() + insertChildAt(child: VChild, index: number) { if (child.parentNode) { child.parentNode.removeChild(child) } this.children.splice(index, 0, child) + this.insertedChildren.add(child) child.parentNode = this } removeChild(child: VChild) { this.children = this.children.filter(ch => ch !== child) + this.insertedChildren.delete(child) child.parentNode = null } @@ -25,20 +29,26 @@ abstract class VParent { console.error("No node found", this) return } - const realChildren = node.childNodes - let i: number - // apply correct children order - for (i = 0; i < this.children.length; i++) { + // inserting + for (let i = this.children.length-1; i >= 0; i--) { const child = this.children[i] child.applyChanges() - //while (realChildren[i] shouldn't be there) remove it //optimal way - if (realChildren[i] !== child.node) { - node.insertBefore(child.node, realChildren[i] || null) + if (this.insertedChildren.has(child)) { + const nextVSibling = this.children[i+1] + node.insertBefore(child.node, nextVSibling ? nextVSibling.node : null) } } - // remove rest - while(realChildren[i]) { - node.removeChild(realChildren[i]) + this.insertedChildren.clear() + // removing + const realChildren = node.childNodes + for(let j = 0; j < this.children.length; j++) { + while (realChildren[j] !== this.children[j].node) { + node.removeChild(realChildren[j]) + } + } + // removing rest + while(realChildren.length > this.children.length) { + node.removeChild(node.lastChild) } } }