From ea3c555b49d2e033986604b85e657b1843e709e5 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Fix adapt css with split (#1600) Fix for #1575 where postcss was raising an exception * adapt the entire CSS as a whole in one pass with postcss, rather than adapting each split part separately * break up the postcss output again and assign to individual text nodes (kind of inverse of splitCssText at record side) * impose an upper bound of 30 iterations on the substring searches to preempt possible pathological behavior * add tests to demonstrate the scenario and prevent regression More technical details: * Fix algorithm; checks against `ix_end` within loop were incorrect when `ix_start` was bigger than zero. * Fix that length check against wrong array was causing 'should record style mutations with multiple child nodes and replay them correctly' test to fail. Note on last point: I haven't looked into things more deeply than that the test was complaining about missing .length after `replayer.pause(1000);` --- .changeset/fix-adapt-css.md | 6 ++++ packages/rrweb-snapshot/src/rebuild.ts | 43 ++++++++++++++++++---- packages/rrweb-snapshot/test/css.test.ts | 46 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-adapt-css.md diff --git a/.changeset/fix-adapt-css.md b/.changeset/fix-adapt-css.md new file mode 100644 index 00000000..428f5833 --- /dev/null +++ b/.changeset/fix-adapt-css.md @@ -0,0 +1,6 @@ +--- +"rrweb": patch +"rrweb-snapshot": patch +--- + +#1575 Fix that postcss could fall over when trying to process css content split arbitrarily diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index f83ac0bc..899eb438 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -102,15 +102,44 @@ export function applyCssSplits( // unexpected: remerge the last two so that we don't discard any css cssTextSplits.splice(-2, 2, cssTextSplits.slice(-2).join('')); } + let adaptedCss = ''; + if (hackCss) { + adaptedCss = adaptCssForReplay(cssTextSplits.join(''), cache); + } + let startIndex = 0; for (let i = 0; i < childTextNodes.length; i++) { + if (i === cssTextSplits.length) { + break; + } const childTextNode = childTextNodes[i]; - const cssTextSection = cssTextSplits[i]; - if (childTextNode && cssTextSection) { - // id will be assigned when these child nodes are - // iterated over in buildNodeWithSN - childTextNode.textContent = hackCss - ? adaptCssForReplay(cssTextSection, cache) - : cssTextSection; + if (!hackCss) { + childTextNode.textContent = cssTextSplits[i]; + } else if (i < cssTextSplits.length - 1) { + let endIndex = startIndex; + let endSearch = cssTextSplits[i + 1].length; + + // don't do hundreds of searches, in case a mismatch + // is caused close to start of string + endSearch = Math.min(endSearch, 30); + + let found = false; + for (; endSearch > 2; endSearch--) { + const searchBit = cssTextSplits[i + 1].substring(0, endSearch); + const searchIndex = adaptedCss.substring(startIndex).indexOf(searchBit); + found = searchIndex !== -1; + if (found) { + endIndex += searchIndex; + break; + } + } + if (!found) { + // something went wrong, put a similar sized chunk in the right place + endIndex += cssTextSplits[i].length; + } + childTextNode.textContent = adaptedCss.substring(startIndex, endIndex); + startIndex = endIndex; + } else { + childTextNode.textContent = adaptedCss.substring(startIndex); } } } diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index a26e0005..b11d4f98 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -289,6 +289,52 @@ describe('applyCssSplits css rejoiner', function () { expect((sn3.childNodes[2] as textNode).textContent).toEqual(''); }); + it('applies css splits correctly when split parts are invalid by themselves', () => { + const badFirstHalf = 'a:hov'; + const badSecondHalf = 'er { color: red; }'; + const markedCssText = [badFirstHalf, badSecondHalf].join('/* rr_split */'); + applyCssSplits(sn, markedCssText, true, mockLastUnusedArg); + expect( + (sn.childNodes[0] as textNode).textContent + + (sn.childNodes[1] as textNode).textContent, + ).toEqual('a:hover,\na.\\:hover { color: red; }'); + }); + + it('applies css splits correctly when split parts are invalid by themselves x3', () => { + let sn3 = { + type: NodeType.Element, + tagName: 'style', + childNodes: [ + { + type: NodeType.Text, + textContent: '', + }, + { + type: NodeType.Text, + textContent: '', + }, + { + type: NodeType.Text, + textContent: '', + }, + ], + } as serializedElementNodeWithId; + const badStartThird = '.a:hover { background-color'; + const badMidThird = ': red; } input:hover {'; + const badEndThird = 'border: 1px solid purple; }'; + const markedCssText = [badStartThird, badMidThird, badEndThird].join( + '/* rr_split */', + ); + applyCssSplits(sn3, markedCssText, true, mockLastUnusedArg); + expect((sn3.childNodes[0] as textNode).textContent).toEqual( + badStartThird.replace('.a:hover', '.a:hover,\n.a.\\:hover'), + ); + expect((sn3.childNodes[1] as textNode).textContent).toEqual( + badMidThird.replace('input:hover', 'input:hover,\ninput.\\:hover'), + ); + expect((sn3.childNodes[2] as textNode).textContent).toEqual(badEndThird); + }); + it('maintains entire css text when there are too few child nodes', () => { let sn1 = { type: NodeType.Element,