tracker: handle emotion-js style population

This commit is contained in:
nick-delirium 2025-05-02 10:05:44 +02:00 committed by Delirium
parent b109dd559a
commit 4a9a082896
4 changed files with 123 additions and 50 deletions

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "16.2.1",
"version": "16.2.2-beta.19",
"keywords": [
"logging",
"replay"

View file

@ -61,6 +61,7 @@ export type Options = Partial<
}
// dev only
__DISABLE_SECURE_MODE?: boolean
checkCssInterval?: number
}
const DOCS_SETUP = '/en/sdk'
@ -192,7 +193,7 @@ export default class API {
Mouse(app, options.mouse)
// inside iframe, we ignore viewport scroll
Scroll(app, this.crossdomainMode)
CSSRules(app)
CSSRules(app, options)
ConstructedStyleSheets(app)
Console(app, options)
Exception(app, options)

View file

@ -127,6 +127,7 @@ export default function (app: App | null) {
return replace.call(this, text).then((sheet: CSSStyleSheet) => {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
console.log('replace')
app.send(AdoptedSSReplaceURLBased(sheetID, text, app.getBaseHref()))
}
return sheet
@ -136,6 +137,7 @@ export default function (app: App | null) {
context.CSSStyleSheet.prototype.replaceSync = function (text: string) {
const sheetID = styleSheetIDMap.get(this)
if (sheetID) {
console.log('replaceSync')
app.send(AdoptedSSReplaceURLBased(sheetID, text, app.getBaseHref()))
}
return replaceSync.call(this, text)

View file

@ -1,6 +1,6 @@
import type App from '../app/index.js'
import {
AdoptedSSInsertRuleURLBased, // TODO: rename to common StyleSheet names
AdoptedSSInsertRuleURLBased,
AdoptedSSDeleteRule,
AdoptedSSAddOwner,
TechnicalInfo,
@ -8,104 +8,174 @@ import {
import { hasTag } from '../app/guards.js'
import { nextID, styleSheetIDMap } from './constructedStyleSheets.js'
export default function (app: App | null) {
if (app === null) {
return
}
export default function (app: App, opts: { checkCssInterval?: number }) {
if (app === null) return
if (!window.CSSStyleSheet) {
app.send(TechnicalInfo('no_stylesheet_prototype_in_window', ''))
return
}
// Track CSS rule snapshots by sheetID:index
const ruleSnapshots = new Map<string, string>()
let checkInterval: number | null = null
const checkIntervalMs = opts.checkCssInterval || 200 // Check every 200ms
// Check all rules for changes
function checkRuleChanges() {
for (let i = 0; i < document.styleSheets.length; i++) {
try {
const sheet = document.styleSheets[i]
const sheetID = styleSheetIDMap.get(sheet)
if (!sheetID) continue
// Check each rule in the sheet
for (let j = 0; j < sheet.cssRules.length; j++) {
try {
const rule = sheet.cssRules[j]
const key = `${sheetID}:${j}`
const newText = rule.cssText
const oldText = ruleSnapshots.get(key)
if (oldText !== newText) {
// Rule is new or changed
if (oldText !== undefined) {
// Rule changed - send update
app.send(AdoptedSSDeleteRule(sheetID, j))
app.send(AdoptedSSInsertRuleURLBased(sheetID, newText, j, app.getBaseHref()))
} else {
// Rule added - send insert
app.send(AdoptedSSInsertRuleURLBased(sheetID, newText, j, app.getBaseHref()))
}
ruleSnapshots.set(key, newText)
}
} catch (e) {
/* Skip inaccessible rules */
}
}
// Check for deleted rules
const keysToCheck = Array.from(ruleSnapshots.keys()).filter((key) =>
key.startsWith(`${sheetID}:`),
)
for (const key of keysToCheck) {
const index = parseInt(key.split(':')[1], 10)
if (index >= sheet.cssRules.length) {
ruleSnapshots.delete(key)
}
}
} catch (e) {
/* Skip inaccessible sheets */
}
}
}
// Standard API hooks
const sendInsertDeleteRule = app.safe((sheet: CSSStyleSheet, index: number, rule?: string) => {
const sheetID = styleSheetIDMap.get(sheet)
if (!sheetID) {
// OK-case. Sheet haven't been registered yet. Rules will be sent on registration.
return
}
if (!sheetID) return
if (typeof rule === 'string') {
app.send(AdoptedSSInsertRuleURLBased(sheetID, rule, index, app.getBaseHref()))
ruleSnapshots.set(`${sheetID}:${index}`, rule)
} else {
app.send(AdoptedSSDeleteRule(sheetID, index))
ruleSnapshots.delete(`${sheetID}:${index}`)
}
})
// TODO: proper rule insertion/removal (how?)
const sendReplaceGroupingRule = app.safe((rule: CSSGroupingRule) => {
let topmostRule: CSSRule = rule
while (topmostRule.parentRule) {
topmostRule = topmostRule.parentRule
}
while (topmostRule.parentRule) topmostRule = topmostRule.parentRule
const sheet = topmostRule.parentStyleSheet
if (!sheet) {
app.debug.warn('No parent StyleSheet found for', topmostRule, rule)
return
}
if (!sheet) return
const sheetID = styleSheetIDMap.get(sheet)
if (!sheetID) {
app.debug.warn('No sheedID found for', sheet, styleSheetIDMap)
return
}
if (!sheetID) return
const cssText = topmostRule.cssText
const ruleList = sheet.cssRules
const idx = Array.from(ruleList).indexOf(topmostRule)
const idx = Array.from(sheet.cssRules).indexOf(topmostRule)
if (idx >= 0) {
app.send(AdoptedSSInsertRuleURLBased(sheetID, cssText, idx, app.getBaseHref()))
app.send(AdoptedSSDeleteRule(sheetID, idx + 1)) // Remove previous clone
} else {
app.debug.warn('Rule index not found in', sheet, topmostRule)
app.send(AdoptedSSDeleteRule(sheetID, idx + 1))
ruleSnapshots.set(`${sheetID}:${idx}`, cssText)
}
})
// Patch prototype methods
const patchContext = app.safe((context: typeof globalThis) => {
if ((context as any).__css_tracking_patched__) return
;(context as any).__css_tracking_patched__ = true
const { insertRule, deleteRule } = context.CSSStyleSheet.prototype
const { insertRule: groupInsertRule, deleteRule: groupDeleteRule } =
context.CSSGroupingRule.prototype
context.CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0): number {
sendInsertDeleteRule(this, index, rule)
return insertRule.call(this, rule, index)
context.CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
const result = insertRule.call(this, rule, index)
sendInsertDeleteRule(this, result, rule)
return result
}
context.CSSStyleSheet.prototype.deleteRule = function (index: number): void {
context.CSSStyleSheet.prototype.deleteRule = function (index: number) {
sendInsertDeleteRule(this, index)
return deleteRule.call(this, index)
}
context.CSSGroupingRule.prototype.insertRule = function (rule: string, index = 0): number {
const result = groupInsertRule.call(this, rule, index) as number
context.CSSGroupingRule.prototype.insertRule = function (rule: string, index = 0) {
const result = groupInsertRule.call(this, rule, index)
sendReplaceGroupingRule(this)
return result
}
context.CSSGroupingRule.prototype.deleteRule = function (index = 0): void {
const result = groupDeleteRule.call(this, index) as void
context.CSSGroupingRule.prototype.deleteRule = function (index: number) {
const result = groupDeleteRule.call(this, index)
sendReplaceGroupingRule(this)
return result
}
})
// Apply patches
patchContext(window)
app.observer.attachContextCallback(patchContext)
// Track style nodes
app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'style') || !node.sheet) {
return
}
if (node.textContent !== null && node.textContent.trim().length > 0) {
return // Non-virtual styles captured by the observer as a text
}
if (!hasTag(node, 'style') || !node.sheet) return
if (node.textContent !== null && node.textContent.trim().length > 0) return
const nodeID = app.nodes.getID(node)
if (!nodeID) {
return
}
if (!nodeID) return
const sheet = node.sheet
const sheetID = nextID()
styleSheetIDMap.set(sheet, sheetID)
app.send(AdoptedSSAddOwner(sheetID, nodeID))
const rules = sheet.cssRules
for (let i = 0; i < rules.length; i++) {
sendInsertDeleteRule(sheet, i, rules[i].cssText)
for (let i = 0; i < sheet.cssRules.length; i++) {
try {
sendInsertDeleteRule(sheet, i, sheet.cssRules[i].cssText)
} catch (e) {
// Skip inaccessible rules
}
}
})
// Start checking and setup cleanup
function startChecking() {
if (checkInterval) return
checkInterval = window.setInterval(checkRuleChanges, checkIntervalMs)
}
setTimeout(startChecking, 50)
app.attachStopCallback(() => {
if (checkInterval) {
clearInterval(checkInterval)
checkInterval = null
}
ruleSnapshots.clear()
})
}