feat(player/VirtualDOM): OnloadVRoot & OnloadStyleSheet for lazy iframe innerContent initialisation & elimination of forceInsertion requirement in this case;; few renamings

This commit is contained in:
Alex Kaminskii 2023-04-05 01:07:35 +02:00
parent 076e54573f
commit 57e4648e5e
2 changed files with 185 additions and 125 deletions

View file

@ -9,13 +9,14 @@ import FocusManager from './FocusManager';
import SelectionManager from './SelectionManager';
import type { StyleElement } from './VirtualDOM';
import {
PostponedStyleSheet,
OnloadStyleSheet,
VDocument,
VElement,
VHTMLElement,
VNode,
VShadowRoot,
VText,
OnloadVRoot,
} from './VirtualDOM';
import { deleteRule, insertRule } from './safeCSSRules';
@ -27,11 +28,13 @@ const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~
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 vRoots: Map<number, VShadowRoot | VDocument> = new Map()
private styleSheets: Map<number, CSSStyleSheet> = new Map()
private ppStyleSheets: Map<number, PostponedStyleSheet> = new Map()
private readonly olVRoots: Map<number, OnloadVRoot> = 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 ppStyleSheetsDeprecated: Map<number, PostponedStyleSheet> = new Map()
private olStyleSheetsDeprecated: Map<number, OnloadStyleSheet> = new Map()
private stringDict: Record<number,string> = {}
private attrsBacktrack: Message[] = []
@ -107,9 +110,9 @@ export default class DOMManager extends ListWalker<Message> {
logger.error("Insert error. Node not found", id);
return;
}
const parent = this.vElements.get(parentID) || this.vRoots.get(parentID)
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID)
if (!parent) {
logger.error("Insert error. Parent vNode not found", parentID, this.vElements, this.vRoots);
logger.error("Insert error. Parent vNode not found", parentID, this.vElements, this.olVRoots);
return;
}
@ -177,10 +180,10 @@ export default class DOMManager extends ListWalker<Message> {
const vHTMLElement = new VHTMLElement(fRoot)
this.vElements.clear()
this.vElements.set(0, vHTMLElement)
const vDoc = new VDocument(() => doc as Document)
const vDoc = OnloadVRoot.fromDocumentNode(doc)
vDoc.insertChildAt(vHTMLElement, 0)
this.vRoots.clear()
this.vRoots.set(0, vDoc) // watchout: id==0 for both Document and documentElement
this.olVRoots.clear()
this.olVRoots.set(0, vDoc) // watchout: id==0 for both Document and documentElement
// this is done for the AdoptedCSS logic
// todo: start from 0-node (sync logic with tracker)
this.vTexts.clear()
@ -201,7 +204,7 @@ export default class DOMManager extends ListWalker<Message> {
this.removeBodyScroll(msg.id, vElem)
this.removeAutocomplete(vElem)
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) { // Styles in priority
vElem.enforceInsertion()
vElem.enforceInsertion() //TODOTODO priority mounting instead
}
return
}
@ -273,7 +276,7 @@ export default class DOMManager extends ListWalker<Message> {
case MType.SetNodeData:
case MType.SetCssData: {
const vText = this.vTexts.get(msg.id)
if (!vText) { logger.error("SetCssData: Node not found", msg); return }
if (!vText) { logger.error("SetNodeData/SetCssData: Node not found", msg); return }
vText.setData(msg.data)
if (msg.tp === MType.SetCssData) { //TODOTODO
@ -286,19 +289,19 @@ export default class DOMManager extends ListWalker<Message> {
* since 4.0.2 in favor of AdoptedSsInsertRule/DeleteRule + AdoptedSsAddOwner as a common case for StyleSheets
*/
case MType.CssInsertRule: {
let styleSheet = this.ppStyleSheetsDeprecated.get(msg.id)
let styleSheet = this.olStyleSheetsDeprecated.get(msg.id)
if (!styleSheet) {
const vElem = this.vElements.get(msg.id)
if (!vElem) { logger.error("CssInsertRule: Node not found", msg); return }
if (vElem.tagName.toLowerCase() !== "style") { logger.error("CssInsertRule: Non-style elemtn", msg); return }
styleSheet = new PostponedStyleSheet(vElem.node as StyleElement)
this.ppStyleSheetsDeprecated.set(msg.id, styleSheet)
styleSheet = OnloadStyleSheet.fromStyleElement(vElem.node as StyleElement)
this.olStyleSheetsDeprecated.set(msg.id, styleSheet)
}
styleSheet.insertRule(msg.rule, msg.index)
return
}
case MType.CssDeleteRule: {
const styleSheet = this.ppStyleSheetsDeprecated.get(msg.id)
const styleSheet = this.olStyleSheetsDeprecated.get(msg.id)
if (!styleSheet) { logger.error("CssDeleteRule: StyleSheet was not created", msg); return }
styleSheet.deleteRule(msg.index)
return
@ -307,33 +310,13 @@ export default class DOMManager extends ListWalker<Message> {
case MType.CreateIFrameDocument: {
const vElem = this.vElements.get(msg.frameID)
if (!vElem) { logger.error("CreateIFrameDocument: Node not found", msg); return }
vElem.enforceInsertion() //TODOTODO
const host = vElem.node
if (host instanceof HTMLIFrameElement) {
const doc = host.contentDocument
if (!doc) {
logger.warn("No default iframe doc", msg, host)
return
}
const vDoc = new VDocument(() => doc)
this.vRoots.set(msg.id, vDoc)
return;
} else if (host instanceof Element) { // shadow DOM
try {
const shadowRoot = host.attachShadow({ mode: 'open' })
const vRoot = new VShadowRoot(() => shadowRoot)
this.vRoots.set(msg.id, vRoot)
} catch(e) {
logger.warn("Can not attach shadow dom", e, msg)
}
} else {
logger.warn("Context message host is not Element", msg)
}
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.styleSheets.get(msg.sheetID) || this.ppStyleSheets.get(msg.sheetID)
const styleSheet = this.olStyleSheets.get(msg.sheetID)
if (!styleSheet) {
logger.warn("No stylesheet was created for ", msg)
return
@ -342,7 +325,7 @@ export default class DOMManager extends ListWalker<Message> {
return
}
case MType.AdoptedSsDeleteRule: {
const styleSheet = this.styleSheets.get(msg.sheetID) || this.ppStyleSheets.get(msg.sheetID)
const styleSheet = this.olStyleSheets.get(msg.sheetID)
if (!styleSheet) {
logger.warn("No stylesheet was created for ", msg)
return
@ -351,7 +334,7 @@ export default class DOMManager extends ListWalker<Message> {
return
}
case MType.AdoptedSsReplace: {
const styleSheet = this.styleSheets.get(msg.sheetID)
const styleSheet = this.olStyleSheets.get(msg.sheetID)
if (!styleSheet) {
logger.warn("No stylesheet was created for ", msg)
return
@ -361,58 +344,62 @@ export default class DOMManager extends ListWalker<Message> {
return
}
case MType.AdoptedSsAddOwner: {
const vRoot = this.vRoots.get(msg.id)
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 (vElem.tagName.toLowerCase() !== "style") { logger.error("Non-style owner", msg); return }
this.ppStyleSheets.set(msg.sheetID, new PostponedStyleSheet(vElem.node as StyleElement))
this.olStyleSheets.set(msg.sheetID, OnloadStyleSheet.fromStyleElement(vElem.node as StyleElement))
return
}
/* Constructed StyleSheet case */
let styleSheet = this.styleSheets.get(msg.sheetID)
if (!styleSheet) {
let context: typeof globalThis | null
if (vRoot instanceof VDocument) {
context = vRoot.node.defaultView
} else {
context = vRoot.node.ownerDocument.defaultView
}
if (!context) { logger.error("AdoptedSsAddOwner: Root node default view not found", msg); return }
styleSheet = new context.CSSStyleSheet() /* a StyleSheet from another Window context won't work */
this.styleSheets.set(msg.sheetID, styleSheet)
let olStyleSheet = this.olStyleSheets.get(msg.sheetID)
if (!olStyleSheet) {
olStyleSheet = OnloadStyleSheet.fromVRootContext(vRoot)
this.olStyleSheets.set(msg.sheetID, olStyleSheet)
}
// @ts-ignore
vRoot.node.adoptedStyleSheets = [...vRoot.node.adoptedStyleSheets, styleSheet]
olStyleSheet.doNext(styleSheet => {
vRoot.onNode(node => {
// @ts-ignore
node.adoptedStyleSheets = [...node.adoptedStyleSheets, styleSheet]
})
})
return
}
case MType.AdoptedSsRemoveOwner: {
const styleSheet = this.styleSheets.get(msg.sheetID)
if (!styleSheet) {
logger.warn("No stylesheet was created for ", msg)
const olStyleSheet = this.olStyleSheets.get(msg.sheetID)
if (!olStyleSheet) {
logger.warn("AdoptedSsRemoveOwner: No stylesheet was created for ", msg)
return
}
const vRoot = this.vRoots.get(msg.id)
if (!vRoot) { logger.error("AdoptedSsRemoveOwner: Node not found", msg); return }
//@ts-ignore
vRoot.node.adoptedStyleSheets = [...vRoot.node.adoptedStyleSheets].filter(s => s !== styleSheet)
const vRoot = this.olVRoots.get(msg.id)
if (!vRoot) { logger.error("AdoptedSsRemoveOwner: Owner node not found", msg); return }
olStyleSheet.doNext(styleSheet => {
vRoot.onNode(node => {
// @ts-ignore
node.adoptedStyleSheets = [...vRoot.node.adoptedStyleSheets].filter(s => s !== styleSheet)
})
})
return
}
case MType.LoadFontFace: {
const vRoot = this.vRoots.get(msg.parentID)
const vRoot = this.olVRoots.get(msg.parentID)
if (!vRoot) { logger.error("LoadFontFace: Node not found", msg); return }
if (vRoot instanceof VShadowRoot) { logger.error(`Node ${vRoot} expected to be a Document`, msg); return }
let descr: Object | undefined
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)
vRoot.node.fonts.add(ff)
return ff.load()
vRoot.doNext(vNode => {
if (vNode instanceof VShadowRoot) { logger.error(`Node ${vNode} expected to be a Document`, msg); return }
let descr: Object | undefined
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)
ff.load() // TODO: wait for this one in StylesManager in a common way with styles
})
return
}
}
}
@ -445,12 +432,13 @@ export default class DOMManager extends ListWalker<Message> {
}
}
/**
* 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?)
* */
async moveReady(t: number): Promise<void> {
// MBTODO (back jump optimisation):
// - store intemediate virtual dom state
// - cancel previous moveReady tasks (is it possible?) if new timestamp is less
// This function autoresets pointer if necessary (better name?)
/**
* Basically just skipping all set attribute with attrs being "href" if user is 'jumping'
* to the other point of replay to save time on NOT downloading any resources before the dom tree changes
@ -466,7 +454,7 @@ export default class DOMManager extends ListWalker<Message> {
})
this.attrsBacktrack = []
this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set
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(t).then(() => {
@ -477,12 +465,16 @@ export default class DOMManager extends ListWalker<Message> {
this.nodeScrollManagers.forEach(manager => {
const msg = manager.moveGetLast(t)
if (msg) {
let vNode: VElement | VDocument | VShadowRoot | undefined
if (vNode = this.vElements.get(msg.id)) {
vNode.node.scrollLeft = msg.x
vNode.node.scrollTop = msg.y
} else if ((vNode = this.vRoots.get(msg.id)) && vNode instanceof VDocument){
vNode.node.defaultView?.scrollTo(msg.x, msg.y)
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.doNext(vNode => {
if (vNode instanceof VDocument) {
vNode.node.defaultView?.scrollTo(msg.x, msg.y)
}
})
}
}
})

View file

@ -41,20 +41,20 @@ export abstract class VNode<T extends Node = Node> {
type VChild = VElement | VText
abstract class VParent<T extends Node = Node> extends VNode<T>{
protected children: VChild[] = []
private insertedChildren: Set<VChild> = new Set()
private recentlyInserted: Set<VChild> = new Set()
insertChildAt(child: VChild, index: number) {
if (child.parentNode) {
child.parentNode.removeChild(child)
}
this.children.splice(index, 0, child)
this.insertedChildren.add(child)
this.recentlyInserted.add(child)
child.parentNode = this
}
removeChild(child: VChild) {
this.children = this.children.filter(ch => ch !== child)
this.insertedChildren.delete(child)
this.recentlyInserted.delete(child)
child.parentNode = null
}
@ -63,13 +63,13 @@ abstract class VParent<T extends Node = Node> extends VNode<T>{
/* Inserting */
for (let i = this.children.length-1; i >= 0; i--) {
const child = this.children[i]
child.applyChanges()
if (this.insertedChildren.has(child)) {
child.applyChanges() /* Building the sub-tree in memory first */
if (this.recentlyInserted.has(child)) {
const nextVSibling = this.children[i+1]
node.insertBefore(child.node, nextVSibling ? nextVSibling.node : null)
}
}
this.insertedChildren.clear()
this.recentlyInserted.clear()
/* Removing in-between */
const realChildren = node.childNodes
for(let j = 0; j < this.children.length; j++) {
@ -104,15 +104,17 @@ export class VShadowRoot extends VParent<ShadowRoot> {
constructor(protected readonly createNode: () => ShadowRoot) { super() }
}
export type VRoot = VDocument | VShadowRoot
export class VElement extends VParent<Element> {
parentNode: VParent | null = null
private newAttributes: Map<string, string | false> = new Map()
constructor(readonly tagName: string, readonly isSVG = false) { super() }
protected createNode(){
protected createNode() {
return this.isSVG
? document.createElementNS('http://www.w3.org/2000/svg', this.tagName)
: document.createElement(this.tagName)
? document.createElementNS('http://www.w3.org/2000/svg', this.tagName)
: document.createElement(this.tagName)
}
setAttribute(name: string, value: string) {
this.newAttributes.set(name, value)
@ -176,42 +178,108 @@ export class VText extends VNode<Text> {
}
}
export type StyleElement = HTMLStyleElement | SVGStyleElement
export class PostponedStyleSheet {
private loaded = false
private stylesheetCallbacks: Callback<CSSStyleSheet>[] = []
constructor(private readonly node: StyleElement) { //TODO: use virtual DOM + onNode callback for better lazy node init
node.addEventListener("load", () => {
const sheet = node.sheet
if (sheet) {
this.stylesheetCallbacks.forEach(cb => cb(sheet))
this.stylesheetCallbacks = []
} else {
console.warn("Style node onload: sheet is null")
}
this.loaded = true
class PromiseQueue<T> {
constructor(private promise: Promise<T>) {}
doNext(cb: Callback<T>) { // Doing this with callbacks list instead might be more efficient. TODO: research
this.promise = this.promise.then(vRoot => {
cb(vRoot)
return vRoot
})
}
catch(cb: Parameters<Promise<T>['catch']>[0]) {
this.promise.catch(cb)
}
}
private applyCallback(cb: Callback<CSSStyleSheet>) {
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)
}
/**
* VRoot wrapper that allows to defer all the API calls till the moment
* when VNode CAN be created (for example, on <iframe> mount&load)
* */
export class OnloadVRoot extends PromiseQueue<VRoot> {
static fromDocumentNode(doc: Document): OnloadVRoot {
return new OnloadVRoot(Promise.resolve(new VDocument(() => doc)))
}
static fromVElement(vElem: VElement): OnloadVRoot {
return new OnloadVRoot(new Promise((resolve, reject) => {
vElem.onNode(host => {
if (host instanceof HTMLIFrameElement) {
/* IFrame case: creating Document */
const doc = host.contentDocument
if (doc) {
resolve(new VDocument(() => doc))
} else {
host.addEventListener('load', () => {
const doc = host.contentDocument
if (doc) {
resolve(new VDocument(() => doc))
} else {
reject("No default Document found on iframe load") // Send `host` for logging as well
}
})
}
} else {
/* ShadowDom case */
try {
const shadowRoot = host.attachShadow({ mode: 'open' })
resolve(new VShadowRoot(() => shadowRoot))
} catch(e) {
reject(e) // "Can not attach shadow dom"
}
}
})
}))
}
onNode(cb: Callback<Document | ShadowRoot>) {
this.doNext(vRoot => vRoot.onNode(cb))
}
applyChanges() {
this.doNext(vRoot => vRoot.applyChanges())
}
insertChildAt(...args: Parameters<VParent['insertChildAt']>) {
this.doNext(vRoot => vRoot.insertChildAt(...args))
}
}
export type StyleElement = HTMLStyleElement | SVGStyleElement
/**
* CSSStyleSheet wrapper that collects all the insertRule/deleteRule calls
* and then applies them when the sheet is ready
* */
export class OnloadStyleSheet extends PromiseQueue<CSSStyleSheet> {
static fromStyleElement(node: StyleElement) {
return new OnloadStyleSheet(new Promise((resolve, reject) => {
node.addEventListener("load", () => {
const sheet = node.sheet
if (sheet) {
resolve(sheet)
} else {
reject("Style node onload: sheet is null")
}
})
}))
}
static fromVRootContext(vRoot: OnloadVRoot) {
return new OnloadStyleSheet(new Promise((resolve, reject) =>
vRoot.onNode(node => {
let context: typeof globalThis | null
if (node instanceof Document) {
context = node.defaultView
} else {
context = node.ownerDocument.defaultView
}
if (!context) { reject("Root node default view not found"); return }
/* a StyleSheet from another Window context won't work */
resolve(new context.CSSStyleSheet())
})
))
}
insertRule(rule: string, index: number) {
this.applyCallback(s => insertRule(s, { rule, index }))
this.doNext(s => insertRule(s, { rule, index }))
}
deleteRule(index: number) {
this.applyCallback(s => deleteRule(s, { index }))
this.doNext(s => deleteRule(s, { index }))
}
}