Our first two articles established the damage: CSS specificity conflicts caused regressions in 61% of the 53 stores we scanned, and z-index arms races produced values above 90,000 in five of them. But we glossed over the hardest part for developers who are actually in the fight: how do you systematically find and fix these conflicts when you didn't write the theme and you don't know which of your three apps is causing the problem?
This article is the workflow. It's the checklist we use when debugging a store that has app-theme conflicts — no theory, no patterns-to-know — just the debugging steps in order.
Step 1: Isolate the Problem to One App
Before you fix anything, confirm which app is responsible. Running multiple apps with conflicts produces symptoms that look like a single problem — broken layout, missing widgets, sluggish performance — but they have different causes.
The process: Disable apps one at a time, starting with the most recently installed, and reload the storefront after each disable.
// First pass: find the conflict window
// 1. Disable the most recently installed app
// 2. Reload the storefront, check DevTools Elements panel
// 3. If still broken → re-enable, disable the next most recent
// 4. If fixed → you have your candidate
Most conflicts surface within two disable cycles. If the store has seven apps and none of them individually cause the problem, you have a multi-app cascade — go to Step 3.
What to check in DevTools while isolating:
// Open Console, filter by error level
// Look for:
// - "Cannot read property of undefined" (DOM query failed)
// - "Maximum update depth exceeded" (MutationObserver loop)
// - "TypeError: jQuery is not defined" (loading order failure)
// Open Network tab, filter by JS
// Look for:
// - Failed script loads (red row in Network)
// - Scripts loaded in wrong order (check Initiator column)
The goal of isolation is a single app. If you can't narrow it to one, document every symptom carefully — multiple apps sometimes create a conflict that's only visible when they're all running.
Step 2: Identify the Conflict Type
Once you've isolated the app, classify the conflict. Each type has a different debugging approach.
Type A: CSS Specificity
Symptom: A widget renders with wrong spacing, wrong font size, or is visually compressed or hidden.
How to confirm: Inspect the widget in DevTools Elements panel. Look at the CSS cascade in the Styles pane — if the widget's rules are crossed out and replaced by a theme selector, specificity is the issue.
// In DevTools Console — check computed specificity of conflicting selectors
const el = document.querySelector('.your-widget-class');
const styles = getComputedStyle(el);
const computedPadding = styles.paddingTop; // if "0px" but you expected "16px", specificity won
Confirm with specificity: Inspect the affected element. Click the CSS selector in the Styles pane. Chrome DevTools shows specificity in a tooltip (e.g., [0, 2, 1]). If the theme's selector is [1, 3, 0] and yours is [0, 1, 0], you lose. Every time.
The fix from our 53-store data: scope your CSS to a data attribute on your container.
/* If your widget is injecting into .product-info */
[data-app-reviews] .review-widget {
padding: 16px;
font-size: 14px;
}
/* Not .review-widget alone — that loses to the theme */
Type B: Event Handler Collision
Symptom: A form submit doesn't fire, fires twice, or fires the wrong action. A button click triggers nothing or triggers multiple actions.
How to confirm: Open DevTools Sources panel, set a breakpoint on the event listener. Or use the Event Listener tab: find the element, expand the click/submit listeners, check which function runs first.
// In Console — see all click handlers on a button
const btn = document.querySelector('[name="add"]');
getEventListeners(btn); // Chrome only — lists all listeners with their source file
// Check if multiple listeners call preventDefault()
document.querySelectorAll('form[action="/cart/add"]').forEach(form => {
let preventDefaultCount = 0;
form.addEventListener('submit', (e) => {
console.log('submit intercepted');
// Count how many times this fires per submit
});
});
The root cause: Multiple apps binding to the same form or button and all calling preventDefault(). The first listener to fire cancels the submission. Other listeners never execute.
**
The fix: **Don't hijack native submit. Emit a custom event and let other listeners coordinate:
// Instead of:
form.addEventListener('submit', (e) => {
e.preventDefault();
doYourThing();
form.submit();
});
// Do this:
form.addEventListener('submit', (e) => {
// Let the event bubble but coordinate via custom event
const event = new CustomEvent('app:cart-before-add', {
detail: { form, product: window.ShopifyAnalytics.meta.product_id },
cancelable: true
});
const wasNotCancelled = document.dispatchEvent(event);
if (wasNotCancelled) {
form.submit();
}
});
If the form needs to show a modal first (upsell, wishlist confirmation), use cancelable: true and check the dispatch result. If another app cancelled the event, your app respects it. If it wasn't cancelled, you proceed.
Type C: DOM Mutation Cascade
Symptom: A widget appears on initial load, then disappears after a few seconds. Or a widget only appears on some pages, not others.
How to confirm: Open DevTools, go to the Elements panel, right-click the
tag → "Break on" → "Subtree modifications." Reload the page. Every time the DOM changes, the debugger pauses. Walk forward through each pause and note which app's script triggered the change.// In Console — observe mutation events (no debugger needed)
const observer = new MutationObserver((mutations) => {
mutations.forEach(m => {
if (m.type === 'childList') {
console.log('DOM change:', m.addedNodes.length, 'nodes added',
'by:', m.target.closest('[data-app]')?.dataset.app || 'unknown source');
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
The root cause: Apps use MutationObserver to detect theme changes and re-inject widgets. When three apps all observe document.body for childList changes, each injection triggers the other observers, and a tight render loop emerges.
In our dataset, the worst case: a store with three apps all observing document.body pushed page load from 2.1s to 6.8s.
The fix: Remove the MutationObserver entirely and use Shopify's app block extension instead.
{% comment %} Your app block — section/app-reviews.liquid {% endcomment %}
{% schema %}
{
"name": "Product Reviews",
"target": "section",
"settings": [
{ "type": "product" }
]
}
{% endschema %}
<div class="review-widget" data-product-id="{{ product.id }}">
<!-- Content — no MutationObserver, no DOM queries -->
</div>
Merchants place the block in the theme editor. The theme controls rendering order. No observer needed.
Step 3: Fix the Conflict
Once isolated and classified, apply the pattern from the relevant section above. But also check these two things that appear in nearly every conflict:
1. Script loading order
<!-- If your app depends on jQuery — don't assume it's there -->
<!-- WRONG: inline script that assumes jQuery exists -->
<script>
$(document).ready(function() { /* fails silently */ });
</script>
<!-- RIGHT: defer and run after DOM is ready -->
<script defer src="{{ 'app-reviews.js' | asset_url }}"></script>
// app-reviews.js
document.addEventListener('DOMContentLoaded', () => {
// DOM is ready, no jQuery dependency
document.querySelector('.review-widget').addEventListener('click', handleClick);
});
2. DOM query brittleness
// WRONG: assumes specific class exists after theme update
const container = document.querySelector('.product__info-container');
container.insertAdjacentHTML('afterend', widgetHTML);
// RIGHT: use Shopify's established injection points via app block
// Or fallback gracefully if the target doesn't exist
const container = document.querySelector('.product__info-container, .product-info, [data-product-section]');
if (container) {
container.insertAdjacentHTML('afterend', widgetHTML);
} else {
console.warn('[AppReviews] Injection target not found — skipping render');
}
The Checklist (Summary)
Before shipping any app update to production, run through this:
- Isolate — confirm which app causes the conflict, not "which conflict exists"
- Classify — CSS specificity, event collision, or DOM mutation
- Check specificity — DevTools shows specificity in selector tooltip; if your selector is lower, it loses
- Check event listeners — DevTools Elements panel → Event Listeners tab shows every listener on an element
- Check script order — Network tab, filter by JS; confirm deferred scripts run after synchronous ones
- Scope CSS — data-attribute container, not bare class selectors
-
Use custom events — not
preventDefault()hijacking of native form submissions - Test with 3+ apps installed — your app never runs alone in production
Post-Debug: Prevent the Next One
After fixing the conflict, the question is always "what do we do so this doesn't happen again?" Three things that work:
- Ship scoped CSS from day one. Use data-app-[slug] on every injected element. Never ship a bare class selector.
- Test with the merchant's actual app stack, not your demo store. The conflict almost never reproduces in your test store because it doesn't have their other apps installed.
- Use app blocks for widget injection. It shifts the responsibility from "hope the DOM structure is stable" to "the merchant placed it where it should be."
The aggregate conflict dataset — including the 53-store breakdown by conflict type, app category, and theme — is at preflight.technology/insights. You can filter by app category and see how common your specific conflict pattern is across the dataset. No signup required to browse.
Top comments (0)