diff --git a/package.json b/package.json index 20ea0a63..b1922fe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@highlight-run/rrweb", - "version": "0.12.14", + "version": "1.0.7", "description": "record and replay the web", "scripts": { "test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts", diff --git a/src/record/mutation.ts b/src/record/mutation.ts index b3e9a3d7..d3cf5585 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -21,6 +21,7 @@ import { removedNodeMutation, addedNodeMutation, Mirror, + styleAttributeValue, } from '../types'; import { isBlocked, @@ -456,6 +457,7 @@ export default class MutationBuffer { break; } case 'attributes': { + const target = m.target as HTMLElement; let value = (m.target as HTMLElement).getAttribute(m.attributeName!); if (m.attributeName === 'value') { value = maskInputValue({ diff --git a/src/record/observer.ts b/src/record/observer.ts index 2f718d7e..a2566413 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -45,15 +45,16 @@ import { fontParam, Mirror, styleDeclarationCallback, + IWindow, } from '../types'; import MutationBuffer from './mutation'; import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; -type WindowWithStoredMutationObserver = Window & { +type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; }; -type WindowWithAngularZone = Window & { +type WindowWithAngularZone = IWindow & { Zone?: { __symbol__?: (key: string) => string; }; @@ -203,44 +204,25 @@ function initMoveObserver( }, callbackThreshold, ); - // update position for mouse, touch, and drag events (drag event extends mouse event) - function handleUpdatePositionEvent(evt: MouseEvent | TouchEvent) { - const target = getEventTarget(evt); - const { clientX, clientY } = isTouchEvent(evt) - ? evt.changedTouches[0] - : evt; - if (!timeBaseline) { - timeBaseline = Date.now(); - } - positions.push({ - x: clientX, - y: clientY, - id: mirror.getId(target as INode), - timeOffset: Date.now() - timeBaseline, - }); - } - - // separate call for non-drag events, in case DragEvent is not defined - const updatePosition = throttle( - (evt) => { - handleUpdatePositionEvent(evt); - wrappedCb( - evt instanceof MouseEvent - ? IncrementalSource.MouseMove - : IncrementalSource.TouchMove, - ); - }, - threshold, - { - trailing: false, - }, - ); - // call for drag events, when DragEvent is defined - const updateDragPosition = throttle( + const updatePosition = throttle( (evt) => { - handleUpdatePositionEvent(evt); + const target = getEventTarget(evt); + const { clientX, clientY } = isTouchEvent(evt) + ? evt.changedTouches[0] + : evt; + if (!timeBaseline) { + timeBaseline = Date.now(); + } + positions.push({ + x: clientX, + y: clientY, + id: mirror.getId(target as INode), + timeOffset: Date.now() - timeBaseline, + }); + // it is possible DragEvent is undefined even on devices + // that support event 'drag' wrappedCb( - evt instanceof DragEvent + typeof DragEvent !== 'undefined' && evt instanceof DragEvent ? IncrementalSource.Drag : evt instanceof MouseEvent ? IncrementalSource.MouseMove @@ -252,13 +234,10 @@ function initMoveObserver( trailing: false, }, ); - // it is possible DragEvent is undefined even on devices - // that support event 'drag' - const dragEventDefined = typeof DragEvent !== 'undefined'; const handlers = [ on('mousemove', updatePosition, doc), on('touchmove', updatePosition, doc), - on('drag', dragEventDefined ? updateDragPosition : updatePosition, doc), + on('drag', updatePosition, doc), ]; return () => { handlers.forEach((h) => h()); @@ -518,11 +497,17 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] { const positions: number[] = []; function recurse(childRule: CSSRule, pos: number[]) { if ( - isCSSGroupingRuleSupported && - childRule.parentRule instanceof CSSGroupingRule + (isCSSGroupingRuleSupported && + childRule.parentRule instanceof CSSGroupingRule) || + (isCSSMediaRuleSupported && + childRule.parentRule instanceof CSSMediaRule) || + (isCSSSupportsRuleSupported && + childRule.parentRule instanceof CSSSupportsRule) || + (isCSSConditionRuleSupported && + childRule.parentRule instanceof CSSConditionRule) ) { const rules = Array.from( - (childRule.parentRule as CSSGroupingRule).cssRules, + (childRule.parentRule as GroupingCSSRule).cssRules, ); const index = rules.indexOf(childRule); pos.unshift(index); @@ -538,11 +523,11 @@ function getNestedCSSRulePositions(rule: CSSRule): number[] { function initStyleSheetObserver( cb: styleSheetRuleCallback, - win: Window, + win: IWindow, mirror: Mirror, ): listenerHandler { - const insertRule = (win as any).CSSStyleSheet.prototype.insertRule; - (win as any).CSSStyleSheet.prototype.insertRule = function ( + const insertRule = win.CSSStyleSheet.prototype.insertRule; + win.CSSStyleSheet.prototype.insertRule = function ( rule: string, index?: number, ) { @@ -556,8 +541,8 @@ function initStyleSheetObserver( return insertRule.apply(this, arguments); }; - const deleteRule = (win as any).CSSStyleSheet.prototype.deleteRule; - (win as any).CSSStyleSheet.prototype.deleteRule = function (index: number) { + const deleteRule = win.CSSStyleSheet.prototype.deleteRule; + win.CSSStyleSheet.prototype.deleteRule = function (index: number) { const id = mirror.getId(this.ownerNode as INode); if (id !== -1) { cb({ @@ -572,26 +557,20 @@ function initStyleSheetObserver( [key: string]: GroupingCSSRuleTypes; } = {}; if (isCSSGroupingRuleSupported) { - supportedNestedCSSRuleTypes[ - 'CSSGroupingRule' - ] = (win as any).CSSGroupingRule; + supportedNestedCSSRuleTypes.CSSGroupingRule = win.CSSGroupingRule; } else { // Some browsers (Safari) don't support CSSGroupingRule // https://caniuse.com/?search=cssgroupingrule // fall back to monkey patching classes that would have inherited from CSSGroupingRule if (isCSSMediaRuleSupported) { - supportedNestedCSSRuleTypes['CSSMediaRule'] = (win as any).CSSMediaRule; + supportedNestedCSSRuleTypes.CSSMediaRule = win.CSSMediaRule; } if (isCSSConditionRuleSupported) { - supportedNestedCSSRuleTypes[ - 'CSSConditionRule' - ] = (win as any).CSSConditionRule; + supportedNestedCSSRuleTypes.CSSConditionRule = win.CSSConditionRule; } if (isCSSSupportsRuleSupported) { - supportedNestedCSSRuleTypes[ - 'CSSSupportsRule' - ] = (win as any).CSSSupportsRule; + supportedNestedCSSRuleTypes.CSSSupportsRule = win.CSSSupportsRule; } } @@ -640,8 +619,8 @@ function initStyleSheetObserver( }); return () => { - (win as any).CSSStyleSheet.prototype.insertRule = insertRule; - (win as any).CSSStyleSheet.prototype.deleteRule = deleteRule; + win.CSSStyleSheet.prototype.insertRule = insertRule; + win.CSSStyleSheet.prototype.deleteRule = deleteRule; Object.entries(supportedNestedCSSRuleTypes).forEach(([typeKey, type]) => { type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; @@ -651,11 +630,11 @@ function initStyleSheetObserver( function initStyleDeclarationObserver( cb: styleDeclarationCallback, - win: Window, + win: IWindow, mirror: Mirror, ): listenerHandler { - const setProperty = (win as any).CSSStyleDeclaration.prototype.setProperty; - (win as any).CSSStyleDeclaration.prototype.setProperty = function ( + const setProperty = win.CSSStyleDeclaration.prototype.setProperty; + win.CSSStyleDeclaration.prototype.setProperty = function ( this: CSSStyleDeclaration, property: string, value: string, @@ -678,9 +657,8 @@ function initStyleDeclarationObserver( return setProperty.apply(this, arguments); }; - const removeProperty = (win as any).CSSStyleDeclaration.prototype - .removeProperty; - (win as any).CSSStyleDeclaration.prototype.removeProperty = function ( + const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty; + win.CSSStyleDeclaration.prototype.removeProperty = function ( this: CSSStyleDeclaration, property: string, ) { @@ -700,8 +678,8 @@ function initStyleDeclarationObserver( }; return () => { - (win as any).CSSStyleDeclaration.prototype.setProperty = setProperty; - (win as any).CSSStyleDeclaration.prototype.removeProperty = removeProperty; + win.CSSStyleDeclaration.prototype.setProperty = setProperty; + win.CSSStyleDeclaration.prototype.removeProperty = removeProperty; }; } @@ -733,7 +711,7 @@ function initMediaInteractionObserver( function initCanvasMutationObserver( cb: canvasMutationCallback, - win: Window, + win: IWindow, blockClass: blockClass, mirror: Mirror, ): listenerHandler { @@ -814,14 +792,17 @@ function initCanvasMutationObserver( } function initFontObserver(cb: fontCallback, doc: Document): listenerHandler { - const win = doc.defaultView; + const win = doc.defaultView as IWindow; + if (!win) { + return () => {}; + } + const handlers: listenerHandler[] = []; const fontMap = new WeakMap(); - const originalFontFace = (win as any).FontFace; - // tslint:disable-next-line: no-any - (win as any).FontFace = function FontFace( + const originalFontFace = win.FontFace; + win.FontFace = (function FontFace( family: string, source: string | ArrayBufferView, descriptors?: FontFaceDescriptors, @@ -838,7 +819,7 @@ function initFontObserver(cb: fontCallback, doc: Document): listenerHandler { JSON.stringify(Array.from(new Uint8Array(source as any))), }); return fontFace; - }; + } as unknown) as typeof FontFace; const restoreHandler = patch(doc.fonts, 'add', function (original) { return function (this: FontFaceSet, fontFace: FontFace) { @@ -854,8 +835,7 @@ function initFontObserver(cb: fontCallback, doc: Document): listenerHandler { }); handlers.push(() => { - // tslint:disable-next-line: no-any - (win as any).FonFace = originalFontFace; + win.FontFace = originalFontFace; }); handlers.push(restoreHandler); @@ -950,6 +930,10 @@ export function initObservers( o: observerParam, hooks: hooksParam = {}, ): listenerHandler { + const currentWindow = o.doc.defaultView; // basically document.window + if (!currentWindow) { + return () => {}; + } mergeHooks(o, hooks); const mutationObserver = initMutationObserver( o.mutationCb, @@ -1008,8 +992,6 @@ export function initObservers( o.mirror, ); - const currentWindow = o.doc.defaultView as Window; // basically document.window - const styleSheetObserver = initStyleSheetObserver( o.styleSheetRuleCb, currentWindow, diff --git a/src/replay/index.ts b/src/replay/index.ts index 5ac03ba7..81b79c1d 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1,4 +1,11 @@ -import { rebuild, buildNodeWithSN, INode, NodeType } from '../snapshot'; +import { + rebuild, + buildNodeWithSN, + INode, + NodeType, + BuildCache, + createCache, +} from '../snapshot'; import * as mittProxy from 'mitt'; import { polyfill as smoothscrollPolyfill } from './smoothscroll'; import { Timer } from './timer'; @@ -30,6 +37,8 @@ import { ElementState, SessionInterval, mouseMovePos, + styleAttributeValue, + styleValueWithPriority, } from '../types'; import { createMirror, @@ -111,6 +120,9 @@ export class Replayer { // Hold the list of CSSRules for in-memory state restoration private virtualStyleRulesMap!: VirtualStyleRulesMap; + // The replayer uses the cache to speed up replay and scrubbing. + private cache: BuildCache = createCache(); + private imageMap: Map = new Map(); /** The first time the player is playing. */ private nextUserInteractionEvent: eventWithTime | null; @@ -145,7 +157,6 @@ export class Replayer { triggerFocus: true, UNSAFE_replayCanvas: false, pauseAnimation: true, - userTriggeredOnInput: true, mouseTail: defaultMouseTailConfig, inactiveThreshold: 0.02, inactiveSkipTime: SKIP_TIME_INTERVAL, @@ -501,6 +512,14 @@ export class Replayer { this.iframe.style.pointerEvents = 'none'; } + /** + * Empties the replayer's cache and reclaims memory. + * The replayer will use this cache to speed up the playback. + */ + public resetCache() { + this.cache = createCache(); + } + private setupDom() { this.wrapper = document.createElement('div'); this.wrapper.classList.add('replayer-wrapper'); @@ -770,6 +789,7 @@ export class Replayer { afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); }, + cache: this.cache, })[1]; for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -843,6 +863,7 @@ export class Replayer { afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); }, + cache: this.cache, }); for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -973,7 +994,11 @@ export class Replayer { d.attributes.forEach((m) => this.treeIndex.attribute(m)); d.removes.forEach((m) => this.treeIndex.remove(m, this.mirror)); } - this.applyMutation(d, isSync); + try { + this.applyMutation(d, isSync); + } catch (error) { + this.warn(`Exception in mutation ${error.message || error}`, d); + } break; } case IncrementalSource.Drag: @@ -1053,7 +1078,6 @@ export class Replayer { debugData: d, }; } else { - console.log(d.type); if (d.type === MouseInteractions.TouchStart) { // don't draw a trail as user has lifted finger and is placing at a new point this.tailPositions.length = 0; @@ -1374,8 +1398,12 @@ export class Replayer { private applyMutation(d: mutationData, useVirtualParent: boolean) { d.removes.forEach((mutation) => { - const target = this.mirror.getNode(mutation.id); + let target = this.mirror.getNode(mutation.id); if (!target) { + if (d.removes.find((r) => r.id === mutation.parentId)) { + // no need to warn, parent was already removed + return; + } return this.warnNodeNotFound(d, mutation.id); } if (this.virtualStyleRulesMap.has(target)) { @@ -1393,20 +1421,35 @@ export class Replayer { // target may be removed with its parents before this.mirror.removeNodeFromMap(target); if (parent) { + let realTarget = null; const realParent = '__sn' in parent ? this.fragmentParentMap.get(parent) : undefined; if (realParent && realParent.contains(target)) { - realParent.removeChild(target); + parent = realParent; } else if (this.fragmentParentMap.has(target)) { /** * the target itself is a fragment document and it's not in the dom * so we should remove the real target from its parent */ - const realTarget = this.fragmentParentMap.get(target)!; - parent.removeChild(realTarget); + realTarget = this.fragmentParentMap.get(target)!; this.fragmentParentMap.delete(target); - } else { + target = realTarget; + } + try { parent.removeChild(target); + } catch (error) { + if (error instanceof DOMException) { + this.warn( + 'parent could not remove child in mutation', + parent, + realParent, + target, + realTarget, + d, + ); + } else { + throw error; + } } } }); @@ -1517,6 +1560,7 @@ export class Replayer { map: this.mirror.map, skipChild: true, hackCss: true, + cache: this.cache, }) as INode; // legacy data, we should not have -1 siblings any more @@ -1613,6 +1657,10 @@ export class Replayer { d.texts.forEach((mutation) => { let target = this.mirror.getNode(mutation.id); if (!target) { + if (d.removes.find((r) => r.id === mutation.id)) { + // no need to warn, element was already removed + return; + } return this.warnNodeNotFound(d, mutation.id); } /** @@ -1626,6 +1674,10 @@ export class Replayer { d.attributes.forEach((mutation) => { let target = this.mirror.getNode(mutation.id); if (!target) { + if (d.removes.find((r) => r.id === mutation.id)) { + // no need to warn, element was already removed + return; + } return this.warnNodeNotFound(d, mutation.id); } if (this.fragmentParentMap.has(target)) { @@ -1634,18 +1686,32 @@ export class Replayer { for (const attributeName in mutation.attributes) { if (typeof attributeName === 'string') { const value = mutation.attributes[attributeName]; - try { - if (value !== null) { + if (value === null) { + ((target as Node) as Element).removeAttribute(attributeName); + } else if (typeof value === 'string') { + try { ((target as Node) as Element).setAttribute(attributeName, value); - } else { - ((target as Node) as Element).removeAttribute(attributeName); + } catch (error) { + if (this.config.showWarning) { + console.warn( + 'An error occurred may due to the checkout feature.', + error, + ); + } } - } catch (error) { - if (this.config.showWarning) { - console.warn( - 'An error occurred may due to the checkout feature.', - error, - ); + } else if (attributeName === 'style') { + let styleValues = value as styleAttributeValue; + const targetEl = (target as Node) as HTMLElement; + for (var s in styleValues) { + if (styleValues[s] === false) { + targetEl.style.removeProperty(s); + } else if (styleValues[s] instanceof Array) { + const svp = styleValues[s] as styleValueWithPriority; + targetEl.style.setProperty(s, svp[0], svp[1]); + } else { + const svs = styleValues[s] as string; + targetEl.style.setProperty(s, svs); + } } } } @@ -1901,7 +1967,11 @@ export class Replayer { } private warnNodeNotFound(d: incrementalData, id: number) { - this.warn(`Node with id '${id}' not found in`, d); + if (this.treeIndex.idRemoved(id)) { + this.warn(`Node with id '${id}' was previously removed. `, d); + } else { + this.warn(`Node with id '${id}' not found. `, d); + } } private warnCanvasMutationFailed( @@ -1919,7 +1989,15 @@ export class Replayer { * is microtask, so events fired on a removed DOM may emit * snapshots in the reverse order. */ - this.debug(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found in`, d); + if (this.treeIndex.idRemoved(id)) { + this.debug( + REPLAY_CONSOLE_PREFIX, + `Node with id '${id}' was previously removed. `, + d, + ); + } else { + this.debug(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found. `, d); + } } private warn(...args: Parameters) { diff --git a/src/replay/timer.ts b/src/replay/timer.ts index 2d2d7c23..684c3ff0 100644 --- a/src/replay/timer.ts +++ b/src/replay/timer.ts @@ -88,7 +88,9 @@ export class Timer { } else if (this.actions[mid].delay > action.delay) { end = mid - 1; } else { - return mid; + // already an action with same delay (timestamp) + // the plus one will splice the new one after the existing one + return mid + 1; } } return start; diff --git a/src/snapshot/index.ts b/src/snapshot/index.ts index 8335768f..5bb5eea6 100644 --- a/src/snapshot/index.ts +++ b/src/snapshot/index.ts @@ -6,7 +6,11 @@ import snapshot, { needMaskingText, IGNORED_NODE, } from './snapshot'; -import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; +import rebuild, { + buildNodeWithSN, + addHoverClass, + createCache, +} from './rebuild'; export * from './types'; export * from './utils'; @@ -16,6 +20,7 @@ export { rebuild, buildNodeWithSN, addHoverClass, + createCache, transformAttribute, visitSnapshot, cleanupSnapshot, diff --git a/src/snapshot/rebuild.ts b/src/snapshot/rebuild.ts index 6952eddf..331bbb8a 100644 --- a/src/snapshot/rebuild.ts +++ b/src/snapshot/rebuild.ts @@ -6,6 +6,7 @@ import { elementNode, idNodeMap, INode, + BuildCache, } from './types'; import { isElement } from './utils'; @@ -63,11 +64,13 @@ function escapeRegExp(string: string) { } const HOVER_SELECTOR = /([^\\]):hover/; -const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g'); -export function addHoverClass(cssText: string): string { +const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); +export function addHoverClass(cssText: string, cache: BuildCache): string { if (!window.HIG_CONFIGURATION?.enableOnHoverClass) { return cssText; } + const cachedStyle = cache?.stylesWithHoverClass.get(cssText); + if (cachedStyle) return cachedStyle; const ast = parse(cssText, { silent: true, }); @@ -100,10 +103,19 @@ export function addHoverClass(cssText: string): string { 'g', ); - return cssText.replace(selectorMatcher, (selector) => { + const result = cssText.replace(selectorMatcher, (selector) => { const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover'); return `${selector}, ${newSelector}`; }); + cache?.stylesWithHoverClass.set(cssText, result); + return result; +} + +export function createCache(): BuildCache { + const stylesWithHoverClass: Map = new Map(); + return { + stylesWithHoverClass, + }; } function buildNode( @@ -111,9 +123,10 @@ function buildNode( options: { doc: Document; hackCss: boolean; + cache: BuildCache; }, ): Node | null { - const { doc, hackCss } = options; + const { doc, hackCss, cache } = options; switch (n.type) { case NodeType.Document: return doc.implementation.createDocument(null, '', null); @@ -136,6 +149,10 @@ function buildNode( continue; } let value = n.attributes[name]; + if (tagName === 'option' && name === 'selected' && value === false) { + // legacy fix (TODO: if `value === false` can be generated for other attrs, should we also omit those other attrs from build?) + continue; + } value = typeof value === 'boolean' || typeof value === 'number' ? '' : value; // attribute names start with rr_ are internal attributes added by rrweb @@ -144,7 +161,7 @@ function buildNode( const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText'; if (isRemoteOrDynamicCss && hackCss) { - value = addHoverClass(value); + value = addHoverClass(value, cache); } if (isTextarea || isRemoteOrDynamicCss) { const child = doc.createTextNode(value); @@ -244,7 +261,9 @@ function buildNode( return node; case NodeType.Text: return doc.createTextNode( - n.isStyle && hackCss ? addHoverClass(n.textContent) : n.textContent, + n.isStyle && hackCss + ? addHoverClass(n.textContent, cache) + : n.textContent, ); case NodeType.CDATA: return doc.createCDATASection(n.textContent); @@ -263,16 +282,24 @@ export function buildNodeWithSN( skipChild?: boolean; hackCss: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }, ): INode | null { - const { doc, map, skipChild = false, hackCss = true, afterAppend } = options; - let node = buildNode(n, { doc, hackCss }); + const { + doc, + map, + skipChild = false, + hackCss = true, + afterAppend, + cache, + } = options; + let node = buildNode(n, { doc, hackCss, cache }); if (!node) { return null; } if (n.rootId) { console.assert( - (map[n.rootId] as unknown as Document) === doc, + ((map[n.rootId] as unknown) as Document) === doc, 'Target document should has the same root id.', ); } @@ -281,6 +308,28 @@ export function buildNodeWithSN( // close before open to make sure document was closed doc.close(); doc.open(); + if ( + n.compatMode === 'BackCompat' && + n.childNodes && + n.childNodes[0].type !== NodeType.DocumentType // there isn't one already defined + ) { + // Trigger compatMode in the iframe + // this is needed as document.createElement('iframe') otherwise inherits a CSS1Compat mode from the parent replayer environment + if ( + n.childNodes[0].type === NodeType.Element && + 'xmlns' in n.childNodes[0].attributes && + n.childNodes[0].attributes.xmlns === 'http://www.w3.org/1999/xhtml' + ) { + // might as well use an xhtml doctype if we've got an xhtml namespace + doc.write( + '', + ); + } else { + doc.write( + '', + ); + } + } node = doc; } @@ -298,6 +347,7 @@ export function buildNodeWithSN( skipChild: false, hackCss, afterAppend, + cache, }); if (!childNode) { console.warn('Failed to rebuild', childN); @@ -335,7 +385,7 @@ function handleScroll(node: INode) { if (n.type !== NodeType.Element) { return; } - const el = node as Node as HTMLElement; + const el = (node as Node) as HTMLElement; for (const name in n.attributes) { if (!(n.attributes.hasOwnProperty(name) && name.startsWith('rr_'))) { continue; @@ -357,9 +407,10 @@ function rebuild( onVisit?: (node: INode) => unknown; hackCss?: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }, ): [Node | null, idNodeMap] { - const { doc, onVisit, hackCss = true, afterAppend } = options; + const { doc, onVisit, hackCss = true, afterAppend, cache } = options; const idNodeMap: idNodeMap = {}; const node = buildNodeWithSN(n, { doc, @@ -367,6 +418,7 @@ function rebuild( skipChild: false, hackCss, afterAppend, + cache, }); visit(idNodeMap, (visitedNode) => { if (onVisit) { diff --git a/src/snapshot/snapshot.ts b/src/snapshot/snapshot.ts index 32b911f2..e09d70fd 100644 --- a/src/snapshot/snapshot.ts +++ b/src/snapshot/snapshot.ts @@ -14,7 +14,7 @@ import { import { isElement, isShadowRoot, maskInputValue } from './utils'; let _id = 1; -const tagNameRegex = RegExp('[^a-z0-9-_:]'); +const tagNameRegex = new RegExp('[^a-z0-9-_:]'); export const IGNORED_NODE = -2; @@ -49,9 +49,13 @@ function getCssRulesString(s: CSSStyleSheet): string | null { } function getCssRuleString(rule: CSSRule): string { - return isCSSImportRule(rule) - ? getCssRulesString(rule.styleSheet) || '' - : rule.cssText; + let cssStringified = rule.cssText; + if (isCSSImportRule(rule)) { + try { + cssStringified = getCssRulesString(rule.styleSheet) || cssStringified; + } catch {} + } + return cssStringified; } function isCSSImportRule(rule: CSSRule): rule is CSSImportRule { @@ -373,17 +377,26 @@ function serializeNode( } = options; // Only record root id when document object is not the base document let rootId: number | undefined; - if ((doc as unknown as INode).__sn) { - const docId = (doc as unknown as INode).__sn.id; + if (((doc as unknown) as INode).__sn) { + const docId = ((doc as unknown) as INode).__sn.id; rootId = docId === 1 ? undefined : docId; } switch (n.nodeType) { case n.DOCUMENT_NODE: - return { - type: NodeType.Document, - childNodes: [], - rootId, - }; + if ((n as HTMLDocument).compatMode !== 'CSS1Compat') { + return { + type: NodeType.Document, + childNodes: [], + compatMode: (n as HTMLDocument).compatMode, // probably "BackCompat" + rootId, + }; + } else { + return { + type: NodeType.Document, + childNodes: [], + rootId, + }; + } case n.DOCUMENT_TYPE_NODE: return { type: NodeType.DocumentType, @@ -466,9 +479,12 @@ function serializeNode( } } if (tagName === 'option') { - const selectValue = (n as HTMLOptionElement).parentElement; - if (attributes.value === (selectValue as HTMLSelectElement).value) { - attributes.selected = (n as HTMLOptionElement).selected; + if ((n as HTMLOptionElement).selected) { + attributes.selected = true; + } else { + // ignore the html attribute (which corresponds to DOM (n as HTMLOptionElement).defaultSelected) + // if it's already been changed + delete attributes.selected; } } // canvas image data @@ -501,7 +517,12 @@ function serializeNode( } // iframe if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { - delete attributes.src; + if (!(n as HTMLIFrameElement).contentDocument) { + // we can't record it directly as we can't see into it + // preserve the src attribute so a decision can be taken at replay time + attributes.rr_src = attributes.src; + } + delete attributes.src; // prevent auto loading } return { type: NodeType.Element, @@ -780,7 +801,7 @@ export function serializeNodeWithId( // Remove the image's src if enableStrictPrivacy. if (serializedNode.needBlock && serializedNode.tagName === 'img') { const clone = n.cloneNode(); - (clone as unknown as HTMLImageElement).src = ''; + ((clone as unknown) as HTMLImageElement).src = ''; map[id] = clone as INode; } /** Highlight Code End */ diff --git a/src/snapshot/types.ts b/src/snapshot/types.ts index ffeef690..8720783e 100644 --- a/src/snapshot/types.ts +++ b/src/snapshot/types.ts @@ -10,6 +10,7 @@ export enum NodeType { export type documentNode = { type: NodeType.Document; childNodes: serializedNodeWithId[]; + compatMode?: string; }; export type documentTypeNode = { @@ -111,3 +112,7 @@ export type MaskTextFn = (text: string) => string; export type MaskInputFn = (text: string) => string; export type KeepIframeSrcFn = (src: string) => boolean; + +export type BuildCache = { + stylesWithHoverClass: Map; +}; diff --git a/src/types.ts b/src/types.ts index 5b36580b..a109698f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -311,16 +311,22 @@ export type textMutation = { value: string | null; }; +export type styleAttributeValue = { + [key: string]: styleValueWithPriority | string | false; +}; + +export type styleValueWithPriority = [string, string]; + export type attributeCursor = { node: Node; attributes: { - [key: string]: string | null; + [key: string]: string | styleAttributeValue | null; }; }; export type attributeMutation = { id: number; attributes: { - [key: string]: string | null; + [key: string]: string | styleAttributeValue | null; }; }; @@ -532,7 +538,6 @@ export type playerConfig = { triggerFocus: boolean; UNSAFE_replayCanvas: boolean; pauseAnimation?: boolean; - userTriggeredOnInput: boolean; mouseTail: | boolean | { @@ -612,5 +617,8 @@ export interface HighlightConfiguration { declare global { interface Window { HIG_CONFIGURATION: HighlightConfiguration; + FontFace: typeof FontFace; } } + +export type IWindow = Window & typeof globalThis; diff --git a/src/utils.ts b/src/utils.ts index bbf1f147..b481500e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -502,6 +502,10 @@ export class TreeIndex { this.scrollMap = new Map(); this.inputMap = new Map(); } + + public idRemoved(id: number): boolean { + return this.removeIdSet.has(id); + } } type ResolveTree = { diff --git a/typings/types.d.ts b/typings/types.d.ts index 038294c6..7f2b966c 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -219,17 +219,22 @@ export declare type textMutation = { id: number; value: string | null; }; + +export type styleAttributeValue = { + [key: string]: [string, string] | string | false; +}; + export declare type attributeCursor = { - node: Node; - attributes: { - [key: string]: string | null; - }; + node: Node; + attributes: { + [key: string]: string | styleAttributeValue | null; + }; }; export declare type attributeMutation = { - id: number; - attributes: { - [key: string]: string | null; - }; + id: number; + attributes: { + [key: string]: string | styleAttributeValue | null; + }; }; export declare type removedNodeMutation = { parentId: number;