From 2891c362a3933b81175d8ea250432ff84535176b Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 17 Dec 2024 14:54:17 -0500 Subject: [PATCH 1/3] fix: inserted styles lost when moving elements (#232) fix code for nodejs tests change fix direction to avoid issues with duplicate styles format issues swap waitForTimeout for waitForRAF in test that flaked Add unit tests for new functions Fix broken test causes by file formatting removing spaced --------- Co-authored-by: jaj1014 Co-authored-by: jaj1014 --- packages/rrdom/src/diff.ts | 71 +++++- packages/rrdom/test/diff.test.ts | 70 +++++- .../test/events/moving-style-sheet-on-diff.ts | 203 ++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 23 +- 4 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 packages/rrweb/test/events/moving-style-sheet-on-diff.ts diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 05dc0f2218..69c1d08573 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -379,7 +379,7 @@ function diffChildren( nodeMatching(oldStartNode, newEndNode, replayer.mirror, rrnodeMirror) ) { try { - oldTree.insertBefore(oldStartNode, oldEndNode.nextSibling); + handleInsertBefore(oldTree, oldStartNode, oldEndNode.nextSibling); } catch (e) { console.warn(e); } @@ -390,7 +390,7 @@ function diffChildren( nodeMatching(oldEndNode, newStartNode, replayer.mirror, rrnodeMirror) ) { try { - oldTree.insertBefore(oldEndNode, oldStartNode); + handleInsertBefore(oldTree, oldEndNode, oldStartNode); } catch (e) { console.warn(e); } @@ -415,7 +415,7 @@ function diffChildren( nodeMatching(nodeToMove, newStartNode, replayer.mirror, rrnodeMirror) ) { try { - oldTree.insertBefore(nodeToMove, oldStartNode); + handleInsertBefore(oldTree, nodeToMove, oldStartNode); } catch (e) { console.warn(e); } @@ -449,7 +449,7 @@ function diffChildren( } try { - oldTree.insertBefore(newNode, oldStartNode || null); + handleInsertBefore(oldTree, newNode, oldStartNode || null); } catch (e) { console.warn(e); } @@ -471,7 +471,7 @@ function diffChildren( rrnodeMirror, ); try { - oldTree.insertBefore(newNode, referenceNode); + handleInsertBefore(oldTree, newNode, referenceNode); } catch (e) { console.warn(e); } @@ -579,3 +579,64 @@ export function nodeMatching( if (node1Id === -1 || node1Id !== node2Id) return false; return sameNodeType(node1, node2); } + +/** + * Copies CSSRules and their position from HTML style element which don't exist in it's innerText + */ +function getInsertedStylesFromElement( + styleElement: HTMLStyleElement, +): Array<{ index: number; cssRuleText: string }> | undefined { + const elementCssRules = styleElement.sheet?.cssRules; + if (!elementCssRules || !elementCssRules.length) return; + // style sheet w/ innerText styles to diff with actual and get only inserted styles + const tempStyleSheet = new CSSStyleSheet(); + tempStyleSheet.replaceSync(styleElement.innerText); + + const innerTextStylesMap: { [key: string]: CSSRule } = {}; + + for (let i = 0; i < tempStyleSheet.cssRules.length; i++) { + innerTextStylesMap[tempStyleSheet.cssRules[i].cssText] = + tempStyleSheet.cssRules[i]; + } + + const insertedStylesStyleSheet = []; + + for (let i = 0; i < elementCssRules?.length; i++) { + const cssRuleText = elementCssRules[i].cssText; + + if (!innerTextStylesMap[cssRuleText]) { + insertedStylesStyleSheet.push({ + index: i, + cssRuleText, + }); + } + } + + return insertedStylesStyleSheet; +} + +/** + * Conditionally copy insertedStyles for STYLE nodes and apply after calling insertBefore' + * For non-STYLE nodes, just insertBefore + */ +export function handleInsertBefore( + oldTree: Node, + nodeToMove: Node, + insertBeforeNode: Node | null, +): void { + let insertedStyles; + + if (nodeToMove.nodeName === 'STYLE') { + insertedStyles = getInsertedStylesFromElement( + nodeToMove as HTMLStyleElement, + ); + } + + oldTree.insertBefore(nodeToMove, insertBeforeNode); + + if (insertedStyles && insertedStyles.length) { + insertedStyles.forEach(({ cssRuleText, index }) => { + (nodeToMove as HTMLStyleElement).sheet?.insertRule(cssRuleText, index); + }); + } +} diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 3f18a6ee7e..7f56ec2ecb 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -23,6 +23,7 @@ import { ReplayerHandler, nodeMatching, sameNodeType, + handleInsertBefore, } from '../src/diff'; import type { IRRElement, IRRNode } from '../src/document'; import { Replayer } from 'rrweb'; @@ -1441,7 +1442,7 @@ describe('diff algorithm for rrdom', () => { const rrHtmlEl = rrDocument.createElement('html'); rrDocument.mirror.add(rrHtmlEl, rrdom.getDefaultSN(rrHtmlEl, ${htmlElId})); rrIframeEl.contentDocument.appendChild(rrHtmlEl); - + const replayer = { mirror: rrdom.createMirror(), applyCanvas: () => {}, @@ -1450,7 +1451,7 @@ describe('diff algorithm for rrdom', () => { applyStyleSheetMutation: () => {}, }; rrdom.diff(iframeEl, rrIframeEl, replayer); - + iframeEl.contentDocument.documentElement.className = '${className.toLowerCase()}'; iframeEl.contentDocument.childNodes.length === 2 && @@ -1972,4 +1973,69 @@ describe('diff algorithm for rrdom', () => { expect(nodeMatching(node1, node2, NodeMirror, rrdomMirror)).toBeFalsy(); }); }); + + describe('test handleInsertBefore function', () => { + it('should insert nodeToMove before insertBeforeNode in oldTree for non-style elements', () => { + const oldTree = document.createElement('div'); + const nodeToMove = document.createElement('div'); + const insertBeforeNode = document.createElement('div'); + oldTree.appendChild(insertBeforeNode); + + expect(oldTree.children.length).toEqual(1); + + handleInsertBefore(oldTree, nodeToMove, insertBeforeNode); + + expect(oldTree.children.length).toEqual(2); + expect(oldTree.children[0]).toEqual(nodeToMove); + }); + + it('should not drop inserted styles when moving a style element with inserted styles', async () => { + function MockCSSStyleSheet() { + this.replaceSync = jest.fn(); + this.cssRules = [{ cssText: baseStyle }]; + } + + jest + .spyOn(window, 'CSSStyleSheet') + .mockImplementationOnce(MockCSSStyleSheet as any); + + const baseStyle = 'body {margin: 0;}'; + const insertedStyle = 'div {display: flex;}'; + + document.write(''); + + const insertBeforeNode = document.createElement('style'); + document.documentElement.appendChild(insertBeforeNode); + + const nodeToMove = document.createElement('style'); + nodeToMove.appendChild(document.createTextNode(baseStyle)); + document.documentElement.appendChild(nodeToMove); + nodeToMove.sheet?.insertRule(insertedStyle); + + // validate dom prior to moving element + expect(document.documentElement.children.length).toEqual(4); + expect(document.documentElement.children[2]).toEqual(insertBeforeNode); + expect(document.documentElement.children[3]).toEqual(nodeToMove); + expect(nodeToMove.sheet?.cssRules.length).toEqual(2); + expect(nodeToMove.sheet?.cssRules[0].cssText).toEqual(insertedStyle); + expect(nodeToMove.sheet?.cssRules[1].cssText).toEqual(baseStyle); + + // move the node + handleInsertBefore( + document.documentElement, + nodeToMove, + insertBeforeNode, + ); + + // nodeToMove was inserted before + expect(document.documentElement.children.length).toEqual(4); + expect(document.documentElement.children[2]).toEqual(nodeToMove); + expect(document.documentElement.children[3]).toEqual(insertBeforeNode); + // styles persisted on the moved element + // w/ document.documentElement.insertBefore(nodeToMove, insertBeforeNode) insertedStyle wouldn't be copied + expect(nodeToMove.sheet?.cssRules.length).toEqual(2); + expect(nodeToMove.sheet?.cssRules[0].cssText).toEqual(insertedStyle); + expect(nodeToMove.sheet?.cssRules[1].cssText).toEqual(baseStyle); + }); + }); }); diff --git a/packages/rrweb/test/events/moving-style-sheet-on-diff.ts b/packages/rrweb/test/events/moving-style-sheet-on-diff.ts new file mode 100644 index 0000000000..af80da3e17 --- /dev/null +++ b/packages/rrweb/test/events/moving-style-sheet-on-diff.ts @@ -0,0 +1,203 @@ +import { EventType, IncrementalSource } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; + +const now = Date.now(); +const events: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 10, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1000, + height: 800, + }, + timestamp: now + 10, + }, + // full snapshot: + { + data: { + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: 2, + tagName: 'html', + attributes: { + lang: 'en', + }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'style', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: + '#wrapper { width: 200px; margin: 50px auto; background-color: gainsboro; padding: 20px; }.target-element { padding: 12px; margin-top: 12px; }', + isStyle: true, + id: 6, + }, + ], + id: 5, + }, + { + type: 2, + tagName: 'style', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: + '.new-element-class { font-size: 32px; color: tomato; }', + isStyle: true, + id: 8, + }, + ], + id: 7, + }, + ], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: { + id: 'wrapper', + }, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: { + class: 'target-element', + }, + childNodes: [ + { + type: 2, + tagName: 'p', + attributes: { + class: 'target-element-child', + }, + childNodes: [ + { + type: 3, + textContent: 'Element to style', + id: 113, + }, + ], + id: 12, + }, + ], + id: 11, + }, + ], + id: 10, + }, + ], + id: 9, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + type: EventType.FullSnapshot, + timestamp: now + 20, + }, + // 1st mutation that applies StyleSheetRule + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + id: 5, + adds: [ + { + rule: '.target-element{background-color:teal;}', + }, + ], + }, + timestamp: now + 30, + }, + // 2nd mutation inserts new style element to trigger other style element to get moved in diff + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [{ parentId: 4, id: 7 }], + adds: [ + { + parentId: 4, + nextId: 5, + node: { + type: 2, + tagName: 'style', + attributes: {}, + childNodes: [], + id: 98, + }, + }, + { + parentId: 98, + nextId: null, + node: { + type: 3, + textContent: + '.new-element-class { font-size: 32px; color: tomato; }', + isStyle: true, + id: 99, + }, + }, + ], + }, + timestamp: now + 2000, + }, + // dummy event to have somewhere to skip + { + data: { + adds: [], + texts: [], + source: IncrementalSource.Mutation, + removes: [], + attributes: [], + }, + type: EventType.IncrementalSnapshot, + timestamp: now + 3000, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index da322e8749..13a43560a9 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -10,6 +10,7 @@ import { waitForRAF, } from './utils'; import styleSheetRuleEvents from './events/style-sheet-rule-events'; +import movingStyleSheetOnDiff from './events/moving-style-sheet-on-diff'; import orderingEvents from './events/ordering'; import scrollEvents from './events/scroll'; import scrollWithParentStylesEvents from './events/scroll-with-parent-styles'; @@ -174,6 +175,22 @@ describe('replayer', function () { await assertDomSnapshot(page); }); + it('should persist StyleSheetRule changes when skipping triggers parent style element to move in diff', async () => { + await page.evaluate(`events = ${JSON.stringify(movingStyleSheetOnDiff)}`); + + const result = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(3000); + const rules = [...replayer.iframe.contentDocument.styleSheets].map( + (sheet) => [...sheet.rules], + ).flat(); + rules.some((x) => x.cssText === '.target-element { background-color: teal; }'); + `); + + expect(result).toEqual(true); + }); + it('should apply fast forwarded StyleSheetRules that where added', async () => { await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`); const result = await page.evaluate(` @@ -225,7 +242,7 @@ describe('replayer', function () { await waitForRAF(page); /** check the second selection event */ - [startOffset, endOffset] = (await page.evaluate(` + [startOffset, endOffset] = (await page.evaluate(` replayer.pause(410); var range = replayer.iframe.contentDocument.getSelection().getRangeAt(0); [range.startOffset, range.endOffset]; @@ -702,7 +719,7 @@ describe('replayer', function () { events = ${JSON.stringify(canvasInIframe)}; const { Replayer } = rrweb; var replayer = new Replayer(events,{showDebug:true}); - replayer.pause(550); + replayer.pause(550); `); const replayerIframe = await page.$('iframe'); const contentDocument = await replayerIframe!.contentFrame()!; @@ -788,7 +805,7 @@ describe('replayer', function () { await page.evaluate(` const { Replayer } = rrweb; let replayer = new Replayer(events); - replayer.play(); + replayer.play(); `); const replayerWrapperClassName = 'replayer-wrapper'; From 3cb6bc0a0ceb896f39fb0515eca63ae0478b54d0 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 28 Jan 2026 17:09:19 +0000 Subject: [PATCH 2/3] Reapply "Full overhawl of video & audio playback to make it more complete (rrweb-io#1432)" (#264) This reverts commit https://github.com/getsentry/rrweb/commit/c6bc3c7f7de4b7fdc747bba5672a6e78b6f01d77. --- .changeset/cool-grapes-hug.md | 5 + .changeset/dirty-rules-dress.md | 5 + .changeset/mighty-ads-worry.md | 5 + .changeset/silver-pots-sit.md | 5 + .changeset/smart-geckos-cover.md | 5 + packages/rrdom/src/diff.ts | 2 + packages/rrdom/src/document.ts | 1 + packages/rrdom/test/diff.test.ts | 2 + packages/rrdom/test/document.test.ts | 1 + packages/rrweb-snapshot/src/rebuild.ts | 11 + packages/rrweb-snapshot/src/snapshot.ts | 10 +- packages/rrweb-snapshot/src/types.ts | 21 + packages/rrweb/src/record/observer.ts | 3 +- packages/rrweb/src/replay/index.ts | 75 ++- packages/rrweb/src/replay/media/index.ts | 294 ++++++++ packages/rrweb/test/e2e/webgl.test.ts | 25 +- .../events/video-playback-on-full-snapshot.ts | 550 +++++++++++++++ packages/rrweb/test/events/video-playback.ts | 628 ++++++++++++++++++ .../rrweb/test/html/assets/bunny-video.webm | Bin 0 -> 1602346 bytes packages/rrweb/test/html/video.html | 19 + .../cross-origin-iframes.test.ts.snap | 9 +- packages/rrweb/test/replay/ video.test.ts | 240 +++++++ ...en-the-player-wasnt-started-yet-1-snap.png | Bin 0 -> 18686 bytes ...ll-play-from-the-correct-moment-1-snap.png | Bin 0 -> 141056 bytes ...will-seek-to-the-correct-moment-1-snap.png | Bin 0 -> 141252 bytes ...ithout-media-interaction-events-1-snap.png | Bin 0 -> 154767 bytes packages/rrweb/test/utils.ts | 25 + packages/types/src/index.ts | 4 + 28 files changed, 1886 insertions(+), 59 deletions(-) create mode 100644 .changeset/cool-grapes-hug.md create mode 100644 .changeset/dirty-rules-dress.md create mode 100644 .changeset/mighty-ads-worry.md create mode 100644 .changeset/silver-pots-sit.md create mode 100644 .changeset/smart-geckos-cover.md create mode 100644 packages/rrweb/src/replay/media/index.ts create mode 100644 packages/rrweb/test/events/video-playback-on-full-snapshot.ts create mode 100644 packages/rrweb/test/events/video-playback.ts create mode 100644 packages/rrweb/test/html/assets/bunny-video.webm create mode 100644 packages/rrweb/test/html/video.html create mode 100644 packages/rrweb/test/replay/ video.test.ts create mode 100644 packages/rrweb/test/replay/__image_snapshots__/video-test-ts-video-will-be-paused-when-the-player-wasnt-started-yet-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/video-test-ts-video-will-play-from-the-correct-moment-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/video-test-ts-video-will-seek-to-the-correct-moment-1-snap.png create mode 100644 packages/rrweb/test/replay/__image_snapshots__/video-test-ts-video-will-seek-to-the-correct-moment-without-media-interaction-events-1-snap.png diff --git a/.changeset/cool-grapes-hug.md b/.changeset/cool-grapes-hug.md new file mode 100644 index 0000000000..cde43b29ff --- /dev/null +++ b/.changeset/cool-grapes-hug.md @@ -0,0 +1,5 @@ +--- +'rrdom': patch +--- + +Support `loop` in `RRMediaElement` diff --git a/.changeset/dirty-rules-dress.md b/.changeset/dirty-rules-dress.md new file mode 100644 index 0000000000..19b2070ffc --- /dev/null +++ b/.changeset/dirty-rules-dress.md @@ -0,0 +1,5 @@ +--- +'rrweb-snapshot': minor +--- + +Video and Audio elements now also capture `playbackRate`, `muted`, `loop`, `volume`. diff --git a/.changeset/mighty-ads-worry.md b/.changeset/mighty-ads-worry.md new file mode 100644 index 0000000000..1906ac8945 --- /dev/null +++ b/.changeset/mighty-ads-worry.md @@ -0,0 +1,5 @@ +--- +'rrweb': minor +--- + +Full overhawl of `video` and `audio` element playback. More robust and fixes lots of bugs related to pausing/playing/skipping/muting/playbackRate etc. diff --git a/.changeset/silver-pots-sit.md b/.changeset/silver-pots-sit.md new file mode 100644 index 0000000000..b53a943825 --- /dev/null +++ b/.changeset/silver-pots-sit.md @@ -0,0 +1,5 @@ +--- +'@rrweb/types': patch +--- + +Add `loop` to `mediaInteractionParam` diff --git a/.changeset/smart-geckos-cover.md b/.changeset/smart-geckos-cover.md new file mode 100644 index 0000000000..a5a0e0f44c --- /dev/null +++ b/.changeset/smart-geckos-cover.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Record `loop` on `