diff --git a/.changeset/stylesheet-resync-mid-sheet-inserts.md b/.changeset/stylesheet-resync-mid-sheet-inserts.md new file mode 100644 index 0000000..fb3e4dc --- /dev/null +++ b/.changeset/stylesheet-resync-mid-sheet-inserts.md @@ -0,0 +1,5 @@ +--- +"rrweb": patch +--- + +Fix the stylesheet resync re-sending the wrong rules after the `insertRule` patch was displaced. It assumed missed rules were appended and re-sent the sheet's tail, but libraries that order rules by component (styled-components) insert mid-sheet, so replays got duplicated rules and lost the new ones. The resync now replaces the replayer's copy of any sheet whose rule count changed. diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index eaedcfa..749cb35 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -36,7 +36,6 @@ import type { listenerHandler, scrollCallback, styleSheetRuleCallback, - styleSheetAddRule, styleSheetDeleteRule, viewportResizeCallback, inputValue, @@ -1094,9 +1093,8 @@ function initStyleSheetObserver( * * While the patch is healthy the replayer is in step with us by construction, * so there is nothing to do and we only remember where each sheet has got to. - * Once it is not, we top the replayer up: the tail for a sheet that has grown - * (which is all any CSS-in-JS library does), or a wholesale replacement when we - * cannot tell which rules survived. + * Once it is not, we replace the replayer's copy of any sheet whose rule count + * no longer matches what we reported. */ const resyncCssomStyleSheets = (trusted: boolean) => { for (let i = 0; i < doc.styleSheets.length; i++) { @@ -1141,17 +1139,10 @@ function initStyleSheetObserver( if (live === state.reported) continue; - if (live > state.reported) { - const adds: styleSheetAddRule[] = []; - for (let index = state.reported; index < live; index++) { - adds.push({ rule: rules[index].cssText, index }); - } - styleSheetRuleCb({ id, adds }); - state.reported = live; - } else { - // Shrunk, so it was rewritten rather than appended to. - replaceReplayerCopy(id, state, rules); - } + // The count alone cannot say where the missed rules went. Libraries that + // order rules by component (styled-components) insert mid-sheet, so + // re-sending the tail would duplicate shifted rules and drop the new ones. + replaceReplayerCopy(id, state, rules); } }; diff --git a/packages/rrweb/test/record/stylesheet-resync.test.ts b/packages/rrweb/test/record/stylesheet-resync.test.ts index f3a0b56..0f06fbd 100644 --- a/packages/rrweb/test/record/stylesheet-resync.test.ts +++ b/packages/rrweb/test/record/stylesheet-resync.test.ts @@ -125,6 +125,61 @@ describe('stylesheet resync', function (this: ISuite) { expect(addedRules(ctx.events)).toContain('body { color: rgb(1, 2, 3); }'); }); + it('keeps the replayer in step when a missed rule was inserted mid-sheet', async () => { + const live = await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + const pristine = CSSStyleSheet.prototype.insertRule; + + record({ + emit: (window as unknown as IWindow).emit, + styleSheetResyncInterval: 50, + }); + + const styleElement = document.createElement('style'); + document.head.appendChild(styleElement); + const sheet = styleElement.sheet as CSSStyleSheet; + + return new Promise((resolve) => { + setTimeout(() => { + sheet.insertRule('.first { color: rgb(1, 1, 1); }', 0); + sheet.insertRule('.second { color: rgb(2, 2, 2); }', 1); + sheet.insertRule('.third { color: rgb(3, 3, 3); }', 2); + }, 200); + setTimeout(() => { + CSSStyleSheet.prototype.insertRule = pristine; + // Libraries that group rules by component insert at the group's + // offset rather than appending. + sheet.insertRule('.missed { color: rgb(4, 4, 4); }', 1); + }, 300); + setTimeout(() => { + resolve(Array.from(sheet.cssRules, (rule) => rule.cssText)); + }, 600); + }); + }); + + // Apply the recorded rule events the way the replayer does: each event's + // adds before its removes, ignoring indices out of range. + const replayed: string[] = []; + for (const data of styleSheetRuleEvents(ctx.events)) { + for (const { rule, index } of data.adds ?? []) { + const at = typeof index === 'number' ? index : replayed.length; + if (at <= replayed.length) replayed.splice(at, 0, rule); + } + for (const { index } of data.removes ?? []) { + const at = index as number; + if (at < replayed.length) replayed.splice(at, 1); + } + } + + expect(live).toEqual([ + '.first { color: rgb(1, 1, 1); }', + '.missed { color: rgb(4, 4, 4); }', + '.second { color: rgb(2, 2, 2); }', + '.third { color: rgb(3, 3, 3); }', + ]); + expect(replayed).toEqual(live); + }); + it('records rules again once the patch has been reinstated', async () => { await ctx.page.evaluate(() => { const { record } = (window as unknown as IWindow).rrweb;