> ## Documentation Index
> Fetch the complete documentation index at: https://docs.depict.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Theme integration for market versions and A/B tests

> Render Depict's per-market product orders from metaobjects, hide out-of-stock products, keep pagination correct, and let collection A/B tests serve each shopper their variant.

> [!NOTE]
>
> This page is aimed at a merchant's theme developer: the person editing the
> collection section, pagination snippet and facets of the Shopify theme.

## Overview

Shopify collections have a single global product order that applies to all
markets. To merchandise each market differently, Depict stores
market-specific orderings in `depict_multi_market_collection` metaobjects,
keyed by collection ID and market ID. This integration makes your collection
section read products from those metaobjects instead of the collection's
default order.

A [collection version
test](/shopify-lite/ab-testing.md#collection-version-test) rides on the same
integration: Depict switches the test on for stores that have steps 1 to 5
in place. [Collection version tests](#collection-version-tests) lists what
a test needs from the theme.

The same integration also supports hiding out-of-stock (OOS) products from
collection pages while keeping their product pages reachable. This is
controlled by two metafields set from the Depict portal: one on the shop
(hide globally) and one per collection.

> [!WARNING]
>
> The `$app:collection-settings` / `hide_oos` metafield definition needs
> storefront access `PUBLIC_READ`. Without it these Liquid reads return
> `nil` and OOS hiding silently stays off.

## Implementation

Update your existing `main-collection.liquid` (or equivalent collection
section) as follows. The snippets use Dawn's structure; adapt the markup to
your theme.

### 1. Add market detection logic

Place this at the top of the section, outside the `{% paginate %}` block.
It resolves the per-market metaobject, reads the two hide-OOS metafields,
and skips Depict ordering whenever filters or a custom sort are active.

```liquid theme={null}
{% liquid
  assign use_metaobject_products = false

  assign depict_hide_oos_globally = shop.metafields['app--93504864257--collection-settings']['hide_oos_globally'].value
  assign depict_hide_oos_collection = collection.metafields['app--93504864257--collection-settings']['hide_oos'].value
  assign depict_hide_oos = false
  if depict_hide_oos_globally == true or depict_hide_oos_collection == true
    assign depict_hide_oos = true
  endif

  assign has_active_filters = false
  for filter in collection.filters
    if filter.active_values.size > 0
      assign has_active_filters = true
      break
    endif
  endfor

  assign has_custom_sort = false
  if collection.sort_by != collection.default_sort_by and collection.sort_by != blank
    assign has_custom_sort = true
  endif

  unless has_active_filters or has_custom_sort
    assign multi_market_handle = "collection_" | append: collection.id | append: "_market_" | append: localization.market.id
    assign multi_market_collection = metaobjects.depict_multi_market_collection[multi_market_handle]

    unless multi_market_collection and multi_market_collection.product_order and multi_market_collection.product_order.value
      assign fallback_handle = "collection_" | append: collection.id | append: "_market_fallback"
      assign multi_market_collection = metaobjects.depict_multi_market_collection[fallback_handle]
    endunless

    if multi_market_collection and multi_market_collection.product_order and multi_market_collection.product_order.value
      assign first_chunk = multi_market_collection.product_order.value.products_0.value | reverse | reverse
      if first_chunk.size > 0
        assign market_products_metaobject = multi_market_collection.product_order.value
        assign use_metaobject_products = true
      endif
    endif
  endunless
%}
```

The lookup falls back to `collection_{id}_market_fallback`, which covers
the main/default market and any market that has not been merchandised.

### 2. Compute the real page count

When OOS products are hidden, Shopify's native `paginate` object (based on
`collection.products`) over-counts, because hidden products still count
toward the total. The `depict_multi_market_collection` wrapper carries an
`in_stock_counts` JSON field that maps every enabled market ID to that
market's in-stock product count, for example
`{"93309862172":3000,"93309894940":3000}`.

Each count is the number of products in the collection that are
storefront-visible (status `ACTIVE`) and in stock for that market. It is
always less than or equal to `collection.products_count`. A larger count is
a Depict-side bug: report it rather than working around it in the theme.

> [!NOTE]
>
> In-stock counts are currently enabled per merchant; rollout to all
> multi-market merchants is pending a canary week. The old
> `depict_collection_data` metaobject is dead. Do not reference it in new
> integrations.

This block must run **before** the render loop in step 3, because the loop
uses `depict_in_stock_count`. Resolve `multi_market_collection` and the count
at the top of the section (no paginate context needed), but compute
`metaobject_pages` **inside** the `{% paginate %}` block: `paginate.page_size`
is `nil` outside it and dividing by `nil` silently yields a wrong page count.

```liquid theme={null}
{% liquid
  assign metaobject_pages = blank
  if use_metaobject_products and depict_hide_oos
    assign depict_market_id = localization.market.id | append: ''
    assign depict_in_stock_counts = multi_market_collection.in_stock_counts.value
    if depict_in_stock_counts
      assign depict_in_stock_count = depict_in_stock_counts[depict_market_id]
      if depict_in_stock_count
        assign metaobject_pages = depict_in_stock_count | times: 1.0 | divided_by: section.settings.products_per_page | ceil
      endif
    endif
  endif
%}
```

* `localization.market.id` renders as a plain number; the `| append: ''`
  string normalisation is required for keying the JSON hash.
* `section.settings.products_per_page` is Dawn's page size. In general,
  divide by whatever feeds your outer `paginate`.
* If the field or the market key is missing, everything degrades to native
  pagination (`nil` becomes `blank` and the override goes unused).

> [!WARNING]
>
> `blank` is truthy in Liquid and `blank | times: 1` is `0`. Never "blank
> out" the count to disable the feature: it renders zero products. To
> disable, remove or skip this assign block entirely.

### 3. Add custom product rendering logic

Products render from the chunked metaobject arrays (`products_0`,
`products_1`, …), manually tracking which ones fall inside the current page
window. Replace the product rendering loop with the following. The scan cap
lines are explained right after the snippet.

```liquid theme={null}
{% if use_metaobject_products %}
  {% liquid
    assign end_paginate_offset = paginate.current_offset | plus: paginate.page_size
    assign page_paginate = paginate
    assign product_index = 0
    assign index_in_page = 0

    assign depict_scan_cap = -1
    if depict_hide_oos and depict_in_stock_count
      assign depict_scan_cap = depict_in_stock_count | times: 1
    endif
  %}
  {% unless depict_scan_cap != -1 and page_paginate.current_offset >= depict_scan_cap %}
    {% for product_key_index in (0..39) %}
      {% liquid
        assign variable = "products_" | append: product_key_index
        assign products = market_products_metaobject[variable].value
        if products.count == 0
          break
        endif
      %}
      {% paginate products by 128 %}
        {% for product in products %}
          {% unless depict_hide_oos and product.available == false %}
            {% if product_index >= page_paginate.current_offset and index_in_page < page_paginate.page_size %}
              <li
                id="{{ section.id }}-{{ product.id }}"
                class="product-grid__item product-grid__item--{{ index_in_page }}"
                data-page="{{ page_paginate.current_page }}"
                data-product-id="{{ product.id }}"
                ref="cards[]"
              >
                {% content_for 'block', type: '_product-card', id: 'product-card', closest.product: product %}
              </li>
              {% assign index_in_page = index_in_page | plus: 1 %}
            {% endif %}
            {% assign product_index = product_index | plus: 1 %}
          {% endunless %}
          {% if product_index >= end_paginate_offset %}
            {% break %}
          {% endif %}
          {% if depict_scan_cap != -1 and product_index >= depict_scan_cap %}{% break %}{% endif %}
        {% endfor %}
      {% endpaginate %}
      {% if product_index >= end_paginate_offset %}
        {% break %}
      {% endif %}
      {% if depict_scan_cap != -1 and product_index >= depict_scan_cap %}{% break %}{% endif %}
    {% endfor %}
  {% endunless %}
{% else %}
  {% for product in collection.products %}
    <li
      id="{{ section.id }}-{{ product.id }}"
      class="product-grid__item product-grid__item--{{ forloop.index0 }}"
      data-page="{{ paginate.current_page }}"
      data-product-id="{{ product.id }}"
      ref="cards[]"
    >
      {% content_for 'block', type: '_product-card', id: 'product-card', closest.product: product %}
    </li>
  {% endfor %}
{% endif %}
```

What this does:

* With OOS hiding on, only available products count toward the page offset
  and index.
* It breaks out of the loop as soon as the current page window is filled
  (`product_index >= end_paginate_offset`), even when OOS hiding is on, so
  it never walks the full product set.
* `if products.count == 0 break` is safe because Liquid drops references it
  cannot resolve, so an all-invisible chunk reads as empty and ends the walk
  early. Depict guarantees (since August 2026) that synced orders contain
  only storefront-visible products. If a collection stops rendering midway
  while deeper pages exist, suspect unresolvable references and report it
  to Depict.

> [!WARNING]
>
> Never wrap the `end_paginate_offset` breaks in `{% unless depict_hide_oos %}`.
> That forces the loop to hydrate `.available` on every reference (around
> 5,120 in large collections) just to count, producing multi-second cold
> renders. The real page count comes from the precomputed in-stock count
> instead.

#### The scan cap is mandatory

`depict_scan_cap` caps the scan at the in-stock count so requests never walk
the OOS tail. It does two things:

1. Skips the walk entirely on pages past the last real page (the
   `{% unless … current_offset >= depict_scan_cap %}` wrapper).
2. Stops at the count-th in-stock match (the extra `break` at both existing
   break points).

Without the cap, requests past the real last page hydrate every reference
to render nothing, around 2.9 seconds on a 4,000-product collection. A
crawler doing this once caused a storefront-wide rate-limit fence, and ghost
URLs stay crawler-discoverable via Shopify's auto-injected
`<link rel="next">`.

### 4. Fix pagination for OOS filtering

Feed `metaobject_pages` from step 2 through your theme's pagination plumbing.
When the count is unavailable, `metaobject_pages` stays blank and everything
falls through to the native values.

#### Infinite scroll

If your theme uses infinite scroll, find where it sets the total page count
on a data attribute (for example `data-last-page`) and override it:

```liquid theme={null}
data-last-page="{% if metaobject_pages %}{{ metaobject_pages }}{% else %}{{ paginate.pages }}{% endif %}"
```

#### Pagination buttons

If your theme renders pagination buttons, pass `metaobject_pages` as a
`total_pages_override` parameter to the pagination snippet:

```liquid theme={null}
{% render 'your-pagination-snippet', paginate: paginate, total_pages_override: metaobject_pages %}
```

Then modify the snippet to respect the override. `paginate.parts` and
`paginate.pages` reflect the unfiltered collection size, so three things need
adjusting.

1. Compute the effective total at the top of the snippet, and use
   `effective_pages` instead of `paginate.pages` wherever the total is
   referenced (visibility checks, mobile-hide logic, and so on):

   ```liquid theme={null}
   {%- assign effective_pages = total_pages_override | default: paginate.pages -%}
   ```

2. Skip page links beyond the real total. Inside the loop over
   `paginate.parts`, add an early skip:

   ```liquid theme={null}
   {% liquid
     if part.is_link
       assign part_page = part.title | plus: 0
       if part_page > 0 and part_page > effective_pages
         continue
       endif
     endif
   %}
   ```

   Ellipsis (`…`) parts past the effective last page survive this pruning
   because they are not links. This is cosmetic only.

3. Fix the "next" arrow. The native `paginate.next` may point to a page
   beyond the real total, so add a check on the current page:

   ```liquid theme={null}
   {%- if paginate.next and paginate.current_page < effective_pages -%}
   ```

### 5. Fix the total products count

Themes show a product count in the filters bar or facets area (Dawn:
`snippets/facets.liquid`). With OOS hiding active,
`collection.products_count` still includes the hidden products. Use the
in-stock count when it applies:

```liquid theme={null}
{% liquid
  assign total_products_to_display = collection.products_count
  if depict_hide_oos and depict_in_stock_count
    assign total_products_to_display = depict_in_stock_count
  endif
%}
```

Pass `total_products_to_display` wherever the total is shown: the filters
bar, the load-more "X of Y" text, and any results header.

> [!WARNING]
>
> The filters bar renders before `{% paginate %}`. Resolve
> `multi_market_collection` and `depict_in_stock_count` at the top of the
> section, and compute `metaobject_pages` inside the paginate block.

## Collection version tests

A collection version test pits two saved versions of one collection against
each other. Depict's app embed serves the test and reorders the grid in the
shopper's browser; the section needs no extra Liquid. Depict switches the
test on per store once steps 1 to 5 are in place, so market versions and
tests render the same way.

### How a test reaches the storefront

When a test starts, Depict writes each variant's order as a
`depict_market_products` metaobject and, for every market in scope, a
`depict_multi_market_collection` metaobject with the handle
`collection_{collection id}_market_{market id}_abtest_{test id}_variant_A`
(and `_variant_B`) whose `product_order` points at it. These sit next to the
market's own `collection_{collection id}_market_{market id}` object and
never replace it.

On every collection page view the **Depict Brand Features** app embed reads
the collection's running test, resolves both variants for
`localization.market.id`, and hands them to the Depict script. The script
assigns the shopper to a group once per browser, fetches that variant's
whole order from the Storefront API, and reorders the product cards your
section rendered for the page. New tests also serve each variant's
content cards, product duplicates and image settings. A
running test outranks the market's own order for the shopper's group. When
the test ends, the live version's order applies again; promoting a winner
publishes it like any other version, so it reaches the storefront through
steps 1 to 5.

Because the section renders the live order's products for each page, which
page a product is on follows the live order; its position within the page
follows the variant.

### What the theme must provide

* **Depict's grid markup.** The `<depict-grid-start-beacon>` and
  `<depict-grid-end-beacon>` elements around the product loop, and
  `data-depict-product-id="{{ product.id }}"` on every product card or an
  element inside it. See [Content features theme
  integration](/shopify-lite/theme/content-features-integration). Depict's
  theme patcher adds these; keep them when you rework the section, or the
  script cannot find the grid or match the cards to the variant's order.
* **Steps 1 to 5 above**, on the theme that serves the test. Depict switches
  collection version tests on per store once they are in place.
* **The Depict Brand Features app embed switched on** for the theme. See
  [Theme setup](/shopify-lite/theme-setup.md#depict-brand-features-automatic).
  The storefront access key the script uses is written by the app; nothing
  to add.

> [!NOTE]
>
> The group is stored per browser in `localStorage` under
> `depict_collection_ab_variant_{test id}`, so a returning shopper keeps
> their group on the same device. Filtered and custom-sorted views keep the
> theme's native order for both groups, exactly as the market order does in
> step 1; the group is still assigned. Inside the Depict portal's live
> preview no test is served.

### Verify on the storefront

1. Start a test in the Depict app on the collection.
2. Open the collection in two different browsers, in a market the test
   covers. Private windows of one browser share storage, so they land in the
   same group. In each browser's console, read the group:

   ```js theme={null}
   const script = document.querySelector("#depict-grid-rendering-script");
   const testId = JSON.parse(script.dataset.collectionAbTest).test_id;
   localStorage.getItem("depict_collection_ab_variant_" + testId);
   ```

   Repeat in a fresh browser profile until one reads `A` and the other `B`.
3. In each browser, check that the `#depict-grid-rendering-script` element
   has a non-empty `data-collection-ab-test` attribute and a non-empty
   `data-storefront-access-key` attribute. An empty test attribute means
   the embed found no running test for this market, or one of the variant
   metaobjects has no products. Without the access key the script serves
   no test and never writes the group key.
4. Compare the order of the page's products between the browsers with the
   two versions in the Depict app. The order follows each browser's group.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.