From 5c3fc505ab2296acbfc58336abeeeda3b69534f3 Mon Sep 17 00:00:00 2001 From: Alex Kaminskii Date: Wed, 17 Aug 2022 18:21:25 +0200 Subject: [PATCH] feat(tracker): adoptedStyleSheets --- tracker/tracker/src/common/messages.gen.ts | 40 ++++- tracker/tracker/src/main/app/messages.gen.ts | 61 +++++++ .../src/main/app/observer/top_observer.ts | 35 ++-- tracker/tracker/src/main/index.ts | 2 + .../src/main/modules/adoptedStyleSheets.ts | 155 ++++++++++++++++++ tracker/tracker/src/main/modules/scroll.ts | 21 ++- .../src/webworker/MessageEncoder.gen.ts | 20 +++ 7 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 tracker/tracker/src/main/modules/adoptedStyleSheets.ts diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts index 6d5bd83a4..365246b82 100644 --- a/tracker/tracker/src/common/messages.gen.ts +++ b/tracker/tracker/src/common/messages.gen.ts @@ -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 diff --git a/tracker/tracker/src/main/app/messages.gen.ts b/tracker/tracker/src/main/app/messages.gen.ts index 8d5e95880..05f4f2894 100644 --- a/tracker/tracker/src/main/app/messages.gen.ts +++ b/tracker/tracker/src/main/app/messages.gen.ts @@ -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, + ] +} + diff --git a/tracker/tracker/src/main/app/observer/top_observer.ts b/tracker/tracker/src/main/app/observer/top_observer.ts index a4c9343c6..55ba5af7d 100644 --- a/tracker/tracker/src/main/app/observer/top_observer.ts +++ b/tracker/tracker/src/main/app/observer/top_observer.ts @@ -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 = [] + + // 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() diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index fbe53baef..53452f863 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -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) diff --git a/tracker/tracker/src/main/modules/adoptedStyleSheets.ts b/tracker/tracker/src/main/modules/adoptedStyleSheets.ts new file mode 100644 index 000000000..8716c8a5e --- /dev/null +++ b/tracker/tracker/src/main/modules/adoptedStyleSheets.ts @@ -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 = new Map() + const adoptedStyleSheetsOwnings: Map = 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) + } + }) +} diff --git a/tracker/tracker/src/main/modules/scroll.ts b/tracker/tracker/src/main/modules/scroll.ts index a14399ea1..5f428fe11 100644 --- a/tracker/tracker/src/main/modules/scroll.ts +++ b/tracker/tracker/src/main/modules/scroll.ts @@ -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 = new Map() + const nodeScroll: Map = 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( diff --git a/tracker/tracker/src/webworker/MessageEncoder.gen.ts b/tracker/tracker/src/webworker/MessageEncoder.gen.ts index cd98daab8..c38fa77a8 100644 --- a/tracker/tracker/src/webworker/MessageEncoder.gen.ts +++ b/tracker/tracker/src/webworker/MessageEncoder.gen.ts @@ -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 + } }