Skip to main content

Marqo Storefront Search Widget -- Developer Guide

Internal developer guide for working on the storefront search widget codebase.


9. Architecture Overview

Widget lifecycle

init --> search --> render --> events
| | | |
| | | +-- DOM events dispatched (marqo:*)
| | +-- Vue components mount/update
| +-- Marqo API query + facet processing
+-- Config validation, CSS injection, container detection
  1. Init (marqo-search-app.ts): MarqoSearchApp validates config (config-validator.ts), injects CSS variables (css-variables.ts), detects containers (selector-utils.ts), sets up the FilterManager, and dispatches marqo:ready.
  2. Search (search.ts, search-api.ts): Queries the Marqo API, dispatches marqo:search.start. On success, processes results and facets. Dispatches marqo:search.results or marqo:search.empty.
  3. Render (ui-rendering.ts): Orchestrates Vue component mounting. Creates or updates reactive refs. Vue handles DOM updates reactively.
  4. Events (dom-manager.ts, event-manager.ts): DOM events dispatched after each render cycle. EventManager handles document-level event delegation for sort, pagination, and items-per-page changes.

Vue component tree

MarqoSearchApp (vanilla TS, manages lifecycle)
|
+-- GridApp (Vue) -- manages product grid + grid injections
| +-- ProductCard (Vue) -- per-product card (image, title, price, CTA, reviews)
| +-- Grid Injections (v-html) -- promo banners spliced between cards
|
+-- FilterSidebar (Vue) -- manages all filter sections
| +-- FilterAccordion (Vue) -- collapsible wrapper for each filter
| | +-- CategoricalFilter -- checkbox-based string facets
| | +-- RangeFilter -- min/max with preset buttons
| | +-- HierarchicalFilter -- expandable category tree
| +-- StockAvailabilityFilter -- in-stock/out-of-stock toggle
|
+-- ActiveFilters (Vue) -- active filter pills with remove buttons
+-- Pagination (Vue) -- page numbers with prev/next
+-- SortDropdown (Vue) -- sort option dropdown
+-- ItemsPerPage (Vue) -- per-page count dropdown
+-- ResultsCount (Vue) -- "Showing X-Y of Z results"

Data flow

window.MarqoUIConfig (injected by Shopify app proxy)
|
v
MarqoSearchApp reads config
|
+-- generateCssVariables(uiComponents) --> <style> element (Layer 1)
|
+-- ui-rendering.ts creates reactive refs
| |
| +-- productsRef = ref<MarqoSearchHit[]>([])
| +-- facetsRef = ref<ProcessedMarqoFacets>({})
| +-- paginationStateRef = ref({ currentPage, totalPages })
| +-- sortValueRef = ref("relevance")
| +-- ...
|
+-- Vue apps mount with provide/inject
|
+-- GridApp injects productsRef via PRODUCTS_INJECTION_KEY
+-- FilterSidebar receives facets + callbacks as props
+-- Other components receive state as props

The key architectural pattern: ui-rendering.ts owns the reactive refs and creates/mounts Vue apps. When new search results arrive, it updates the ref values (productsRef.value = newProducts), and Vue reactively re-renders the components. Vue apps are created once and updated by ref mutation, not re-created on each search.

Three-layer CSS architecture

Layer 1: Auto-generated CSS variables (css-variables.ts)
Scoped to .marqo-search-layout
Generated from UIComponent .value objects
Includes dynamic @media rules for columns + filter breakpoint

Layer 2: Template CSS (UIComponent.css field from DDB)
Uses var() references to Layer 1 variables
Editable in the Advanced Template Editor

Layer 3: Custom CSS overrides (custom_css UIComponent from DDB)
Free-form CSS written by the merchant
Applied last, highest specificity

All three layers are injected as <style> elements in order, so Layer 3 overrides Layer 2, which overrides Layer 1.


10. Local Development

Source location

components/shopify/storefront_search/
src/
index.ts -- entry point
marqo-search-app.ts -- main app class
search.ts -- search orchestration
search-api.ts -- Marqo API client
filter-manager.ts -- filter state management
css-variables.ts -- Layer 1 CSS variable generation
ui-rendering.ts -- Vue app mounting/updating
event-manager.ts -- document-level event delegation
config-validator.ts -- MarqoUIConfig validation
selector-utils.ts -- container detection
constants.ts -- CSS classes, DOM IDs, defaults
types.d.ts -- shared TypeScript types
utils.ts -- utility functions
vue/
GridApp.ts -- product grid + injection interleaving
ProductCard.ts -- product card rendering
Pagination.ts -- page navigation
SortDropdown.ts -- sort dropdown
ItemsPerPage.ts -- per-page dropdown
ResultsCount.ts -- results count display
template-detection.ts -- Tier 4 theme override detection
filters/
FilterSidebar.ts -- filter container
FilterAccordion.ts -- collapsible wrapper
CategoricalFilter.ts -- checkbox filters
RangeFilter.ts -- min/max range filters
HierarchicalFilter.ts -- tree-based filters
StockAvailabilityFilter.ts -- in-stock toggle
ActiveFilters.ts -- active filter pills
ui-rendering/
dom-manager.ts -- DOM helpers + dispatchMarqoEvent
event-handlers.ts -- mobile filter toggle
tests/
ui-knobs/ -- CSS variable round-trip tests
vue-components.test.ts -- Vue component mount tests
...
dist/
bundle.js -- production IIFE bundle
vite.config.ts -- build configuration
package.json
tsconfig.json

How to run locally

cd components/shopify/storefront_search

# Install dependencies
npm install

# Build the production bundle
npx vite build

# Run tests
npx vitest run

# Run tests in watch mode
npx vitest

# Run tests with coverage
npx vitest run --coverage

Deploy to dev store

After building, the dist/bundle.js file is served via Shopify app proxy. For local development, you can:

  1. Build with npx vite build
  2. The bundle is picked up by the Shopify app proxy serving path
  3. Test on your development store

package.json scripts

ScriptCommandPurpose
buildvite buildBuild production IIFE bundle
testvitestRun tests (watch mode)
coveragevitest run --coverageRun tests with coverage report

11. Adding a New UI Knob

A "UI knob" is a dashboard setting that controls a visual aspect of the widget via a CSS variable. Here is the step-by-step process for adding one.

Example: Adding a card padding setting

Step 1: Add the type definition

File: components/ecom_admin/app/lib/types.ts

Add the new field to the relevant settings interface:

// In ProductDisplaySettings
export interface ProductDisplaySettings {
// ... existing fields ...
cardPadding: number; // NEW
}

Step 2: Add the default value

File: components/ecom_admin/app/hooks/use-settings.ts

Add a default in the DEFAULT_SETTINGS object:

productDisplay: {
// ... existing defaults ...
cardPadding: 12, // NEW
}

Step 3: Add to the settings converter

File: components/ecom_admin/app/lib/settings-converter.ts

Update both backendToFrontend (read from UIComponent) and frontendToBackend (write to UIComponent):

// In backendToFrontend -- extract from backend value object
cardPadding: displayValue.cardPadding ?? 12,

// In frontendToBackend -- write to backend value object
cardPadding: settings.productDisplay.cardPadding,

Step 4: Add the admin UI control

Find the relevant section component in components/ecom_admin/app/components/settings/ and add the input control (slider, color picker, dropdown, etc.).

Step 5: Add the CSS variable

File: components/shopify/storefront_search/src/css-variables.ts

Add the variable generation in generateCssVariables():

// In the Product Card section
vars.push(`--marqo-card-padding: ${dc.cardPadding ?? 12}px`);

Step 6: Add the CSS variable test

File: components/shopify/storefront_search/tests/ui-knobs/product-display.test.ts

test("product_display_config.cardPadding -> --marqo-card-padding (px suffix)", () => {
const ui = makeUiComponents({
product_display_config: { cardPadding: 20 },
});
const css = generateCssVariables(ui);
expectCssVar(css, "--marqo-card-padding", "20px");
});

Step 7: Add the converter test

File: components/ecom_admin/app/lib/__tests__/settings-converter.test.ts

Add a round-trip test that verifies the value survives frontendToBackend then backendToFrontend.

Step 8: Use the variable in template CSS

The variable is now available for use in Layer 2 template CSS and Layer 3 custom CSS:

.marqo-product-card-content {
padding: var(--marqo-card-padding);
}

Test commands

# CSS variable tests
cd components/shopify/storefront_search && npx vitest run tests/ui-knobs/

# Converter tests
cd components/ecom_admin && npm test -- --run app/lib/__tests__/settings-converter.test.ts

12. Adding a New Vue Component

Where to create

All Vue components live in components/shopify/storefront_search/src/vue/. Filter-related components are in the vue/filters/ subdirectory.

h() render function pattern

The widget uses runtime-only Vue (no template compiler). All components use h() render functions instead of <template> blocks. This keeps the bundle smaller and avoids CSP issues with runtime compilation.

import { defineComponent, h, computed, type PropType, type VNode } from "vue";

export default defineComponent({
name: "MyComponent",
props: {
myProp: { type: String, required: true },
},
setup(props) {
const computedValue = computed(() => props.myProp.toUpperCase());

// Return a render function
return () => {
return h("div", { class: "my-component" }, [
h("span", null, computedValue.value),
]);
};
},
});

Key h() patterns used in the codebase

Conditional rendering (v-if equivalent):

// Include element only when condition is true
const children: VNode[] = [];
if (someCondition.value) {
children.push(h("span", { class: "badge" }, "Sale!"));
}
return h("div", null, children);

List rendering (v-for equivalent):

const items = props.list.map((item, idx) =>
h("li", { key: item.id }, item.label)
);
return h("ul", null, items);

Event binding (@click equivalent):

h("button", {
onClick: (e: Event) => { /* handler */ },
onMouseenter: () => { /* handler */ },
}, "Click me")

Dynamic attributes (:attr equivalent):

h("img", {
src: imageUrl.value,
alt: title.value,
class: isActive.value ? "active" : "",
})

Raw HTML (v-html equivalent):

h("div", { innerHTML: sanitizedHtml })

Wiring into ui-rendering.ts

New components are mounted via Vue's createApp in ui-rendering.ts. The pattern:

  1. Declare module-level refs for reactive state:
let vueMyApp: App | null = null;
let vueMyStateRef: Ref<MyState> | null = null;
  1. In the render function, create or update:
if (!vueMyApp) {
// First render -- create and mount
vueMyStateRef = ref(initialState);
vueMyApp = createApp({
setup() {
return () => h(MyComponent, {
myProp: vueMyStateRef!.value.something,
});
},
});
vueMyApp.mount(containerElement);
} else {
// Subsequent renders -- just update the ref
vueMyStateRef!.value = newState;
}
  1. Add cleanup in destroyVueApps():
const apps = [vueGridApp, vueFilterApp, /* ... */ vueMyApp];

Receiving data via props vs inject

  • Props: Used for data that the parent component controls. All non-grid components receive their state as props. Example: Pagination receives currentPage and totalPages as props.

  • Provide/inject: Used when the data source is outside of Vue. The GridApp injects productsRef via PRODUCTS_INJECTION_KEY because the products array is managed by ui-rendering.ts (vanilla TypeScript), not a parent Vue component.

// In ui-rendering.ts (provider)
const productsRef = ref<MarqoSearchHit[]>([]);
const app = createApp(GridApp);
app.provide(PRODUCTS_INJECTION_KEY, productsRef);
app.mount(container);

// In GridApp.ts (consumer)
const products = inject(PRODUCTS_INJECTION_KEY);

Preserving CSS classes

All .marqo-* CSS classes must remain identical to the previous output. They are referenced by:

  • Default CSS templates in DDB (templates.py)
  • CSS variable var() references (Layer 1 and Layer 2)
  • Merchant Custom CSS (Tier 2)
  • Merchant Advanced Template Editor overrides (Tier 3)
  • Third-party integrations targeting .marqo-* selectors

When adding a new component, use descriptive .marqo-* class names that follow the existing pattern (e.g., .marqo-product-card, .marqo-filter-section, .marqo-pagination).


13. Adding a New DOM Event

Step 1: Add to the MarqoEventName union type

File: components/shopify/storefront_search/src/ui-rendering/dom-manager.ts

export type MarqoEventName =
| "marqo:ready"
| "marqo:destroy"
| "marqo:search.start"
| "marqo:search.results"
| "marqo:search.empty"
| "marqo:search.error"
| "marqo:card.render"
| "marqo:cta.click"
| "marqo:filter.change"
| "marqo:filter.clear"
| "marqo:sort.change"
| "marqo:page.change"
| "marqo:grid.injected"
| "marqo:my.new.event"; // ADD HERE

If the event should be cancelable (merchants can call preventDefault()), add it to the CANCELABLE_EVENTS set:

const CANCELABLE_EVENTS: Set<MarqoEventName> = new Set([
"marqo:cta.click",
"marqo:my.new.event", // ADD HERE if cancelable
]);

Step 2: Dispatch the event

Call dispatchMarqoEvent() from the appropriate location:

import { dispatchMarqoEvent } from "../ui-rendering/dom-manager";

// Non-cancelable event
dispatchMarqoEvent("marqo:my.new.event", {
someData: value,
});

// Cancelable event -- check return value
const allowed = dispatchMarqoEvent("marqo:my.new.event", {
someData: value,
});
if (!allowed) {
// Merchant called preventDefault()
return;
}

Step 3: Add tests

File: components/shopify/storefront_search/tests/ui-knobs/dom-events.test.ts

test("dispatches marqo:my.new.event with payload", () => {
const events: CustomEvent[] = [];
const handler = (e: Event) => events.push(e as CustomEvent);
document.addEventListener("marqo:my.new.event", handler);

dispatchMarqoEvent("marqo:my.new.event", { someData: "test" });

expect(events).toHaveLength(1);
expect(events[0].detail.someData).toBe("test");
expect(events[0].bubbles).toBe(true);

document.removeEventListener("marqo:my.new.event", handler);
});

Step 4: Document in user guide

Add the event to the event reference table in docs/storefront-widget-user-guide.md (Section 6) with its name, trigger condition, cancelability, and payload fields.

Existing dispatch locations

For reference, here is where each current event is dispatched:

EventFileFunction/Hook
marqo:readymarqo-search-app.tsAfter init
marqo:destroymarqo-search-app.tsdestroy()
marqo:search.startsearch.tsBefore API call
marqo:search.resultsui-rendering.tsAfter Vue render
marqo:search.emptyui-rendering.tsWhen 0 results
marqo:search.errorsearch.tsOn API error
marqo:card.rendervue/GridApp.tsonMounted / watch(products)
marqo:cta.clickvue/ProductCard.tshandleCtaClick()
marqo:filter.changeui-rendering/event-handlers.tsFilter change handler
marqo:filter.clearui-rendering/event-handlers.tsClear filters handler
marqo:sort.changeevent-manager.tsSort change handler
marqo:page.changeevent-manager.tsPage change handler
marqo:grid.injectedvue/GridApp.tsonMounted / watch(products)

14. Extending Grid Injections

Data model

Backend: components/shopify/admin_server/admin_server/models/ui_settings.py

Grid injections are stored as a UIComponent:

"grid_injections": UIComponent(
name="Grid Injections",
enabled=True,
value={
"items": [
{
"id": "spring-promo",
"position": 4,
"span": 4,
"html": "<div>...</div>",
"css": ".promo { ... }",
"enabled": True,
}
]
},
)

Frontend types

File: components/ecom_admin/app/lib/types.ts

export interface GridInjectionItem {
id: string;
position: number;
span: number;
html: string;
css: string;
enabled: boolean;
}

export interface GridInjectionsSettings {
items: GridInjectionItem[];
}

Settings converter

File: components/ecom_admin/app/lib/settings-converter.ts

The converter maps between the backend UIComponent format and the frontend GridInjectionsSettings type. Items are stored in the value.items array.

Admin UI

File: components/ecom_admin/app/components/settings/grid-injections-section.tsx

The admin UI renders an "Add Content Block" button and a list of configured injections, each with:

  • Position input
  • Column span input
  • HTML code editor
  • CSS code editor
  • Enable/disable toggle
  • Delete button

Storefront rendering

File: components/shopify/storefront_search/src/vue/GridApp.ts

The GridApp component handles injection interleaving:

  1. injections computed reads from window.MarqoUIConfig.uiComponents.grid_injections
  2. interleavedItems computed merges products and injections into a single list, sorted by descending position to prevent index shifting
  3. Injections render as <div class="marqo-grid-injection"> with innerHTML (sanitized) and a grid-column: span min(N, var(--marqo-grid-columns, N)) style that clamps the span to the current column count
  4. After rendering, marqo:grid.injected event is dispatched

Adding a new injection field

To add a new field to injection items (e.g., backgroundColor):

  1. Add to GridInjectionItem in types.ts
  2. Add default in settings converter
  3. Add to admin UI section component
  4. Read in GridApp.ts interleavedItems computed
  5. Apply in the render function (e.g., as a style attribute)
  6. Add tests in tests/vue-components.test.ts

15. Template System

UIComponent model

Every configurable component in the widget is represented as a UIComponent with this shape:

class UIComponent:
name: str # Display name (e.g., "Product Card")
html: str | None # HTML template (Tier 3 editable)
css: str | None # CSS template (Tier 3 editable)
value: dict | None # Settings values (Tier 1 knobs)
enabled: bool # Whether the component is shown

The html and css fields hold the Tier 3 Advanced Template Editor content. The value field holds the structured settings (Tier 1 knobs). The settings converter reads only enabled and value; it never modifies html or css.

Default templates

Default HTML and CSS templates for each component are defined in the backend:

  • components/shopify/admin_server/admin_server/models/templates.py

These templates use Vue syntax (for product cards) and standard HTML (for other components). They reference .marqo-* CSS classes and var() CSS variable references.

Tier 4 template detection

File: components/shopify/storefront_search/src/vue/template-detection.ts

export function getTemplate(
componentKey: string,
fallback: string | null = null,
): string | null {
const el = document.getElementById(`marqo-${componentKey}-template`);
if (el && el.textContent) {
const trimmed = el.textContent.trim();
if (trimmed.length > 0) return trimmed;
}
return fallback;
}

export function hasTemplate(componentKey: string): boolean {
return getTemplate(componentKey) !== null;
}

The detection scans for <script id="marqo-{component}-template" type="text/template"> tags in the DOM. If found, the template content is used instead of the DDB-stored template.

Supported component keys

KeyComponent
product-cardProduct card
filtersFilter sidebar
paginationPagination controls
sortSort dropdown
loadingLoading state
errorError state
no-resultsNo results state

Pre-compilation plan for CSP safety

The widget uses runtime-only Vue (vue/dist/vue.runtime.esm-bundler.js). This means:

  • h() render functions are used instead of string templates
  • No eval() or new Function() at runtime
  • CSP-safe: no unsafe-eval needed in script-src

For Tier 4 theme template overrides that use string templates (e.g., {{ title }}), the template compiler would need to be included. The current plan is to pre-compile theme templates at build time or use a safe template compilation strategy. This is tracked as future work.


16. Testing

Test architecture

Tests are organized by type:

TypeLocationWhat they test
UI knob round-tripstorefront_search/tests/ui-knobs/*.test.tsCSS variable generation from settings
Vue componentstorefront_search/tests/vue-components.test.tsComponent mounting, rendering, events
DOM eventsstorefront_search/tests/ui-knobs/dom-events.test.tsdispatchMarqoEvent() helper
Integrationstorefront_search/tests/dom-events-integration.test.tsCTA handler + event payloads
Config validationstorefront_search/tests/config-validator.test.tsMarqoUIConfig validation
Filter logicstorefront_search/tests/filter-manager.stock.test.tsFilter state management
Converterecom_admin/app/lib/__tests__/settings-converter.test.tsSettings round-trip conversion

UI knob round-trip tests (CSS variables)

These tests verify that a setting value in UIComponent produces the correct CSS variable. They use a shared test utility:

import { generateCssVariables } from "../../src/css-variables";
import { makeUiComponents, expectCssVar } from "./knob-test-utils";

test("cardBorderRadius -> --marqo-card-border-radius (px suffix)", () => {
const ui = makeUiComponents({
product_display_config: { cardBorderRadius: 16 },
});
const css = generateCssVariables(ui);
expectCssVar(css, "--marqo-card-border-radius", "16px");
});

makeUiComponents() creates a complete UIComponents object with defaults, overriding only the fields you specify. expectCssVar() asserts that the generated CSS contains the expected variable declaration.

Vue component tests

These tests mount actual Vue components using createApp + provide, the same pattern used in production:

import { createApp, ref, nextTick } from "vue";
import GridApp, { PRODUCTS_INJECTION_KEY } from "../src/vue/GridApp";

function mountGridApp(products, config) {
window.MarqoUIConfig = config;
const container = document.createElement("div");
document.body.appendChild(container);

const productsRef = ref(products);
const app = createApp(GridApp);
app.provide(PRODUCTS_INJECTION_KEY, productsRef);
app.mount(container);

return { app, productsRef, container };
}

test("renders product cards", async () => {
const { container } = mountGridApp([makeProduct()], defaultConfig);
await nextTick();

const cards = container.querySelectorAll(".marqo-product-card");
expect(cards).toHaveLength(1);
});

How to add a test for each type

UI knob test:

  1. Open the relevant file in tests/ui-knobs/ (e.g., product-display.test.ts)
  2. Add a test that calls makeUiComponents() with your setting, runs generateCssVariables(), and asserts the CSS variable value with expectCssVar()
  3. Run: cd components/shopify/storefront_search && npx vitest run tests/ui-knobs/

Vue component test:

  1. Open tests/vue-components.test.ts
  2. Mount the component with mountGridApp() or write a custom mount helper
  3. Assert DOM output, event dispatching, or reactive behavior
  4. Run: cd components/shopify/storefront_search && npx vitest run tests/vue-components.test.ts

DOM event test:

  1. Open tests/ui-knobs/dom-events.test.ts
  2. Call dispatchMarqoEvent() and assert the event was dispatched with the correct payload
  3. Run: cd components/shopify/storefront_search && npx vitest run tests/ui-knobs/dom-events.test.ts

Converter test:

  1. Open components/ecom_admin/app/lib/__tests__/settings-converter.test.ts
  2. Add a test that creates frontend settings, converts to backend, converts back, and asserts round-trip fidelity
  3. Run: cd components/ecom_admin && npm test -- --run app/lib/__tests__/settings-converter.test.ts

Test environment

All tests run in jsdom (configured in vite.config.ts):

test: {
globals: true,
environment: "jsdom",
}

This provides a browser-like DOM environment (document, window, HTMLElement, etc.) without a real browser.


17. Build and Bundle

Vite configuration

File: components/shopify/storefront_search/vite.config.ts

import { defineConfig } from "vite";

export default defineConfig({
resolve: {
alias: {
// Use runtime-only Vue build (~22KB gzipped, CSP-safe, no template compiler)
vue: "vue/dist/vue.runtime.esm-bundler.js",
},
},
define: {
// Vue's ESM bundler build checks process.env.NODE_ENV at runtime.
// IIFE bundles don't have `process`, so we replace it at build time.
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
lib: {
entry: "src/index.ts",
name: "ShopifySearchApp",
formats: ["iife"],
fileName: () => "bundle.js",
},
outDir: "dist",
minify: "terser",
},
test: {
globals: true,
environment: "jsdom",
},
});

Key build decisions

IIFE format: The bundle is loaded as a <script> tag on Shopify storefronts. IIFE (Immediately Invoked Function Expression) ensures the code executes without module loader support and does not pollute the global namespace.

Runtime-only Vue: The alias vue: "vue/dist/vue.runtime.esm-bundler.js" excludes Vue's template compiler (~13KB gzipped). All components use h() render functions instead of <template> blocks. This makes the bundle smaller and CSP-safe.

process.env.NODE_ENV define: Vue's ESM bundler build references process.env.NODE_ENV for tree-shaking dev-only code. IIFE bundles run in browser contexts where process does not exist. The define replaces all references at build time with "production", enabling Vue to tree-shake development warnings.

Terser minification: The minify: "terser" setting produces the smallest output. Default esbuild minification is faster but slightly larger.

Bundle size budget

Target: < 55KB gzipped.

The bundle includes:

  • Vue runtime (~22KB gzipped)
  • Widget application code (~25-30KB gzipped)
  • No template compiler, no Handlebars, no external dependencies beyond Vue

Tree-shaking

Vue's ESM bundler build enables tree-shaking of unused Vue features. The define of process.env.NODE_ENV as "production" allows bundlers to dead-code-eliminate Vue's development-only code paths (warnings, runtime checks).

Since we use only a subset of Vue's API (defineComponent, h, ref, computed, watch, inject, provide, createApp, nextTick, onMounted, onUnmounted), unused Vue features are tree-shaken out.

Build command

cd components/shopify/storefront_search
npx vite build

Output: dist/bundle.js

Dependency overview

DependencyTypeSize (gzipped)Purpose
vueRuntime~22KBReactive rendering framework
viteDev--Build tool
vitestDev--Test runner
jsdomDev--DOM environment for tests
terserDev--JavaScript minifier
typescriptDev--Type checking

There is exactly one production dependency: vue. Everything else is devDependencies.


18. Verifying Bundle Changes on a Live Merchant Preview

For high-risk bundle PRs (refactors, render-path changes, anything where jsdom tests aren't enough), prove behavior parity on a real merchant page without touching the storefront. This is a read-only technique: no theme writes, no storefront API writes, no DDB writes.

When to use this

  • Refactor PRs touching storefront_search/ where you need to show the rendered output is byte-equivalent.
  • Investigating "does this work on the real merchant theme" before merge.
  • Any change where the local dev store doesn't reproduce the merchant's data shape, CSS, or third-party scripts.

For everyday local iteration, see Local Development instead — this section is specifically for verification on production-equivalent merchant previews.

How it works

The production bundle URL is shared across merchants:

https://d1cn6wu7hc977d.cloudfront.net/search/bundle.js

Playwright's page.route intercepts that request and serves your local dist/bundle.js instead. The merchant's theme, data, and runtime stay untouched — only the bundle is swapped.

Step-by-step

  1. Open the merchant's Marqo preview. Examples:

    • Muji US: the shopifypreview.com link from the integration doc.
    • Laura Geller: the live domain with lmi_preview, targeting, and vsly_vid params (see reference_lg_debugging memory or docs/runbooks/diagnostics/components/lg-promo-messages.md).
  2. Capture "before". Measure structured DOM metrics and screenshot under the unmodified production bundle.

  3. Build the local bundle.

    cd components/shopify/storefront_search
    npx vite build
  4. Route the CloudFront URL to your local file and count hits to prove the swap took:

    let hits = 0;
    await page.route('**/d1cn6wu7hc977d.cloudfront.net/search/bundle.js*', (route) => {
    hits++;
    return route.fulfill({
    contentType: 'application/javascript',
    path: '/absolute/path/to/components/shopify/storefront_search/dist/bundle.js',
    });
    });
    await page.reload();
    // After reload, assert hits >= 1. If hits === 0, the swap was bypassed (caching, service worker, etc.).
  5. Capture "after" at the same viewport width and scroll position as the "before" capture.

  6. Compare structured DOM metrics (per-row counts, badge texts, data-* attributes, class lists) — not just pixel screenshots. Pixel diffs are noisy; structural diffs are decisive.

Gotchas

  • route.fulfill({ path }) reads files on the Playwright server, not in the page VM. Inside browser_run_code_unsafe's evaluator there is no fs or dynamic import — you cannot read the bundle from page-side code. Use the host-side page.route API.
  • TypeScript casts (as X) are invalid inside page.evaluate strings. The string is parsed as plain JavaScript. Strip type assertions or move the logic out of the evaluate call.
  • Promo popups break screenshots. Postscript, Attentive, OneTrust (#shopify-pc__banner) all overlay the page. Remove or hide them before screenshot/measure.
  • Viewport width drives dynamic measurement. Swatch overflow, card row counts, and other layout decisions re-trigger on resize. Before/after numbers are only comparable at the same width and the same scroll position.
  • Vnode-level ref callbacks only fire when built inside a component render context. If you construct vnodes outside a render function, refs won't attach — your verification will look broken when the code is actually fine.
  • Caching can silently bypass the route. Always assert hits >= 1 in the route handler. If it's zero, check for service workers, CDN-pinned Cache-Control, or a stale page.context().

Why this beats jsdom alone

jsdom tests catch logic regressions but not interactions with the merchant's CSS, third-party scripts (Visually.io, Postscript), Shopify Markets routing, or real product data. Route interception gives you real-merchant evidence with zero write surface — strictly stronger than jsdom for high-risk PRs, with no rollback risk.