feat(tracker): adoptedStyleSheets

This commit is contained in:
Alex Kaminskii 2022-08-17 18:21:25 +02:00
parent 3ec74095d7
commit 5c3fc505ab
7 changed files with 316 additions and 18 deletions

View file

@ -51,6 +51,11 @@ export enum Type {
CSSInsertRuleURLBased = 67,
MouseClick = 69,
CreateIFrameDocument = 70,
AdoptedSSReplaceURLBased = 71,
AdoptedSSInsertRuleURLBased = 73,
AdoptedSSDeleteRule = 75,
AdoptedSSAddOwner = 76,
AdoptedSSRemoveOwner = 77,
}
@ -400,6 +405,39 @@ export type CreateIFrameDocument = [
id: number,
]
export type AdoptedSSReplaceURLBased = [
type: Type.AdoptedSSReplaceURLBased,
sheetID: number,
text: string,
baseURL: string,
]
type Message = BatchMetadata | PartitionedMessage | Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | PageLoadTiming | PageRenderTiming | JSException | RawCustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | ResourceTiming | ConnectionInformation | SetPageVisibility | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument
export type AdoptedSSInsertRuleURLBased = [
type: Type.AdoptedSSInsertRuleURLBased,
sheetID: number,
rule: string,
index: number,
baseURL: string,
]
export type AdoptedSSDeleteRule = [
type: Type.AdoptedSSDeleteRule,
sheetID: number,
index: number,
]
export type AdoptedSSAddOwner = [
type: Type.AdoptedSSAddOwner,
sheetID: number,
id: number,
]
export type AdoptedSSRemoveOwner = [
type: Type.AdoptedSSRemoveOwner,
sheetID: number,
id: number,
]
type Message = BatchMetadata | PartitionedMessage | Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | PageLoadTiming | PageRenderTiming | JSException | RawCustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | ResourceTiming | ConnectionInformation | SetPageVisibility | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner
export default Message

View file

@ -646,3 +646,64 @@ export function CreateIFrameDocument(
]
}
export function AdoptedSSReplaceURLBased(
sheetID: number,
text: string,
baseURL: string,
): Messages.AdoptedSSReplaceURLBased {
return [
Messages.Type.AdoptedSSReplaceURLBased,
sheetID,
text,
baseURL,
]
}
export function AdoptedSSInsertRuleURLBased(
sheetID: number,
rule: string,
index: number,
baseURL: string,
): Messages.AdoptedSSInsertRuleURLBased {
return [
Messages.Type.AdoptedSSInsertRuleURLBased,
sheetID,
rule,
index,
baseURL,
]
}
export function AdoptedSSDeleteRule(
sheetID: number,
index: number,
): Messages.AdoptedSSDeleteRule {
return [
Messages.Type.AdoptedSSDeleteRule,
sheetID,
index,
]
}
export function AdoptedSSAddOwner(
sheetID: number,
id: number,
): Messages.AdoptedSSAddOwner {
return [
Messages.Type.AdoptedSSAddOwner,
sheetID,
id,
]
}
export function AdoptedSSRemoveOwner(
sheetID: number,
id: number,
): Messages.AdoptedSSRemoveOwner {
return [
Messages.Type.AdoptedSSRemoveOwner,
sheetID,
id,
]
}

View file

@ -12,6 +12,8 @@ export interface Options {
captureIFrames: boolean
}
type ContextCallback = (context: Window & typeof globalThis) => void
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () => new ShadowRoot()
export default class TopObserver extends Observer {
@ -44,25 +46,36 @@ export default class TopObserver extends Observer {
})
}
private readonly contextCallbacks: Array<ContextCallback> = []
// Attached once per Tracker instance
attachContextCallback(cb: ContextCallback) {
this.contextCallbacks.push(cb)
}
private iframeObservers: IFrameObserver[] = []
private handleIframe(iframe: HTMLIFrameElement): void {
let doc: Document | null = null
let win: Window | null = null
const handle = this.app.safe(() => {
const id = this.app.nodes.getID(iframe)
if (id === undefined) {
return
} //log
if (iframe.contentDocument === doc) {
return
} // How frequently can it happen?
doc = iframe.contentDocument
if (!doc || !iframe.contentWindow) {
//log
return
}
const observer = new IFrameObserver(this.app)
this.iframeObservers.push(observer)
observer.observe(iframe)
const currentWin = iframe.contentWindow
const currentDoc = iframe.contentDocument
if (currentDoc && currentDoc !== doc) {
const observer = new IFrameObserver(this.app)
this.iframeObservers.push(observer)
observer.observe(iframe)
doc = currentDoc
}
if (currentWin && currentWin !== win) {
//@ts-ignore https://github.com/microsoft/TypeScript/issues/41684
this.contextCallbacks.forEach((cb) => cb(currentWin))
win = currentWin
}
})
iframe.addEventListener('load', handle) // why app.attachEventListener not working?
handle()

View file

@ -19,6 +19,7 @@ import Performance from './modules/performance.js'
import Scroll from './modules/scroll.js'
import Viewport from './modules/viewport.js'
import CSSRules from './modules/cssrules.js'
import AdoptedStyleSheets from './modules/adoptedStyleSheets.js'
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'
import type { Options as AppOptions } from './app/index.js'
@ -113,6 +114,7 @@ export default class API {
if (app !== null) {
Viewport(app)
CSSRules(app)
AdoptedStyleSheets(app)
Connection(app)
Console(app, options)
Exception(app, options)

View file

@ -0,0 +1,155 @@
import type App from '../app/index.js'
import {
TechnicalInfo,
AdoptedSSReplaceURLBased,
AdoptedSSInsertRuleURLBased,
AdoptedSSDeleteRule,
AdoptedSSAddOwner,
AdoptedSSRemoveOwner,
} from '../app/messages.gen.js'
import { isRootNode } from '../app/guards.js'
type StyleSheetOwner = (Document | ShadowRoot) & { adoptedStyleSheets: CSSStyleSheet[] }
function hasAdoptedSS(node: Node): node is StyleSheetOwner {
return (
isRootNode(node) &&
// @ts-ignore
typeof node.adoptedStyleSheets !== 'undefined'
)
}
export default function (app: App | null) {
if (app === null) {
return
}
if (!hasAdoptedSS(document)) {
app.attachStartCallback(() => {
// MBTODO: pre-start sendQueue app
app.send(TechnicalInfo('no_adopted_stylesheets', ''))
})
return
}
let nextID = 0xf
const styleSheetIDMap: Map<CSSStyleSheet, number> = new Map()
const adoptedStyleSheetsOwnings: Map<number, number[]> = new Map()
const updateAdoptedStyleSheets = (root: StyleSheetOwner) => {
let nodeID = app.nodes.getID(root)
if (root === document) {
nodeID = 0 // main document doesn't have nodeID. ID count starts from the documentElement
}
if (!nodeID) {
return
}
let pastOwning = adoptedStyleSheetsOwnings.get(nodeID)
if (!pastOwning) {
pastOwning = []
}
const nowOwning: number[] = []
const styleSheets = root.adoptedStyleSheets
for (const s of styleSheets) {
let sheetID = styleSheetIDMap.get(s)
const init = !sheetID
if (!sheetID) {
sheetID = ++nextID
}
nowOwning.push(sheetID)
if (!pastOwning.includes(sheetID)) {
app.send(AdoptedSSAddOwner(sheetID, nodeID))
}
if (init) {
const rules = s.cssRules
for (let i = 0; i < rules.length; i++) {
app.send(AdoptedSSInsertRuleURLBased(sheetID, rules[i].cssText, i, app.getBaseHref()))
}
}
}
for (const sheetID of pastOwning) {
if (!nowOwning.includes(sheetID)) {
app.send(AdoptedSSRemoveOwner(sheetID, nodeID))
}
}
adoptedStyleSheetsOwnings.set(nodeID, nowOwning)
}
function patchAdoptedStyleSheets(
prototype: typeof Document.prototype | typeof ShadowRoot.prototype,
) {
const nativeAdoptedStyleSheetsDescriptor = Object.getOwnPropertyDescriptor(
prototype,
'adoptedStyleSheets',
)
if (nativeAdoptedStyleSheetsDescriptor) {
Object.defineProperty(prototype, 'adoptedStyleSheets', {
...nativeAdoptedStyleSheetsDescriptor,
set: function (this: StyleSheetOwner, value) {
// @ts-ignore
const retVal = nativeAdoptedStyleSheetsDescriptor.set.call(this, value)
updateAdoptedStyleSheets(this)
return retVal
},
})
}
}
const patchContext = (context: typeof globalThis): void => {
patchAdoptedStyleSheets(context.Document.prototype)
patchAdoptedStyleSheets(context.ShadowRoot.prototype)
//@ts-ignore TODO: configure ts (use necessary lib)
const { insertRule, deleteRule, replace, replaceSync } = context.CSSStyleSheet.prototype
//@ts-ignore
context.CSSStyleSheet.prototype.replace = function (text: string) {
return replace.call(this, text).then((sheet: CSSStyleSheet) => {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
app.send(AdoptedSSReplaceURLBased(sheetID, text, app.getBaseHref()))
}
return sheet
})
}
//@ts-ignore
context.CSSStyleSheet.prototype.replaceSync = function (text: string) {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
app.send(AdoptedSSReplaceURLBased(sheetID, text, app.getBaseHref()))
}
return replaceSync.call(this, text)
}
context.CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
app.send(AdoptedSSInsertRuleURLBased(sheetID, rule, index, app.getBaseHref()))
}
return insertRule.call(this, rule, index)
}
context.CSSStyleSheet.prototype.deleteRule = function (index: number) {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
app.send(AdoptedSSDeleteRule(sheetID, index))
}
return deleteRule.call(this, index)
}
}
patchContext(window)
app.observer.attachContextCallback(patchContext)
app.attachStopCallback(() => {
styleSheetIDMap.clear()
adoptedStyleSheetsOwnings.clear()
})
// So far main Document is not triggered with nodeCallbacks
app.attachStartCallback(() => {
updateAdoptedStyleSheets(document as StyleSheetOwner)
})
app.nodes.attachNodeCallback((node: Node): void => {
if (hasAdoptedSS(node)) {
updateAdoptedStyleSheets(node)
}
})
}

View file

@ -1,10 +1,16 @@
import type App from '../app/index.js'
import { SetViewportScroll, SetNodeScroll } from '../app/messages.gen.js'
import { isElementNode } from '../app/guards.js'
import { isElementNode, isRootNode } from '../app/guards.js'
export default function (app: App): void {
let documentScroll = false
const nodeScroll: Map<Element, [number, number]> = new Map()
const nodeScroll: Map<Node, [number, number]> = new Map()
function setNodeScroll(target: EventTarget | null) {
if (target instanceof Element) {
nodeScroll.set(target, [target.scrollLeft, target.scrollTop])
}
}
const sendSetViewportScroll = app.safe((): void =>
app.send(
@ -38,18 +44,21 @@ export default function (app: App): void {
app.nodes.attachNodeCallback((node, isStart) => {
if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) {
nodeScroll.set(node, [node.scrollLeft, node.scrollTop])
} else if (isRootNode(node)) {
// scroll is not-composed event (https://javascript.info/shadow-dom-events)
app.attachEventListener(node, 'scroll', (e: Event): void => {
setNodeScroll(e.target)
})
}
})
app.attachEventListener(window, 'scroll', (e: Event): void => {
app.attachEventListener(document, 'scroll', (e: Event): void => {
const target = e.target
if (target === document) {
documentScroll = true
return
}
if (target instanceof Element) {
nodeScroll.set(target, [target.scrollLeft, target.scrollTop])
}
setNodeScroll(target)
})
app.ticker.attach(

View file

@ -209,6 +209,26 @@ export default class MessageEncoder extends PrimitiveEncoder {
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.AdoptedSSReplaceURLBased:
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3])
break
case Messages.Type.AdoptedSSInsertRuleURLBased:
return this.uint(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.string(msg[4])
break
case Messages.Type.AdoptedSSDeleteRule:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.AdoptedSSAddOwner:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.AdoptedSSRemoveOwner:
return this.uint(msg[1]) && this.uint(msg[2])
break
}
}