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
- Init (
marqo-search-app.ts):MarqoSearchAppvalidates config (config-validator.ts), injects CSS variables (css-variables.ts), detects containers (selector-utils.ts), sets up theFilterManager, and dispatchesmarqo:ready. - Search (
search.ts,search-api.ts): Queries the Marqo API, dispatchesmarqo:search.start. On success, processes results and facets. Dispatchesmarqo:search.resultsormarqo:search.empty. - Render (
ui-rendering.ts): Orchestrates Vue component mounting. Creates or updates reactive refs. Vue handles DOM updates reactively. - Events (
dom-manager.ts,event-manager.ts): DOM events dispatched after each render cycle.EventManagerhandles 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:
- Build with
npx vite build - The bundle is picked up by the Shopify app proxy serving path
- Test on your development store
package.json scripts
| Script | Command | Purpose |
|---|---|---|
build | vite build | Build production IIFE bundle |
test | vitest | Run tests (watch mode) |
coverage | vitest run --coverage | Run 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:
- Declare module-level refs for reactive state:
let vueMyApp: App | null = null;
let vueMyStateRef: Ref<MyState> | null = null;
- 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;
}
- 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:
PaginationreceivescurrentPageandtotalPagesas props. -
Provide/inject: Used when the data source is outside of Vue. The
GridAppinjectsproductsRefviaPRODUCTS_INJECTION_KEYbecause the products array is managed byui-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:
| Event | File | Function/Hook |
|---|---|---|
marqo:ready | marqo-search-app.ts | After init |
marqo:destroy | marqo-search-app.ts | destroy() |
marqo:search.start | search.ts | Before API call |
marqo:search.results | ui-rendering.ts | After Vue render |
marqo:search.empty | ui-rendering.ts | When 0 results |
marqo:search.error | search.ts | On API error |
marqo:card.render | vue/GridApp.ts | onMounted / watch(products) |
marqo:cta.click | vue/ProductCard.ts | handleCtaClick() |
marqo:filter.change | ui-rendering/event-handlers.ts | Filter change handler |
marqo:filter.clear | ui-rendering/event-handlers.ts | Clear filters handler |
marqo:sort.change | event-manager.ts | Sort change handler |
marqo:page.change | event-manager.ts | Page change handler |
marqo:grid.injected | vue/GridApp.ts | onMounted / 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:
injectionscomputed reads fromwindow.MarqoUIConfig.uiComponents.grid_injectionsinterleavedItemscomputed merges products and injections into a single list, sorted by descending position to prevent index shifting- Injections render as
<div class="marqo-grid-injection">withinnerHTML(sanitized) and agrid-column: span min(N, var(--marqo-grid-columns, N))style that clamps the span to the current column count - After rendering,
marqo:grid.injectedevent is dispatched
Adding a new injection field
To add a new field to injection items (e.g., backgroundColor):
- Add to
GridInjectionItemintypes.ts - Add default in settings converter
- Add to admin UI section component
- Read in
GridApp.tsinterleavedItemscomputed - Apply in the render function (e.g., as a style attribute)
- 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
| Key | Component |
|---|---|
product-card | Product card |
filters | Filter sidebar |
pagination | Pagination controls |
sort | Sort dropdown |
loading | Loading state |
error | Error state |
no-results | No 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()ornew Function()at runtime - CSP-safe: no
unsafe-evalneeded 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:
| Type | Location | What they test |
|---|---|---|
| UI knob round-trip | storefront_search/tests/ui-knobs/*.test.ts | CSS variable generation from settings |
| Vue component | storefront_search/tests/vue-components.test.ts | Component mounting, rendering, events |
| DOM events | storefront_search/tests/ui-knobs/dom-events.test.ts | dispatchMarqoEvent() helper |
| Integration | storefront_search/tests/dom-events-integration.test.ts | CTA handler + event payloads |
| Config validation | storefront_search/tests/config-validator.test.ts | MarqoUIConfig validation |
| Filter logic | storefront_search/tests/filter-manager.stock.test.ts | Filter state management |
| Converter | ecom_admin/app/lib/__tests__/settings-converter.test.ts | Settings 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:
- Open the relevant file in
tests/ui-knobs/(e.g.,product-display.test.ts) - Add a test that calls
makeUiComponents()with your setting, runsgenerateCssVariables(), and asserts the CSS variable value withexpectCssVar() - Run:
cd components/shopify/storefront_search && npx vitest run tests/ui-knobs/
Vue component test:
- Open
tests/vue-components.test.ts - Mount the component with
mountGridApp()or write a custom mount helper - Assert DOM output, event dispatching, or reactive behavior
- Run:
cd components/shopify/storefront_search && npx vitest run tests/vue-components.test.ts
DOM event test:
- Open
tests/ui-knobs/dom-events.test.ts - Call
dispatchMarqoEvent()and assert the event was dispatched with the correct payload - Run:
cd components/shopify/storefront_search && npx vitest run tests/ui-knobs/dom-events.test.ts
Converter test:
- Open
components/ecom_admin/app/lib/__tests__/settings-converter.test.ts - Add a test that creates frontend settings, converts to backend, converts back, and asserts round-trip fidelity
- 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
| Dependency | Type | Size (gzipped) | Purpose |
|---|---|---|---|
vue | Runtime | ~22KB | Reactive rendering framework |
vite | Dev | -- | Build tool |
vitest | Dev | -- | Test runner |
jsdom | Dev | -- | DOM environment for tests |
terser | Dev | -- | JavaScript minifier |
typescript | Dev | -- | 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
-
Open the merchant's Marqo preview. Examples:
- Muji US: the
shopifypreview.comlink from the integration doc. - Laura Geller: the live domain with
lmi_preview,targeting, andvsly_vidparams (seereference_lg_debuggingmemory ordocs/runbooks/diagnostics/components/lg-promo-messages.md).
- Muji US: the
-
Capture "before". Measure structured DOM metrics and screenshot under the unmodified production bundle.
-
Build the local bundle.
cd components/shopify/storefront_searchnpx vite build -
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.). -
Capture "after" at the same viewport width and scroll position as the "before" capture.
-
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. Insidebrowser_run_code_unsafe's evaluator there is nofsor dynamicimport— you cannot read the bundle from page-side code. Use the host-sidepage.routeAPI.- TypeScript casts (
as X) are invalid insidepage.evaluatestrings. 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
refcallbacks 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 >= 1in the route handler. If it's zero, check for service workers, CDN-pinnedCache-Control, or a stalepage.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.