← back to Dw Theme Boost Fix
Fix new-arrivals scroll-jump: deep ?page=N->paged + anti-collapse image-box CSS (CLS 1.9->0.12 on page 7)
04227c1332e345c39210a7b30c00801dc3205d8a · 2026-08-04 10:29:31 -0700 · Steve Abrams
Files touched
A boost-infinite-override.liquid
Diff
commit 04227c1332e345c39210a7b30c00801dc3205d8a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 10:29:31 2026 -0700
Fix new-arrivals scroll-jump: deep ?page=N->paged + anti-collapse image-box CSS (CLS 1.9->0.12 on page 7)
---
boost-infinite-override.liquid | 384 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 384 insertions(+)
diff --git a/boost-infinite-override.liquid b/boost-infinite-override.liquid
new file mode 100644
index 0000000..d27b381
--- /dev/null
+++ b/boost-infinite-override.liquid
@@ -0,0 +1,384 @@
+{% 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 20260804-1030 */
+(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 -> PAGED (DW, 2026-08-04 — residual scroll-jump fix, Steve-approved).
+ // A deep-linked ?page=N (N>1) has no stable anchor in infinite scroll, and its
+ // batch-append near the fold shoves the footer/main (~0.8 CLS = "images jump").
+ // Rendering deep landings as classic paged (100/page) has ZERO append shove.
+ // NOT persisted -> a normal landing (no page param) keeps infinite as the default
+ // per Steve's 2026-07-13 verdict; only genuine deep ?page=N links go paged.
+ if (mode === 'infinite') {
+ try { if (parseInt(new URL(location.href).searchParams.get('page'), 10) > 1) mode = 'paged'; } catch (e) {}
+ }
+
+ 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' %}
+<style id="dw-anti-collapse">
+/* DW 2026-08-04: reserve each Boost card's image box from inject-time (up the full
+ a > div > wrapper chain) so lazy images can't reflow siblings on infinite-scroll
+ append. Matches the theme's existing forced 1/1 card look (no visual change). */
+.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; }
+</style>
+{% endif %}
← 62e4c65 fix: cascade-guard for deep-page infinite-scroll runaway (Ho
·
back to Dw Theme Boost Fix
·
TK-10629: de-leak Versa Designed Surfaces -> Hollywood Contr 2e45e29 →