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

@ -81,9 +81,9 @@ export default function (app: App | null) {
}
adoptedStyleSheetsOwnings.set(nodeID, nowOwning)
}, 20) // Mysterious bug:
/* On the page https://explore.fast.design/components/fast-accordion
/* On the page https://explore.fast.design/components/fast-accordion
the only rule inside the only adoptedStyleSheet of the iframe-s document
gets changed during first milliseconds after the load.
gets changed during first milliseconds after the load.
However, none of the documented methods (replace, insertRule) is triggered.
The rule is not substituted (remains the same object), however the text gets changed.
*/
@ -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()
})
}