Repository navigation
🐛 [RUM] Session Replay: shadow root adoptedStyleSheets replayed with wrong or missing styles (stylesheet ID drift + late adoption not recorded) #5117
Description
Disclaimer:
The following bug was detected by Opus 5.5 when trying to debug why session replays are recorded with broken styles in our application, and the following report was also written with some help from AI.
Describe the bug
Product: rum (Session Replay).
Web components that style their shadow roots only through constructable stylesheets (shadowRoot.adoptedStyleSheets) are replayed with no styles, or with the styles of an unrelated component. Light DOM content styled via <link>/<style> replays correctly. This is the default pattern for Stencil (shadow: true, lazy-load output) and common in Lit, where one constructed CSSStyleSheet is shared by all instances of a component class.
Two independent recorder problems cause this:
- Stylesheet ID drift.
serializeStyleSheetemits anAddStyleSheetchange for every occurrence of a sheet, but allocates a recorder-side ID only for sheets not seen before. When one constructed sheet is adopted by several shadow roots, recorder and player IDs go out of sync and later shadow roots get attached to the wrong (or a non-existent) stylesheet. - Late
adoptedStyleSheetsare never recorded. Adopted sheets are only read when the document/shadow root is serialized. Components that adopt their styles after the shadow root exists (lazy hydration, first render after a dynamicimport()) stay unstyled in the replay until the next full snapshot.
Environment:
@datadog/browser-rum: reproduced on 7.10.0 and 7.14.0. The relevant code is unchanged onmain. Latest release at time of writing is 7.15.0; its changelog has no related fix, but I have not tested on 7.15.0 directly.- Browser: Chromium-based (constructable stylesheets support)
- Config:
sessionReplaySampleRate: 100; reproduced withdefaultPrivacyLevel'mask'and'allow'
To Reproduce
Minimal page (no framework). x-a, x-b and x-c each share one constructed sheet across their instances:
<script>
// load and init @datadog/browser-rum with sessionReplaySampleRate: 100
</script>
<x-a></x-a><x-a></x-a><x-a></x-a>
<x-b></x-b>
<x-c></x-c>
<script>
const sheetA = new CSSStyleSheet();
sheetA.replaceSync(':host { display: block; color: red; }');
const sheetB = new CSSStyleSheet();
sheetB.replaceSync(':host { display: block; color: blue; }');
const sheetC = new CSSStyleSheet();
sheetC.replaceSync(':host { display: block; color: green; }');
function define(name, sheet, { async = false } = {}) {
customElements.define(
name,
class extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<p>' + name + '</p>';
if (async) {
// variant 2: adopt after the shadow root was created
setTimeout(() => (root.adoptedStyleSheets = [sheet]), 500);
} else {
root.adoptedStyleSheets = [sheet];
}
}
}
);
}
define('x-a', sheetA);
define('x-b', sheetB);
define('x-c', sheetC, { async: true });
</script>Steps:
- Serve the page above with RUM and Session Replay enabled.
- Let it load for a few seconds, then end the session / open the replay.
- Problem 1:
x-bis expected to be replayed red (x-a's style) instead of blue.sheetAis sent once perx-ashadow root, so it takes player IDs 0, 1 and 2, andsheetBgets player ID 3. The recorder, however, attachesx-bto ID 1, a copy ofsheetA(see the walk-through under Root cause). In real components, the wrongly attached sheet's selectors usually don't match the shadow DOM, so the component looks unstyled rather than mis-styled. - Problem 2:
x-cis replayed unstyled, because its sheet is adopted after its shadow root was serialized. CallingdatadogRum.startView()after the page settles forces a full snapshot and fixesx-c(once problem 1 is fixed), confirming that the recorded DOM is correct and only the stylesheet attachment is missing.
Expected behavior
Each shadow root is replayed with the same adoptedStyleSheets as on the recorded page, including sheets shared between shadow roots and sheets adopted after the shadow root was first serialized.
Root cause
Problem 1: packages/browser-rum/src/domain/record/serialization/serializeNode.ts
function serializeStyleSheet(sheet: CSSStyleSheet, transaction: SerializationTransaction): StyleSheetId {
const rules = Array.from(sheet.cssRules || sheet.rules, (rule) => createString(StringRole.Css, rule.cssText));
// ...
transaction.addStyleSheet(rules, mediaList, sheet.disabled); // always emitted
return transaction.scope.styleSheetIds.getOrInsert(sheet); // ID allocated only for unseen sheets
}AddStyleSheet carries no explicit ID, so the player must number sheets in the order they are received. The recorder numbers distinct sheet objects instead, so every duplicate puts the player's numbering one step ahead. Walk-through for the repro above:
| Serialized shadow root | Player ID of the added sheet (one per AddStyleSheet) |
Recorder ID (getOrInsert, one per distinct sheet) |
Attached to | Replayed with |
|---|---|---|---|---|
x-a first |
0 → sheetA |
sheetA → 0 (new) |
0 | sheetA (correct) |
x-a second |
1 → copy of sheetA |
sheetA → 0 (cached) |
0 | sheetA (correct) |
x-a third |
2 → copy of sheetA |
sheetA → 0 (cached) |
0 | sheetA (correct) |
x-b |
3 → sheetB |
sheetB → 1 (next free) |
1 | copy of sheetA (wrong) |
The x-a roots are unaffected because they all reference ID 0, the original. x-b references ID 1, which the player assigned to the second copy of sheetA, while sheetB's rules sit unreferenced at ID 3. The gap grows by one for every duplicate, so with N instances of the first component the next new sheet is offset by N − 1. Since the recorder never allocates more IDs than the player, the referenced sheet always exists but belongs to another component. Its selectors typically don't match, so the component appears unstyled.
The player's numbering is inferred from the recorder code (the player source is not public). It is consistent with the observation that skipping already-emitted sheets fixes the replay.
The <link>/<style> path is unaffected in practice because those sheets are not shared.
Problem 2: adoptedStyleSheets are only read in serializeDocumentNode / serializeDocumentFragmentNode (same serializeNode.ts). After that:
trackers/trackStyleSheet.tsonly instrumentsinsertRule/deleteRulefor sheets with anownerNode; constructed sheets have none;- nothing observes assignment to
adoptedStyleSheets, in-placepushon itsObservableArray, orreplace/replaceSync; serializeMutations.tsdoes not re-check the stylesheets of already serialized shadow roots.
All paths are under packages/browser-rum/src/domain/record/.
Suggested fix
-
Emit each sheet once per recording scope; return the existing ID when known:
const existingSheetId = transaction.scope.styleSheetIds.get(sheet); if (existingSheetId !== undefined) { return existingSheetId; } // ...build rules, then: transaction.addStyleSheet(rules, mediaList, sheet.disabled); return transaction.scope.styleSheetIds.getOrInsert(sheet);
Why: player and recorder counters must advance together. It also avoids re-sending identical CSS for every component instance. Validated locally: shared component styles replay correctly.
-
Detect changes to the adopted sheets of already serialized documents/shadow roots and emit
AttachedStyleSheetswith the current list (new sheets go throughserializeStyleSheet, so fix 1 applies). A low-risk approach is to remember the adopted sheets per serialized shadow root and compare them at the end of each mutation batch. A complete fix should also coverdocument.adoptedStyleSheets, reset tracked state on full snapshot, avoid retaining disconnected shadow roots, and ideally recordinsertRule/deleteRule/replace/replaceSyncon constructed sheets. Patching theadoptedStyleSheetssetter alone is insufficient since in-placepushbypasses it.Why: component runtimes commonly adopt styles after the shadow root is in the DOM. Status: a comparison-based patch exists locally, but replay verification of fix 2 is still pending.
Additional context
- [REPLAY] Add support for
adoptedStyleSheets#1916 addedadoptedStyleSheetssupport (serialization at node-serialization time only). - 🎨 [PANA-5260] Consolidate recorder object id tracking code #4049 consolidated recorder object ID tracking and is likely where
styleSheetIds.getOrInsertcame from (inferred, diff not confirmed). - Session Replay doesn't capture elements rendered in a Shadow DOM #1518 was the original shadow DOM replay report (unrelated to stylesheets).
- I found no existing issue or PR for either problem. I'm happy to open a PR for fix 1 if that is welcome.
Activity
electrostaticfleece commentedon Oct 7, 2026
We’re seeing the same issue. Our web components render correctly on the live page, but Session Replay attaches incorrect CSS to some shadow roots. We inspected the reconstructed DOM in an affected replay. The shadow roots and adoptedStyleSheets are present, yet some sheets contain styles intended for other components.
We see this with
@datadog/browser-rum ^7.13.0. The same application replays correctly with^6.33.0. Our components share CSSStyleSheet objects across shadow roots, so the ID drift described here fits the behavior: the recorder adds a stylesheet entry on each encounter, while reusing the ID of an existing sheet. This appears to create one recorded entry per encounter, but one ID per unique stylesheet object.We haven’t inspected the raw recording to confirm its exact ID sequence.