QA-ing A/B tests inside shadow DOM: why your selectors lie to you
Shadow roots silently break querySelector-based test code and most QA tooling. Here's what's actually happening and how to write tests that work inside web components.
You write your activation code. You test it in DevTools. The selector returns the element. You launch. The test does nothing. No console error. No broken UI. Just nothing, silently, on a segment of users whose product pages happen to use a web component.
This is shadow DOM, and it breaks more A/B tests than most teams realise.
What shadow DOM actually does to your selectors
Shadow DOM is a browser-native encapsulation mechanism. When a custom element (like <product-card> or <checkout-widget>) attaches a shadow root, its internal DOM lives in a separate tree. document.querySelector and document.querySelectorAll stop at shadow boundaries — they cannot see inside.
// This returns null if .price is inside a shadow root
var priceEl = document.querySelector('.price');
// You have to traverse into the shadow root first
var host = document.querySelector('product-card');
var priceEl = host.shadowRoot?.querySelector('.price');
The problem for test developers is that this failure is entirely silent. The query returns null, your activation guard (if (el) { ... }) swallows it gracefully, and the test appears to run without error. QA passes on pages where the component happens to render in the light DOM. It silently misses every page where it doesn’t.
Why your QA tooling lies to you
Chrome DevTools’ element selector tester, Optimizely Web’s visual editor, and most test framework preview tools all operate on the top-level document. They find the element in a context where it happens to be accessible — usually because the component also renders a fallback in the light DOM for crawlers, or because you’re testing a variant of the page that doesn’t use the shadow-DOM version of the component.
The correct way to check in DevTools:
// This is the query that represents what your test code actually runs
document.querySelector('.price') // returns null
// This is what you need instead
document.querySelector('product-card').shadowRoot.querySelector('.price')
If those two return different things, your test has a shadow boundary problem and your QA process didn’t catch it.
Writing selectors that cross shadow roots
For situations where you need to find an element but don’t know which shadow root it’s in, you need a recursive traversal:
function deepQuerySelector(selector, root) {
root = root || document;
var found = root.querySelector(selector);
if (found) return found;
var all = root.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var sr = all[i].shadowRoot;
if (sr) {
found = deepQuerySelector(selector, sr);
if (found) return found;
}
}
return null;
}
// Usage — same interface as querySelector
var priceEl = deepQuerySelector('.price');
This is a blunt instrument. On a complex page it’s slow — you’re walking the entire DOM recursively. Use it for discovery and debugging, not as your production activation pattern.
The closed shadow root problem
There’s a worse variant: closed shadow roots. When a component is instantiated with { mode: 'closed' }, .shadowRoot returns null even to JavaScript that has a reference to the host element. You cannot traverse into it at all from outside the component.
var host = document.querySelector('payment-widget');
host.shadowRoot // null — this is a closed shadow root
If you encounter this, you cannot reliably DOM-inject into that component. Your options are:
- Work with the component’s public API if one exists (events, attributes, CSS custom properties)
- Test the host element itself rather than its internals — targeting layout or visibility at the shadow host level rather than the content within
- Escalate to server-side flag-based delivery for that experiment, where you control the rendered output before the component initialises
Styling inside shadow roots
This one catches people who get past the selector problem. Even if you correctly find and modify an element inside a shadow root, external stylesheets don’t penetrate the boundary. Your style.innerHTML = '.price { font-weight: 700 }' will have no effect on elements inside a shadow root.
The component-native way is through CSS custom properties, which do cross shadow boundaries:
var host = document.querySelector('product-card');
// Set a CSS custom property on the host
host.style.setProperty('--price-font-weight', '700');
// The component's internal stylesheet must use it:
// :host { --price-font-weight: 400; }
// .price { font-weight: var(--price-font-weight); }
This only works if the component was written to expose those hooks. If it wasn’t, you’re back to closed-mode territory.
What to actually do
When a client’s pages use web components, I add shadow DOM traversal checks to the pre-launch QA checklist:
- Manually run
deepQuerySelectorin DevTools to confirm the element is actually being found during QA - Check
.shadowRootmode — open the component source or check in DevTools (closed roots shownull) - Test on a real device — some shadow DOM implementations differ between desktop and mobile browsers
- MutationObserver — if you’re using an observer for dynamic content, note that it does not cross shadow boundaries by default; you need to observe the shadow root directly
// MutationObserver must be attached to the shadow root, not document.body
var host = document.querySelector('product-card');
if (host.shadowRoot) {
new MutationObserver(function(mutations) {
var priceEl = host.shadowRoot.querySelector('.price');
if (priceEl) applyVariant(priceEl);
}).observe(host.shadowRoot, { childList: true, subtree: true });
}
Shadow DOM is increasingly common — checkout widgets, product recommendation engines, and third-party embeds often use it. The testing tools haven’t fully caught up. Until they do, you have to know to check.