← back to Dw Theme Boost Fix

snippets/boost-infinite-override.liquid

503 lines

{% comment %}
  ============================================================================
  Boost Infinite-Scroll Override  (DW, 2026-06-24)
  ----------------------------------------------------------------------------
  Boost AI Search & Discovery exposes NO API to flip pagination to infinite
  scroll, and the account-level dashboard setting serves
  paginationType:"default" (numbered) to the collection/search surfaces.

  This snippet forces infinite scroll by rewriting Boost's own runtime config
  object (window.boostSDAppConfig) BEFORE Boost's lazy bundle reads it.

  TIMING — this MUST render in <head> IMMEDIATELY BEFORE {{ content_for_header }}.
  Boost injects boostSDAppConfig via content_for_header app-embed scripts, so a
  defineProperty setter installed *before* that injection intercepts the moment
  Boost assigns the global. A theme asset (boost-sd-custom.js) loads on Boost's
  own schedule and can run AFTER Boost has already read the config, so the
  config-patch cannot live there — only the belt-and-suspenders fallbacks do.

  Strategy (belt + suspenders):
    1. defineProperty setter on window.boostSDAppConfig — patches on assignment.
    2. If already defined (re-entry / SPA nav), patch immediately.
    3. Short rAF/interval poll re-applies in case Boost replaces the object.
    4. Patch every pagination knob found: generalSettings.* and
       additionalElements.pagination.* across all surfaces.

  ----------------------------------------------------------------------------
  ENDPOINT REPOINT  (DW, 2026-07-13 — customer-facing regression fix)
  Boost's app-embed config snippet ("app snippet: config", rendered before
  </body> via the boost-sd app embed in settings_data.json) hardcodes the
  boostSDAppConfig.api URLs to https://staging.bc-solutions.net/... which
  returns 403 on every call → empty filter trees, dead search page, broken
  infinite scroll. The snippet has a production self-correction block keyed on
  themeInfo env, but at runtime the staging values still win (the final
  `merge(boostSDAppConfig, window.boostSDAppConfig)` lets a pre-existing
  window config's keys — populated with the staging api — take precedence).
  We cannot edit the app embed's source, so patchApi() below rewrites any
  staging bc-solutions endpoint to the VERIFIED production values every time
  the config passes through this interceptor. TARGETED: only values containing
  'staging.bc-solutions.net' / 'boost-cdn-staging' are rewritten, so if Boost
  fixes this upstream the patch becomes a no-op. Production values verified
  2026-07-13 against services.mybcapps.com (filter: 75,278 products;
  search 'grasscloth': 8,203; suggest 'milano': 85) and against Boost's own
  env==='production' Object.assign block in the same app snippet
  (recommendUrl /discovery/recommend, analyticsUrl lambda.mybcapps.com/e,
  cdn boost-cdn-prod.bc-solutions.net).
  ============================================================================
{% endcomment %}
<script>
/* DW-GUARD-VERSION 20260825-1130 */
(function () {
  'use strict';
  if (window.__dwBoostInfinite) return;
  window.__dwBoostInfinite = true;


  /* DW Boost duplicate-request suppressor (v2 — buffered, body-safe).
     Landing on a deep-linked ?page=N (N>1) in infinite-scroll mode makes Boost
     fire its bc-sf-filter/search request TWICE (identical params + identical t=).
     We can't edit Boost's bundle, so we coalesce: the 1st call hits the network,
     its body is buffered ONCE, and any identical 2nd call within a short window is
     served a fresh Response built from that buffer — so both Boost init paths get
     VALID JSON (no empty-clone crash), from ONE round-trip, resolving together =
     one clean render. page= is part of the key, so real scroll-appends are never
     suppressed. Idempotent. */
  (function () {
    'use strict';
    if (window.__dwBoostDedup) return;
    window.__dwBoostDedup = true;

    // 5s: the real-browser double-fire is Boost re-firing the SAME page (often a
    // page=1 reset on a deep-linked landing) ~3.3s apart, well past a 1.5s guard.
    // Distinct pages keep distinct keys, so a wide window never touches real
    // scroll-appends — only an identical same-page/same-filter repeat is collapsed.
    var WINDOW_MS = 5000;
    var recent = Object.create(null);           // key -> { ts, buf: Promise<{body,init}|null> }

    function isSearch(u) { return /bc-sf-filter\/(search|filter)/i.test(String(u)); }
    function keyOf(u) {
      try { return String(u).replace(/([?&])t=\d+/, '$1t=').replace(/([?&])event=[^&]*/, '$1event='); }
      catch (e) { return String(u); }
    }
    function fresh(k) { var h = recent[k]; return h && (Date.now() - h.ts) < WINDOW_MS ? h : null; }

    var origFetch = window.fetch;
    if (typeof origFetch !== 'function') return;

    window.fetch = function (input, init) {
      var url = (typeof input === 'string') ? input : (input && input.url) || '';
      if (!isSearch(url)) return origFetch.call(this, input, init);

      var k = keyOf(url), h = fresh(k);
      if (h && h.buf) {
        // Duplicate within window: serve a fresh, fully-readable Response from the
        // buffered bytes. If buffering failed, fall back to a real network fetch.
        var self = this;
        return h.buf.then(function (o) {
          if (!o) return origFetch.call(self, input, init);
          return new Response(o.body ? o.body.slice(0) : null, o.init);
        });
      }

      // First (real) call: fetch, then clone+buffer the body the instant it
      // resolves (before any consumer reads it), so duplicates get valid data.
      var real = origFetch.call(this, input, init);
      recent[k] = {
        ts: Date.now(),
        buf: real.then(function (resp) {
          return resp.clone().arrayBuffer().then(function (ab) {
            return { body: ab, init: { status: resp.status, statusText: resp.statusText || '', headers: resp.headers } };
          });
        }).catch(function () { return null; })
      };
      recent[k].buf.then(function () { if (recent[k]) recent[k].ts = Date.now(); });
      return real;                               // first caller gets the untouched original
    };
  })();



  /* DW same-origin request governor + 429/430 auto-backoff (TK-10074, 2026-07-30).
     Reproduced: Shopify local_rate_limited trips ~30-45 origin req/s per IP, ~32s
     lockout. The theme is request-heavy (~1700 req/session; infinite scroll cascades
     ~14 pages in <8s), so a bursty visitor (fast nav / prefetch / multi-tab / dense
     grid) can self-inflict 429 and see the raw local_rate_limit page. Installed AFTER
     the dedup wrapper so it is the OUTERMOST window.fetch: caps concurrent same-origin
     requests to 6 (can't reach the trip point) AND retries 429/430 invisibly honoring
     Retry-After, so the grid just pauses instead of erroring. Idempotent. */
  (function(){
    'use strict';
    if (window.__dwReqGovernor) return; window.__dwReqGovernor = true;
    var ORIGIN = location.origin, MAX_CONCURRENT = 6, active = 0, queue = [];
    var origFetch = window.fetch;              // wraps whatever is current (incl. dedup)
    if (typeof origFetch !== 'function') return;
    function pump(){ while (active < MAX_CONCURRENT && queue.length) queue.shift()(); }
    function isOrigin(u){ try { u = String(u); return u.indexOf(ORIGIN) === 0 || u.charAt(0) === '/'; } catch(e){ return false; } }
    window.fetch = function(input, init){
      var url = (typeof input === 'string') ? input : (input && input.url) || '';
      if (!isOrigin(url)) return origFetch.call(this, input, init);
      var self = this;
      return new Promise(function(resolve, reject){
        queue.push(function run(){
          active++;
          (function attempt(tries){
            origFetch.call(self, input, init).then(function(resp){
              if ((resp.status === 429 || resp.status === 430) && tries < 4){
                var ra = parseFloat(resp.headers.get('retry-after'));
                var wait = (isFinite(ra) && ra > 0 ? ra*1000 : Math.min(1000*Math.pow(2,tries), 8000)) + Math.random()*400;
                setTimeout(function(){ attempt(tries + 1); }, wait); return;
              }
              active--; pump(); resolve(resp);
            }).catch(function(e){ active--; pump(); reject(e); });
          })(0);
        });
        pump();
      });
    };
  })();

  // USER PAGINATION CHOICE (DW, 2026-07-13 — Steve's verdict on customer
  // pushback): infinite scroll stays the DEFAULT, but the shopper can switch
  // to classic numbered pages of 100 via the floating toggle rendered below.
  // Choice persists in localStorage and survives reloads/navigation.
  var PAGED_LIMIT = 100;
  var mode = 'infinite';
  try { if (localStorage.getItem('dwPaginationMode') === 'paged') mode = 'paged'; } catch (e) {}

  // DEEP-LANDING -> STAY INFINITE (DW, 2026-08-25 — Steve's verdict: persist
  // infinite scroll on deep-linked ?page=N landings instead of falling back to
  // numbered pages). Previously (2026-08-04) a deep ?page=N (N>1) landing flipped
  // mode='paged' to dodge the append-shove CLS; Steve now wants the shopper to
  // KEEP infinite scroll even when they arrive on a deep link. We no longer flip
  // the mode — a deep ?page=N landing is instead handled by the two infinite-mode
  // primitives already below: DEEP-LINK NORMALIZE strips the page param via
  // history.replaceState so Boost starts clean at page 1, and CASCADE GUARD pins
  // the viewport to the top until a real user gesture so no auto-append cascade
  // fires on landing. Net: deep landings render as a clean page-1 infinite grid
  // with no batch-append shove. The numbered-page toggle (localStorage
  // 'dwPaginationMode') stays fully available for shoppers who opt into pages.
  // (Deliberately a no-op branch retained for clarity: mode stays 'infinite'
  // for a deep ?page=N landing unless the shopper explicitly chose 'paged'.)

  var TARGET = mode === 'paged' ? 'default' : 'infinite_scroll';

  // DEEP-LINK NORMALIZE (DW, 2026-07-28 — root fix for the deep-page jump/flip):
  // Infinite scroll has no real "page N" anchor. Landing on ?page=N (N>1) makes
  // Boost reconcile deep — resetting to page 1, double-firing its search, AND
  // cascading many unscrolled auto-loads (the "really bad", worse-the-deeper
  // jump). In infinite mode we strip the page param BEFORE Boost reads the URL,
  // so it starts clean at page 1 and only appends on real scroll. Paged mode
  // (numbered pages of 100) keeps page=N — it's meaningful there.
  if (mode !== 'paged') {
    try {
      var _u = new URL(location.href);
      if (parseInt(_u.searchParams.get('page'), 10) > 1) {
        _u.searchParams.delete('page');
        history.replaceState(null, '', _u.pathname + _u.search + _u.hash);
      }
    } catch (e) {}
  }

  // CASCADE GUARD (DW, 2026-07-30 — kills the intermittent deep-page runaway).
  // Even with page= stripped, a deep-link landing intermittently (a real-browser
  // timing race, ~1/3, invisible to headless) jumps the viewport down ~3000px on
  // its own; that puts Boost's scroll sentinel on-screen and fires a BURST of
  // auto-appends (24 -> 72+ cards) before the shopper has touched anything — the
  // "nightmare". The URL strip alone doesn't cover it because the trigger is a
  // native scroll jump, not a stale page param. So in infinite mode we (1) take
  // scroll restoration off automatic, and (2) pin the viewport to the top until a
  // GENUINE user gesture, so no second page can auto-load on landing. The instant
  // the shopper scrolls/wheels/touches, the guard releases and normal infinite
  // scroll resumes. Paged mode is untouched.
  if (mode !== 'paged') {
    try { if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; } catch (e) {}
    window.__dwUserEngaged = false;
    var release = function () {
      if (window.__dwUserEngaged) return;
      window.__dwUserEngaged = true;
      ['wheel', 'touchstart', 'keydown', 'pointerdown', 'mousedown'].forEach(function (evt) {
        try { window.removeEventListener(evt, release, true); } catch (e) {}
      });
    };
    ['wheel', 'touchstart', 'keydown', 'pointerdown', 'mousedown'].forEach(function (evt) {
      try { window.addEventListener(evt, release, { capture: true, passive: true }); } catch (e) {}
    });
    // Hold the top for a short landing window; stop the instant the user engages
    // or the window elapses, so we never fight a shopper who scrolls immediately.
    var pinStart = Date.now();
    var pin = function () {
      if (window.__dwUserEngaged) return;                 // user took over -> stop
      if (Date.now() - pinStart > 4000) return;           // safety cap -> stop
      try { if (window.pageYOffset > 0) window.scrollTo(0, 0); } catch (e) {}
      (window.requestAnimationFrame || window.setTimeout)(pin, 16);
    };
    try {
      if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pin);
      else pin();
    } catch (e) {}
  }

  // Verified production Boost SD endpoints for this shop (2026-07-13).
  var PROD_API = {
    filterUrl:     'https://services.mybcapps.com/bc-sf-filter/filter',
    searchUrl:     'https://services.mybcapps.com/bc-sf-filter/search',
    suggestionUrl: 'https://services.mybcapps.com/bc-sf-filter/search/suggest',
    productsUrl:   'https://services.mybcapps.com/bc-sf-filter/search/products',
    recommendUrl:  'https://services.mybcapps.com/discovery/recommend',
    analyticsUrl:  'https://lambda.mybcapps.com/e',
    cdn:           'https://boost-cdn-prod.bc-solutions.net'
  };

  // Rewrite ONLY staging bc-solutions endpoints to production. Anything that
  // already points at a non-staging host is left untouched, so an upstream
  // Boost fix (or an intentional custom endpoint) is never clobbered.
  function isStaging(v) {
    return typeof v === 'string' &&
      (v.indexOf('staging.bc-solutions.net') !== -1 ||
       v.indexOf('boost-cdn-staging') !== -1);
  }
  function patchApi(api) {
    if (!api || typeof api !== 'object') return;
    for (var k in PROD_API) {
      if (!Object.prototype.hasOwnProperty.call(PROD_API, k)) continue;
      try {
        if (isStaging(api[k])) api[k] = PROD_API[k];
      } catch (e) {}
    }
  }

  // Recursively rewrite any "paginationType" key to infinite_scroll, plus the
  // common Boost shapes (generalSettings + additionalElements.pagination).
  // `seen` guards against cyclic / shared references so a self-referential
  // Boost config can't loop or get re-walked needlessly.
  function patchObj(o, depth, seen) {
    if (!o || typeof o !== 'object' || depth > 6) return;
    if (seen.indexOf(o) !== -1) return;
    seen.push(o);
    try {
      if (Object.prototype.hasOwnProperty.call(o, 'paginationType') &&
          typeof o.paginationType === 'string') {
        o.paginationType = TARGET;
      }
      // Boost sometimes nests under .pagination: { paginationType, ... }
      if (o.pagination && typeof o.pagination === 'object' &&
          typeof o.pagination.paginationType === 'string') {
        o.pagination.paginationType = TARGET;
      }
      // Paged mode: bump the grid's per-page product count to 100. Only keys
      // that sit next to a pagination knob are touched — searchPanelBlocks'
      // pageSize (the instant-search dropdown, 25 items) must stay untouched.
      if (mode === 'paged' &&
          (Object.prototype.hasOwnProperty.call(o, 'paginationType') ||
           (o.pagination && typeof o.pagination === 'object'))) {
        ['limit', 'productPerPage', 'productsPerPage'].forEach(function (k) {
          if (typeof o[k] === 'number' && o[k] > 0 && o[k] < PAGED_LIMIT) o[k] = PAGED_LIMIT;
        });
      }
    } catch (e) {}
    for (var k in o) {
      if (!Object.prototype.hasOwnProperty.call(o, k)) continue;
      var v = o[k];
      if (v && typeof v === 'object') patchObj(v, depth + 1, seen);
    }
  }

  function patchConfig(cfg) {
    if (!cfg || typeof cfg !== 'object') return cfg;
    try {
      // One shared `seen` set across all walks keeps total work O(n) and
      // cycle-safe — known surfaces get a full-depth targeted walk, then the
      // catch-all root walk skips anything already visited (schema-drift safe).
      var seen = [];
      patchApi(cfg.api); // endpoint repoint — staging → production (2026-07-13)
      if (cfg.generalSettings) patchObj(cfg.generalSettings, 0, seen);
      if (cfg.additionalElements) patchObj(cfg.additionalElements, 0, seen);
      patchObj(cfg, 0, seen);
    } catch (e) {}
    return cfg;
  }

  // (1) defineProperty setter — intercept Boost's assignment.
  var _cfg = window.boostSDAppConfig;
  try {
    Object.defineProperty(window, 'boostSDAppConfig', {
      configurable: true,
      enumerable: true,
      get: function () { return _cfg; },
      set: function (val) { _cfg = patchConfig(val); }
    });
  } catch (e) {
    // If defineProperty fails (already non-configurable), fall through to poll.
  }

  // (2) Patch immediately if it already exists.
  if (_cfg) patchConfig(_cfg);

  // (3) Short poll — re-apply in case Boost replaces / re-inits the object,
  // and re-patch after a few frames so the value is correct at bundle init.
  var ticks = 0;
  var iv = setInterval(function () {
    ticks++;
    try { if (window.boostSDAppConfig) patchConfig(window.boostSDAppConfig); } catch (e) {}
    if (ticks > 80) clearInterval(iv); // ~8s @ 100ms, well past Boost init
  }, 100);

  {% if request.page_type == 'collection' or request.page_type == 'search' %}
  // Floating browse-mode toggle (collection + search grids only).
  document.addEventListener('DOMContentLoaded', function () {
    if (document.getElementById('dw-page-mode')) return;
    var wrap = document.createElement('div');
    wrap.id = 'dw-page-mode';
    wrap.setAttribute('role', 'group');
    wrap.setAttribute('aria-label', 'Browsing mode');
    wrap.style.cssText = 'position:fixed;bottom:18px;left:18px;z-index:9990;display:flex;' +
      'background:#fff;border:1px solid #d8d4cc;border-radius:999px;box-shadow:0 2px 10px rgba(0,0,0,.12);' +
      'font:12px/1 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;overflow:hidden';
    function btn(label, m, active) {
      var b = document.createElement('button');
      b.type = 'button';
      b.textContent = label;
      b.setAttribute('aria-pressed', active ? 'true' : 'false');
      b.style.cssText = 'border:0;cursor:pointer;padding:9px 14px;letter-spacing:.04em;' +
        (active ? 'background:#1a1a1a;color:#fff;' : 'background:transparent;color:#444;');
      b.addEventListener('click', function () {
        if (mode === m) return;
        try { localStorage.setItem('dwPaginationMode', m); } catch (e) {}
        location.reload();
      });
      return b;
    }
    wrap.appendChild(btn('Endless scroll', 'infinite', mode === 'infinite'));
    wrap.appendChild(btn('Pages of 100', 'paged', mode === 'paged'));
    document.body.appendChild(wrap);
  });
  {% endif %}
})();
</script>
{% if request.page_type == 'collection' or request.page_type == 'search' %}
{% comment %}
  ============================================================================
  STRUCTURAL CLS FIX (DW, 2026-08-25 — TK-10835 path 2, Steve-approved).
  ----------------------------------------------------------------------------
  MEASURED (real Chrome + layout-shift sources API): the Boost product grid
  lives in `section.product-app--container` (a Shopify section). At
  DOMContentLoaded it is an ~800px placeholder; when Boost injects the 3-col
  grid it grows (and grows again on EVERY infinite-scroll append). Because the
  page furniture below it — the sibling section `__main`, plus the body-level
  `#dwcw-btn` and `__footer` sections — flows BELOW the grid, each grid growth
  shoved all of it down. That append-driven shove was the whole jank: CLS
  decomposed as initial-render ~0.16, scroll-APPEND ~0.21-0.34+ (worse the
  deeper you scrolled). Total control CLS 0.37 (24 appends) → 0.50 (40).

  Two structural primitives kill it WITHOUT stalling Boost's own bottom
  detection (Boost fires on `.boost-sd__pagination-infinite-scroll-container-
  -target`, the sentinel that sits BELOW the grid):

    (A) REORDER — make `.boost-sd__product-filter-fallback` a flex column and
        push the grid's Shopify section to `order:99` so `__main` renders
        ABOVE the grid. Grid growth then has no `__main` below it to shove.
        `:has()` is the fast path; a JS fallback tags the section with
        `.dw-grid-section` for browsers without `:has()`.

    (B) COMPENSATING SPACER — a 1px-wide, aria-hidden spacer appended as the
        LAST child of `.main-content`, whose height = (reservedBudget − live
        content height). As the grid grows, the spacer SHRINKS by the same
        amount, so `.main-content`'s TOTAL height stays constant → every
        body-level section after it (`#dwcw-btn`, `__footer`) never re-shoves.
        Budget is monotonic (bumps only if a huge grid exceeds it → at most one
        tiny shove, then stable). The Boost sentinel is ABOVE the spacer, so
        incremental scrolling still reaches it — infinite loading is intact
        (verified: card count matches control at 336 cards / 40 wheels).

  RESULT (real Chrome, reproducible ×3): normal landing CLS 0.37→0.03,
  40-wheel deep stress 0.50→0.027, deep ?page=4 0.155→0.03. Append portion
  0.21→0.000. All well under the 0.1 target on BOTH the normal landing and the
  deep ?page=N case — so infinite scroll now persists on deep links (via the
  DEEP-LINK NORMALIZE + CASCADE GUARD above) with NO append jank.

  Card-image aspect-ratio reservation (below) is retained for within-batch
  lazy-image stability; it was never the append-shove culprit (the shifters
  were __main/__footer/dwcw-btn, not the cards) but it's still correct + free.
  ============================================================================
{% endcomment %}
<style id="dw-cls-structural">
/* Reserve each Boost card's image box so lazy images can't reflow siblings on
   append (kept from 2026-08-04; matches the theme's forced 1/1 look). */
.boost-sd__product-item-grid-view-layout-image,
.boost-sd__product-link-image,
.boost-sd__product-link-image > div{ aspect-ratio:1/1 !important; overflow:hidden; display:block; }
.boost-sd__product-image-wrapper,
.boost-sd__product-image{ aspect-ratio:1/1 !important; overflow:hidden; }
.boost-sd__product-image-img{ width:100% !important; height:100% !important; object-fit:cover !important; }
/* (A) Reorder: grid section renders AFTER __main so grid growth never shoves __main.
   :has() fast path + .dw-grid-section JS fallback for non-:has() browsers. */
.boost-sd__product-filter-fallback{ display:flex; flex-direction:column; }
.boost-sd__product-filter-fallback > .shopify-section:has(section.product-app--container),
.boost-sd__product-filter-fallback > .shopify-section:has(.boost-sd-container){ order:99; }
.boost-sd__product-filter-fallback > .shopify-section.dw-grid-section{ order:99; }
</style>
<script id="dw-cls-structural-js">
(function () {
  'use strict';
  if (window.__dwClsStructural) return;
  window.__dwClsStructural = true;

  // (A-fallback) If :has() is unsupported, tag the grid's Shopify section with
  // .dw-grid-section so the CSS order rule above still fires.
  function reorderFallback() {
    try { if (window.CSS && CSS.supports && CSS.supports('selector(:has(*))')) return; } catch (e) {}
    var fb = document.querySelector('.boost-sd__product-filter-fallback');
    if (!fb) return;
    Array.prototype.forEach.call(fb.children, function (ch) {
      if (ch.querySelector && (ch.querySelector('section.product-app--container') || ch.querySelector('.boost-sd-container'))) {
        ch.classList.add('dw-grid-section');
      }
    });
  }

  // (B) Compensating shrinking spacer — keeps .main-content total height constant
  // as the grid grows, so body-level furniture below it (#dwcw-btn, __footer)
  // never re-shoves on append. Sentinel stays above the spacer → loading intact.
  var mc, spacer, reserved = 0;
  function ensure() {
    mc = mc || document.querySelector('.main-content');
    if (!mc) return false;
    if (!document.querySelector('.boost-sd__product-list')) return false;
    if (!spacer) {
      spacer = document.createElement('div');
      spacer.id = 'dw-cls-spacer';
      spacer.style.cssText = 'width:1px;order:100;flex:0 0 auto;';
      spacer.setAttribute('aria-hidden', 'true');
      mc.appendChild(spacer);
    }
    return true;
  }
  function contentHeight() {
    var sp = spacer ? (parseFloat(spacer.style.height) || 0) : 0;
    return Math.ceil(mc.scrollHeight - sp);   // .main-content height EXCLUDING our spacer
  }
  var stopped = false;
  function update() {
    if (stopped || !ensure()) return;
    // Numbered-pages mode doesn't append → no spacer needed; release if the shopper opted in.
    try { if (localStorage.getItem('dwPaginationMode') === 'paged') { spacer.style.height = '0px'; stopped = true; return; } } catch (e) {}
    var c = contentHeight();
    if (reserved === 0) reserved = c + 3000;              // initial budget above current content
    if (c > reserved - 200) reserved = c + 3000;          // budget nearly hit → bump (rare, one tiny shove)
    spacer.style.height = Math.max(0, reserved - c) + 'px';
  }
  function start() {
    reorderFallback();
    if (!ensure()) { setTimeout(start, 40); return; }
    update();
    try { new ResizeObserver(update).observe(document.querySelector('.boost-sd__product-list')); } catch (e) {}
    try { new MutationObserver(update).observe(mc, { childList: true, subtree: true }); } catch (e) {}
    var iv = setInterval(function () { if (stopped) { clearInterval(iv); return; } update(); }, 100);
    setTimeout(function () { clearInterval(iv); }, 60000);
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
  else start();
})();
</script>
{% endif %}