Debugging the Liquid-React Bridge
Master debugging techniques for the Liquid-React data handoff. Learn to trace data flow, diagnose hydration issues, and build debugging tools that make troubleshooting faster.
The Liquid-React bridge is where two worlds collide. Data flows from Shopify through Liquid templates into React components, and when something goes wrong, it’s not always obvious which side caused the problem. In this lesson, we’ll build debugging skills and tools specifically for this hybrid architecture.
Common Bridge Issues
Most Liquid-React bugs fall into these categories:
┌─────────────────────────────────────────────────────────────────┐│ COMMON BRIDGE ISSUES │├─────────────────────────────────────────────────────────────────┤│ ││ 1. DATA SERIALIZATION ││ └─ JSON encoding errors ││ └─ Missing or null values ││ └─ Incorrect data types ││ ││ 2. HYDRATION MISMATCHES ││ └─ Server HTML doesn't match React render ││ └─ Dynamic content in static context ││ └─ Missing root elements ││ ││ 3. TIMING ISSUES ││ └─ React mounting before data available ││ └─ Multiple mount attempts ││ └─ Race conditions with Liquid sections ││ ││ 4. SCOPE CONFUSION ││ └─ Wrong Liquid object in scope ││ └─ Section vs template vs global data ││ └─ Nested section rendering ││ │└─────────────────────────────────────────────────────────────────┘Building a Debug Mode
Create a debug mode that exposes the data flowing into React components:
const DEBUG_KEY = 'SHOPIFY_REACT_DEBUG';
/** * Check if debug mode is enabled. * Enable by setting localStorage.SHOPIFY_REACT_DEBUG = 'true' in console. */export function isDebugMode(): boolean { if (typeof window === 'undefined') return false;
try { return localStorage.getItem(DEBUG_KEY) === 'true'; } catch { return false; }}
/** * Log debug information when debug mode is enabled. */export function debugLog(component: string, message: string, data?: unknown): void { if (!isDebugMode()) return;
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); const prefix = `%c[${timestamp}] ${component}`;
console.groupCollapsed(prefix, 'color: #6366f1; font-weight: bold;', message);
if (data !== undefined) { console.log('Data:', data); }
console.trace('Call stack'); console.groupEnd();}
/** * Validate data shape and log warnings for unexpected values. */export function validateBridgeData<T>( component: string, data: unknown, schema: Record<keyof T, 'string' | 'number' | 'boolean' | 'object' | 'array'>): data is T { if (!isDebugMode()) return true;
if (data === null || data === undefined) { console.warn(`[${component}] Bridge data is ${data}`); return false; }
if (typeof data !== 'object') { console.warn(`[${component}] Bridge data is not an object:`, data); return false; }
const obj = data as Record<string, unknown>; const issues: string[] = [];
for (const [key, expectedType] of Object.entries(schema)) { const value = obj[key]; const actualType = Array.isArray(value) ? 'array' : typeof value;
if (value === undefined) { issues.push(`Missing required field: ${key}`); } else if (actualType !== expectedType && value !== null) { issues.push(`${key}: expected ${expectedType}, got ${actualType}`); } }
if (issues.length > 0) { console.warn(`[${component}] Bridge data validation issues:`, issues); console.table(obj); return false; }
debugLog(component, 'Bridge data validated', data); return true;}Debug Overlay Component
A visual overlay that shows the data passed to each React component:
import { useState, useEffect } from 'react';import { isDebugMode } from '@/lib/debug';import styles from './DebugOverlay.module.css';
interface DebugOverlayProps { componentName: string; data: unknown; children: React.ReactNode;}
export function DebugOverlay({ componentName, data, children }: DebugOverlayProps) { const [isExpanded, setIsExpanded] = useState(false); const [debugEnabled, setDebugEnabled] = useState(false);
useEffect(() => { setDebugEnabled(isDebugMode()); }, []);
if (!debugEnabled) { return <>{children}</>; }
return ( <div className={styles.wrapper}> <div className={styles.badge} onClick={() => setIsExpanded(!isExpanded)} title="Click to toggle debug info" > {componentName} </div>
{isExpanded && ( <div className={styles.overlay}> <div className={styles.header}> <span>{componentName}</span> <button onClick={() => setIsExpanded(false)}>×</button> </div> <pre className={styles.data}> {JSON.stringify(data, null, 2)} </pre> <div className={styles.actions}> <button onClick={() => navigator.clipboard.writeText(JSON.stringify(data, null, 2))}> Copy JSON </button> <button onClick={() => console.log(`[${componentName}]`, data)}> Log to Console </button> </div> </div> )}
{children} </div> );}.wrapper { position: relative;}
.badge { position: absolute; top: 0; right: 0; z-index: 9999; padding: 2px 6px; background: #6366f1; color: white; font-size: 10px; font-family: monospace; border-radius: 0 0 0 4px; cursor: pointer; opacity: 0.8;}
.badge:hover { opacity: 1;}
.overlay { position: absolute; top: 0; right: 0; z-index: 10000; width: 400px; max-width: 90vw; max-height: 400px; background: #1e1e2e; border: 1px solid #6366f1; border-radius: 4px; font-family: monospace; font-size: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);}
.header { display: flex; justify-content: space-between; padding: 8px 12px; background: #6366f1; color: white;}
.header button { background: none; border: none; color: white; cursor: pointer; font-size: 16px;}
.data { padding: 12px; margin: 0; max-height: 300px; overflow: auto; color: #a6e3a1; white-space: pre-wrap; word-break: break-word;}
.actions { display: flex; gap: 8px; padding: 8px 12px; border-top: 1px solid #45475a;}
.actions button { padding: 4px 8px; background: #45475a; border: none; border-radius: 4px; color: white; font-size: 11px; cursor: pointer;}
.actions button:hover { background: #585b70;}Use the overlay in your components:
import { DebugOverlay } from '@/components/debug/DebugOverlay';
export function ProductPage({ product }: ProductPageProps) { return ( <DebugOverlay componentName="ProductPage" data={product}> {/* Component content */} </DebugOverlay> );}Tracing Data Flow
When debugging, trace data from Liquid to React:
Step 1: Verify Liquid Output
Add a debug section to your Liquid template:
{% comment %} sections/product-main.liquid {% endcomment %}
{%- if request.design_mode or settings.debug_mode -%} <div class="liquid-debug" style="display: none;"> <script type="application/json" id="liquid-debug-{{ section.id }}"> { "sectionId": {{ section.id | json }}, "productId": {{ product.id | json }}, "productHandle": {{ product.handle | json }}, "variantsCount": {{ product.variants | size }}, "hasImages": {{ product.images | size | at_least: 1 }}, "selectedVariant": {{ product.selected_or_first_available_variant.id | json }}, "timestamp": {{ 'now' | date: '%s' | json }} } </script> </div>{%- endif -%}
{%- comment -%} Actual component data {%- endcomment -%}<div id="product-root" data-section-id="{{ section.id }}"></div>
<script type="application/json" id="product-data-{{ section.id }}"> { "product": {{ product | json }}, "selectedVariantId": {{ product.selected_or_first_available_variant.id | json }}, "settings": { "showQuantity": {{ section.settings.show_quantity | json }}, "enableZoom": {{ section.settings.enable_zoom | json }} } }</script>Step 2: Validate JSON Parsing
Create a safer JSON parser with detailed error reporting:
interface ParseResult<T> { success: boolean; data?: T; error?: string; rawContent?: string;}
/** * Safely parse JSON from a script element with detailed error reporting. */export function parseBridgeData<T>( scriptId: string, componentName: string): ParseResult<T> { const script = document.getElementById(scriptId);
if (!script) { const error = `Script element #${scriptId} not found`; console.error(`[${componentName}] ${error}`); return { success: false, error }; }
const rawContent = script.textContent?.trim() || '';
if (!rawContent) { const error = `Script element #${scriptId} is empty`; console.error(`[${componentName}] ${error}`); return { success: false, error, rawContent }; }
try { const data = JSON.parse(rawContent) as T; debugLog(componentName, `Parsed data from #${scriptId}`, data); return { success: true, data }; } catch (e) { const error = e instanceof Error ? e.message : 'Unknown parse error';
// Try to find the exact location of the syntax error const lines = rawContent.split('\n'); const match = error.match(/position (\d+)/); let contextInfo = '';
if (match) { const position = parseInt(match[1], 10); let charCount = 0; for (let i = 0; i < lines.length; i++) { charCount += lines[i].length + 1; if (charCount >= position) { contextInfo = `\nNear line ${i + 1}: "${lines[i].substring(0, 50)}..."`; break; } } }
console.error(`[${componentName}] JSON parse error: ${error}${contextInfo}`); console.error('Raw content:', rawContent.substring(0, 500));
return { success: false, error, rawContent }; }}Step 3: Component Mounting Diagnostics
Track mounting issues:
import { createRoot, Root } from 'react-dom/client';import { debugLog } from './debug';
interface MountState { elementId: string; componentName: string; root: Root | null; mountedAt: number; unmountedAt: number | null;}
const mountRegistry = new Map<string, MountState>();
export function mountComponent( elementId: string, componentName: string, component: React.ReactElement): boolean { const element = document.getElementById(elementId);
if (!element) { console.error( `[${componentName}] Mount failed: Element #${elementId} not found`, '\nAvailable elements with id:', Array.from(document.querySelectorAll('[id]')) .map((el) => el.id) .filter((id) => id.includes('root') || id.includes('react')) ); return false; }
// Check for existing mount const existingMount = mountRegistry.get(elementId); if (existingMount && !existingMount.unmountedAt) { console.warn( `[${componentName}] Element #${elementId} already has a mounted component: ${existingMount.componentName}`, `\nMounted ${Date.now() - existingMount.mountedAt}ms ago` ); }
try { debugLog(componentName, `Mounting to #${elementId}`);
const root = createRoot(element); root.render(component);
mountRegistry.set(elementId, { elementId, componentName, root, mountedAt: Date.now(), unmountedAt: null, });
debugLog(componentName, 'Mount successful'); return true; } catch (error) { console.error(`[${componentName}] Mount error:`, error); return false; }}
export function unmountComponent(elementId: string): boolean { const mountState = mountRegistry.get(elementId);
if (!mountState || mountState.unmountedAt) { console.warn(`No active mount found for #${elementId}`); return false; }
try { mountState.root?.unmount(); mountState.unmountedAt = Date.now();
debugLog( mountState.componentName, `Unmounted from #${elementId} after ${mountState.unmountedAt - mountState.mountedAt}ms` );
return true; } catch (error) { console.error(`Unmount error for #${elementId}:`, error); return false; }}
// Expose for console debuggingif (typeof window !== 'undefined') { (window as any).__REACT_MOUNTS__ = mountRegistry;}Debugging Hydration Mismatches
Hydration issues occur when React’s rendered output doesn’t match the existing HTML. Here’s how to diagnose them:
import { useEffect, useRef } from 'react';import { isDebugMode } from '@/lib/debug';
interface HydrationCheckerProps { componentName: string; children: React.ReactNode;}
export function HydrationChecker({ componentName, children }: HydrationCheckerProps) { const ref = useRef<HTMLDivElement>(null); const initialHtml = useRef<string | null>(null);
useEffect(() => { if (!isDebugMode() || !ref.current) return;
// Capture initial HTML before React hydration modifies it if (initialHtml.current === null) { initialHtml.current = ref.current.innerHTML; }
// Compare after hydration const currentHtml = ref.current.innerHTML;
if (initialHtml.current !== currentHtml) { console.group(`%c[Hydration Mismatch] ${componentName}`, 'color: #f59e0b'); console.log('Server HTML:', initialHtml.current?.substring(0, 500)); console.log('Client HTML:', currentHtml.substring(0, 500));
// Find specific differences const serverLines = initialHtml.current?.split('\n') || []; const clientLines = currentHtml.split('\n');
const differences: string[] = []; const maxLines = Math.max(serverLines.length, clientLines.length);
for (let i = 0; i < maxLines; i++) { if (serverLines[i] !== clientLines[i]) { differences.push( `Line ${i + 1}:\n Server: ${serverLines[i]?.substring(0, 100)}\n Client: ${clientLines[i]?.substring(0, 100)}` ); if (differences.length >= 5) break; } }
if (differences.length > 0) { console.log('First differences:', differences.join('\n\n')); }
console.groupEnd(); } else { console.log(`%c[Hydration OK] ${componentName}`, 'color: #10b981'); } }, [componentName]);
return <div ref={ref}>{children}</div>;}Common Hydration Fixes
- Dynamic values in static context:
// BAD: Date changes between server and clientfunction Footer() { return <span>© {new Date().getFullYear()} My Store</span>;}
// GOOD: Get year from Liquid (stable)function Footer({ currentYear }: { currentYear: number }) { return <span>© {currentYear} My Store</span>;}- Browser-only APIs:
// BAD: window doesn't exist on "server" (Liquid)function Header() { const isMobile = window.innerWidth < 768; return isMobile ? <MobileNav /> : <DesktopNav />;}
// GOOD: Use effect for client-only logicfunction Header() { const [isMobile, setIsMobile] = useState(false);
useEffect(() => { setIsMobile(window.innerWidth < 768); }, []);
// Render a neutral state initially return isMobile ? <MobileNav /> : <DesktopNav />;}- Random or changing values:
// BAD: IDs change between rendersfunction ProductList({ products }) { return products.map((p) => ( <div key={Math.random()}>{p.title}</div> ));}
// GOOD: Use stable IDs from datafunction ProductList({ products }) { return products.map((p) => ( <div key={p.id}>{p.title}</div> ));}Console Debugging Commands
Add helpful commands to the browser console:
interface DebugCommands { enable: () => void; disable: () => void; showMounts: () => void; showBridgeData: () => void; validateAll: () => void; inspectComponent: (name: string) => void;}
export function registerDebugCommands(): void { if (typeof window === 'undefined') return;
const commands: DebugCommands = { enable() { localStorage.setItem('SHOPIFY_REACT_DEBUG', 'true'); console.log('Debug mode enabled. Refresh to see debug overlays.'); },
disable() { localStorage.removeItem('SHOPIFY_REACT_DEBUG'); console.log('Debug mode disabled.'); },
showMounts() { const mounts = (window as any).__REACT_MOUNTS__; if (!mounts || mounts.size === 0) { console.log('No React components mounted.'); return; }
console.table( Array.from(mounts.values()).map((m: any) => ({ Element: m.elementId, Component: m.componentName, 'Mounted (ms ago)': Date.now() - m.mountedAt, Status: m.unmountedAt ? 'Unmounted' : 'Active', })) ); },
showBridgeData() { const scripts = document.querySelectorAll('script[type="application/json"]'); const data: Record<string, unknown> = {};
scripts.forEach((script) => { try { data[script.id || 'unnamed'] = JSON.parse(script.textContent || '{}'); } catch (e) { data[script.id || 'unnamed'] = `Parse error: ${e}`; } });
console.log('Bridge data scripts found:', scripts.length); console.log(data); },
validateAll() { console.group('Validating all bridge data...');
const scripts = document.querySelectorAll('script[type="application/json"]'); let valid = 0; let invalid = 0;
scripts.forEach((script) => { try { JSON.parse(script.textContent || ''); console.log(`✓ ${script.id}`); valid++; } catch (e) { console.error(`✗ ${script.id}:`, e); invalid++; } });
console.groupEnd(); console.log(`Results: ${valid} valid, ${invalid} invalid`); },
inspectComponent(name: string) { const mounts = (window as any).__REACT_MOUNTS__; if (!mounts) { console.log('Mount registry not available.'); return; }
for (const mount of mounts.values()) { if (mount.componentName.toLowerCase().includes(name.toLowerCase())) { console.log(`Found: ${mount.componentName}`); console.log('Element:', document.getElementById(mount.elementId)); console.log('Mount state:', mount); return; } }
console.log(`No component matching "${name}" found.`); }, };
(window as any).shopifyReact = commands;
console.log( '%cShopify React Debug', 'color: #6366f1; font-weight: bold; font-size: 14px;', '\n\nCommands available via window.shopifyReact:', '\n .enable() - Enable debug mode', '\n .disable() - Disable debug mode', '\n .showMounts() - List mounted components', '\n .showBridgeData() - Show all bridge data', '\n .validateAll() - Validate all JSON scripts', '\n .inspectComponent(name) - Find component by name' );}Initialize in your main entry:
import { registerDebugCommands } from '@/lib/debug-commands';
// Register debug commands in developmentif (process.env.NODE_ENV === 'development') { registerDebugCommands();}Key Takeaways
-
Build debug tools early: Invest in debugging infrastructure before you need it.
-
Trace data at each step: Verify data in Liquid, in the JSON script, and in React.
-
Use visual overlays: Debug overlays make it easy to see what data each component received.
-
Log with context: Include component names and timestamps in debug output.
-
Validate JSON strictly: Parse errors are common—catch them early with good error messages.
-
Watch for hydration: Mismatches between server and client HTML cause subtle bugs.
-
Console commands help: Quick access to debugging functions saves time.
-
Keep debug code conditional: Only include debug overlays and verbose logging in development.
This concludes the Testing and Debugging module. You now have the skills to write comprehensive tests for your React Shopify theme and debug the tricky issues that arise at the Liquid-React boundary. Well-tested, debuggable code is maintainable code—and maintainable code is code that survives the long term.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...