← back to Secrets Manager
dw-scroll-debug.js
481 lines
/**
* dw-scroll-debug.js
* Headless Playwright investigation of the infinite-scroll glitch on
* https://www.designerwallcoverings.com/collections/decoglass-wallpaper?page=6
*
* Instruments:
* - dw-card-hover.js run() fire count (via page.evaluate injection)
* - .dw-hover-overlay duplicate detection per card
* - MutationObserver callback count
* - Performance long-tasks (PerformanceObserver)
* - Boost network requests (XHR/fetch) for infinite-scroll API calls
* - Card count growth across scroll steps
* - Console errors
*/
const { chromium } = require('playwright');
const path = require('path');
const TARGET_URL = 'https://www.designerwallcoverings.com/collections/decoglass-wallpaper?page=6';
const SCROLL_STEPS = 12;
const SCROLL_PAUSE_MS = 2500; // wait between scrolls for Boost to load
const VIEWPORT = { width: 1440, height: 900 };
(async () => {
const browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
],
});
const context = await browser.newContext({
viewport: VIEWPORT,
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
extraHTTPHeaders: {
'Accept-Language': 'en-US,en;q=0.9',
},
});
const page = await context.newPage();
// ── Capture console messages ──────────────────────────────────────────────
const consoleErrors = [];
const consoleWarnings = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
if (msg.type() === 'warning') consoleWarnings.push(msg.text());
});
// ── Capture network activity ──────────────────────────────────────────────
const boostRequests = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('boost') || url.includes('filter') || url.includes('search') || url.includes('product')) {
boostRequests.push({ url: url.substring(0, 150), method: req.method(), type: req.resourceType() });
}
});
const networkErrors = [];
page.on('requestfailed', (req) => {
networkErrors.push({ url: req.url().substring(0, 120), err: req.failure()?.errorText });
});
let httpStatus = null;
page.on('response', (resp) => {
if (resp.url() === TARGET_URL || resp.url().startsWith(TARGET_URL.split('?')[0])) {
httpStatus = resp.status();
}
});
console.log('=== DW Infinite-Scroll Debug ===');
console.log(`Target: ${TARGET_URL}`);
console.log(`Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`);
console.log(`Scroll steps: ${SCROLL_STEPS}, pause: ${SCROLL_PAUSE_MS}ms each\n`);
// ── Navigate ──────────────────────────────────────────────────────────────
console.log('[1] Navigating to page...');
try {
const resp = await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
httpStatus = resp?.status();
console.log(` HTTP status: ${httpStatus}`);
if (httpStatus === 429) {
console.log(' [RATE-LIMITED 429] Waiting 15s before retry...');
await page.waitForTimeout(15000);
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
} catch (e) {
console.log(` Navigation error: ${e.message}`);
}
// ── Wait for Boost product cards ──────────────────────────────────────────
console.log('[2] Waiting for .boost-sd__product-item cards...');
let initialCardCount = 0;
try {
await page.waitForSelector('.boost-sd__product-item', { timeout: 20000 });
initialCardCount = await page.locator('.boost-sd__product-item').count();
console.log(` Initial card count: ${initialCardCount}`);
} catch (e) {
console.log(` Could not find .boost-sd__product-item: ${e.message}`);
// Check for alternate selectors
const alt1 = await page.locator('.product-list-item').count();
const alt2 = await page.locator('[data-product-id]').count();
console.log(` .product-list-item: ${alt1}, [data-product-id]: ${alt2}`);
const html = await page.content();
const snippet = html.substring(0, 3000);
console.log(` Page HTML snippet: ${snippet}`);
}
// ── Wait for dw-card-hover.js to initialize ───────────────────────────────
console.log('[3] Waiting for dw-card-hover.js to initialize...');
await page.waitForTimeout(2000);
const dwHoverActive = await page.evaluate(() => !!window.__dwCardHover);
console.log(` window.__dwCardHover present: ${dwHoverActive}`);
// ── Instrument: patch run() to count calls ────────────────────────────────
console.log('[4] Patching run() counter and MutationObserver counter...');
const instrumentResult = await page.evaluate(() => {
// We cannot directly wrap the private run() function inside the IIFE.
// Instead, we instrument the MutationObserver callback indirectly:
// 1. Count how many times .dw-hover-overlay nodes are INSERTED (net new)
// which is a proxy for run() doing work on new cards.
// 2. Count MutationObserver callbacks firing on document.body.
window.__dwRunCount = 0;
window.__dwOverlayInsertions = 0;
window.__mutObsCallbackCount = 0;
window.__longTaskCount = 0;
window.__longTaskTotalMs = 0;
// Count overlay insertions via a second MutationObserver
const overlayWatcher = new MutationObserver((muts) => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('dw-hover-overlay')) {
window.__dwOverlayInsertions++;
}
// Also count overlays inside added subtrees
if (node.querySelectorAll) {
window.__dwOverlayInsertions += node.querySelectorAll('.dw-hover-overlay').length;
}
}
}
}
});
overlayWatcher.observe(document.body, { childList: true, subtree: true });
// Measure long tasks
try {
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
window.__longTaskCount++;
window.__longTaskTotalMs += entry.duration;
}
});
po.observe({ entryTypes: ['longtask'] });
} catch (e) { /* longtask not supported */ }
// Count body-level MutationObserver firings indirectly:
// Wrap MutationObserver constructor to intercept calls on document.body
// This needs to be done BEFORE dw-card-hover.js runs — too late now.
// Instead, we count body childList mutations ourselves.
const bodyMutWatcher = new MutationObserver((muts) => {
window.__mutObsCallbackCount++;
});
bodyMutWatcher.observe(document.body, { childList: true, subtree: true });
return { patched: true, overlayWatcher: 'installed', bodyMutWatcher: 'installed' };
});
console.log(` Instrumentation: ${JSON.stringify(instrumentResult)}`);
// ── Baseline overlay counts ────────────────────────────────────────────────
console.log('[5] Baseline card and overlay counts...');
const baseline = await page.evaluate(() => {
const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
let withOverlay = 0, withMultipleOverlays = 0;
for (const card of cards) {
const overlays = card.querySelectorAll('.dw-hover-overlay');
// Also check siblings (v5 places overlay as sibling, not inside card)
const parent = card.parentElement;
const siblingOverlays = parent ? parent.querySelectorAll(':scope > .dw-hover-overlay') : [];
const total = overlays.length + siblingOverlays.length;
if (total >= 1) withOverlay++;
if (total > 1) withMultipleOverlays++;
}
// Count all .dw-hover-overlay in the document
const allOverlays = document.querySelectorAll('.dw-hover-overlay').length;
return {
cards: cards.length,
withOverlay,
withMultipleOverlays,
allOverlays,
overlaysInDom: allOverlays
};
});
console.log(` Cards: ${baseline.cards}, with overlay: ${baseline.withOverlay}, duplicates (>1): ${baseline.withMultipleOverlays}`);
console.log(` Total .dw-hover-overlay in DOM: ${baseline.allOverlays}`);
// ── Scroll steps ──────────────────────────────────────────────────────────
console.log(`[6] Scrolling ${SCROLL_STEPS} times to trigger infinite-scroll...`);
const snapshots = [];
for (let step = 0; step < SCROLL_STEPS; step++) {
// Scroll to bottom
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(SCROLL_PAUSE_MS);
// Capture state
const snap = await page.evaluate((stepNum) => {
const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
let withOverlay = 0, withMultipleOverlays = 0, noOverlay = 0;
const dupCards = [];
for (const card of cards) {
// v5 places overlay as a sibling AFTER the image column, so check
// both inside the card and as an immediate sibling
const insideOverlays = card.querySelectorAll('.dw-hover-overlay').length;
// Traverse parent to find sibling overlays adjacent to this card
let sibCount = 0;
let sib = card.nextSibling;
while (sib) {
if (sib.nodeType === 1 && sib.classList && sib.classList.contains('dw-hover-overlay')) {
sibCount++;
}
sib = sib.nodeType === 1 ? null : sib.nextSibling; // stop at next element node
break; // only check first next sibling
}
// Also check inside card's image wrapper area
const total = insideOverlays + sibCount;
if (total >= 1) withOverlay++;
else noOverlay++;
if (total > 1 || insideOverlays > 1) {
withMultipleOverlays++;
dupCards.push(card.getAttribute('data-product-id') || card.className.substring(0, 40));
}
}
const allOverlays = document.querySelectorAll('.dw-hover-overlay').length;
return {
step: stepNum,
cardCount: cards.length,
withOverlay,
noOverlay,
withMultipleOverlays,
dupCards: dupCards.slice(0, 5),
allOverlays,
scrollY: window.scrollY,
docHeight: document.body.scrollHeight,
mutObsCallbacks: window.__mutObsCallbackCount,
overlayInsertions: window.__dwOverlayInsertions,
longTaskCount: window.__longTaskCount,
longTaskTotalMs: Math.round(window.__longTaskTotalMs),
};
}, step + 1);
console.log(
` Step ${step + 1}: cards=${snap.cardCount} overlays=${snap.allOverlays} ` +
`dups=${snap.withMultipleOverlays} noOverlay=${snap.noOverlay} ` +
`mutCBs=${snap.mutObsCallbacks} overlayInserts=${snap.overlayInsertions} ` +
`longTasks=${snap.longTaskCount}(${snap.longTaskTotalMs}ms) ` +
`scrollY=${snap.scrollY}/${snap.docHeight}`
);
if (snap.dupCards.length > 0) {
console.log(` DUPLICATE OVERLAY CARDS: ${snap.dupCards.join(', ')}`);
}
snapshots.push(snap);
// Check if we stopped getting new cards (stall detection)
if (step > 2 && snap.cardCount === snapshots[step - 1].cardCount && snap.scrollY > 0) {
console.log(` [STALL DETECTED] Card count unchanged at ${snap.cardCount} for 2 steps`);
}
}
// ── Final analysis ────────────────────────────────────────────────────────
console.log('\n[7] Final DOM analysis...');
const finalAnalysis = await page.evaluate(() => {
const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
const allOverlays = document.querySelectorAll('.dw-hover-overlay');
// Check for overlays outside of card contexts (orphaned)
let orphanedOverlays = 0;
for (const ov of allOverlays) {
const inCard = ov.closest('.boost-sd__product-item, .product-list-item');
// v5: overlay is sibling, walk up to see if it's near a card
if (!inCard) {
// Check previous sibling chain for a card
let prev = ov.previousElementSibling;
let foundCard = false;
while (prev && !foundCard) {
if (prev.matches('.boost-sd__product-item, .product-list-item')) foundCard = true;
// Check inside prev for an image layout wrapper
if (prev.matches('.boost-sd__product-item-grid-view-layout-image')) foundCard = true;
prev = prev.previousElementSibling;
}
if (!foundCard) orphanedOverlays++;
}
}
// Count per-card overlays using the v5 sibling placement logic
const cardOverlayCounts = {};
for (const card of cards) {
const pid = card.getAttribute('data-product-id') ||
card.getAttribute('data-id') ||
Array.from(card.classList).find(c => c.includes('boost')) || 'unknown';
// Inside the card
let count = card.querySelectorAll('.dw-hover-overlay').length;
// v5 places overlay after the image-col sibling; the overlay is a child
// of the card's parent (the grid item row/wrapper), not inside the card itself.
// Let's check by scanning ALL overlays and seeing how many follow THIS card
// in the DOM parent.
const parent = card.parentElement;
if (parent) {
const children = Array.from(parent.children);
const cardIdx = children.indexOf(card);
// Count consecutive .dw-hover-overlay siblings right after card
for (let i = cardIdx + 1; i < children.length; i++) {
if (children[i].classList.contains('dw-hover-overlay')) count++;
else break;
}
}
cardOverlayCounts[pid] = count;
}
const duplicatedCards = Object.entries(cardOverlayCounts).filter(([, c]) => c > 1);
const zeroOverlayCards = Object.entries(cardOverlayCounts).filter(([, c]) => c === 0);
// Check if Boost infinite scroll sentinel/loader exists
const loaders = document.querySelectorAll(
'[class*="boost-sd__load-more"], [class*="boost-sd__infinite"], [class*="load-more"], .boost-sd__button-load-more'
);
// Check for any CSS that might block scroll
const bodyOverflow = window.getComputedStyle(document.body).overflow;
const htmlOverflow = window.getComputedStyle(document.documentElement).overflow;
return {
finalCardCount: cards.length,
finalOverlayCount: allOverlays.length,
orphanedOverlays,
duplicatedCards: duplicatedCards.slice(0, 10).map(([id, c]) => `${id}:${c}`),
duplicatedCardCount: duplicatedCards.length,
zeroOverlayCardCount: zeroOverlayCards.length,
boostLoaderElements: loaders.length,
boostLoaderClasses: Array.from(loaders).map(el => el.className).slice(0, 5),
bodyOverflow,
htmlOverflow,
mutObsCallbacks: window.__mutObsCallbackCount,
overlayInsertions: window.__dwOverlayInsertions,
longTaskCount: window.__longTaskCount,
longTaskTotalMs: Math.round(window.__longTaskTotalMs),
dwHoverActive: window.__dwCardHover,
};
});
console.log('\n=== FINAL ANALYSIS ===');
console.log(`Cards in DOM: ${finalAnalysis.finalCardCount}`);
console.log(`Total .dw-hover-overlay in DOM: ${finalAnalysis.finalOverlayCount}`);
console.log(`Orphaned overlays (not near any card): ${finalAnalysis.orphanedOverlays}`);
console.log(`Cards with DUPLICATE overlays: ${finalAnalysis.duplicatedCardCount}`);
if (finalAnalysis.duplicatedCards.length > 0) {
console.log(` Duplicate samples: ${finalAnalysis.duplicatedCards.join(', ')}`);
}
console.log(`Cards with ZERO overlays: ${finalAnalysis.zeroOverlayCardCount}`);
console.log(`Boost loader/sentinel elements: ${finalAnalysis.boostLoaderElements}`);
if (finalAnalysis.boostLoaderClasses.length > 0) {
console.log(` Loader classes: ${finalAnalysis.boostLoaderClasses.join(', ')}`);
}
console.log(`Body overflow: ${finalAnalysis.bodyOverflow}, HTML overflow: ${finalAnalysis.htmlOverflow}`);
console.log(`\nMutationObserver body callbacks: ${finalAnalysis.mutObsCallbacks}`);
console.log(`run() proxy (overlay insertions): ${finalAnalysis.overlayInsertions}`);
console.log(`Long tasks: ${finalAnalysis.longTaskCount} totaling ${finalAnalysis.longTaskTotalMs}ms`);
console.log(`window.__dwCardHover: ${finalAnalysis.dwHoverActive}`);
// ── Scroll growth analysis ────────────────────────────────────────────────
console.log('\n=== SCROLL GROWTH ANALYSIS ===');
let prevCards = baseline.cards;
let stalls = 0;
for (const snap of snapshots) {
const delta = snap.cardCount - prevCards;
const status = delta > 0 ? `+${delta} new cards` : (delta === 0 ? 'STALL - no new cards' : `SHRINK by ${Math.abs(delta)}`);
const ratio = snap.allOverlays > 0 ? (snap.allOverlays / snap.cardCount).toFixed(2) : '0.00';
console.log(` Step ${snap.step}: ${snap.cardCount} cards (${status}), ${snap.allOverlays} overlays, ratio=${ratio}, longTasks=${snap.longTaskCount}(${snap.longTaskTotalMs}ms)`);
if (delta === 0) stalls++;
prevCards = snap.cardCount;
}
// ── MutationObserver thrash analysis ─────────────────────────────────────
console.log('\n=== MUTATION OBSERVER ANALYSIS ===');
const finalSnap = snapshots[snapshots.length - 1];
console.log(`Total body MutationObserver callbacks fired: ${finalSnap.mutObsCallbacks}`);
console.log(`Total overlay insertions by run(): ${finalSnap.overlayInsertions}`);
if (finalSnap.mutObsCallbacks > 0 && finalAnalysis.finalCardCount > 0) {
const callsPerCard = (finalSnap.mutObsCallbacks / finalAnalysis.finalCardCount).toFixed(1);
console.log(`Average MutObs callbacks per card: ${callsPerCard}`);
// Each run() call processes ALL cards and inserts overlays for new ones.
// If run() fires N times and inserts M overlays, N > M suggests many no-op re-scans.
const efficiency = finalSnap.overlayInsertions > 0
? (finalSnap.mutObsCallbacks / finalSnap.overlayInsertions).toFixed(1)
: 'N/A';
console.log(`Callback:overlay-insertion ratio: ${efficiency}:1 (higher = more wasted re-scans)`);
}
// ── SELF-REINFORCING LOOP DETECTION ──────────────────────────────────────
console.log('\n=== SELF-REINFORCING LOOP DETECTION ===');
console.log('Key question: Does run() inserting .dw-hover-overlay nodes trigger MORE observer callbacks?');
console.log('(If yes, each overlay insertion fires the observer, which calls run() again,');
console.log(' which already skips labeled cards — but still does a full querySelectorAll scan)');
console.log(`Cards at baseline: ${baseline.cards}`);
console.log(`Overlays inserted by run(): ${finalSnap.overlayInsertions}`);
console.log(`Observer callbacks fired: ${finalSnap.mutObsCallbacks}`);
if (finalSnap.overlayInsertions > 0 && finalSnap.mutObsCallbacks > finalSnap.overlayInsertions * 1.5) {
console.log('FINDING: Observer callbacks >> overlay insertions — observer is firing for NON-run()-insertions too');
console.log(' This means Boost DOM mutations are also triggering the observer, causing repeated full-grid scans');
}
// ── Console errors ────────────────────────────────────────────────────────
if (consoleErrors.length > 0) {
console.log(`\n=== CONSOLE ERRORS (${consoleErrors.length}) ===`);
consoleErrors.slice(0, 10).forEach((e, i) => console.log(` [${i + 1}] ${e}`));
}
if (consoleWarnings.length > 0) {
console.log(`\n=== CONSOLE WARNINGS (${consoleWarnings.length}) ===`);
consoleWarnings.slice(0, 5).forEach((e, i) => console.log(` [${i + 1}] ${e}`));
}
if (networkErrors.length > 0) {
console.log(`\n=== NETWORK ERRORS (${networkErrors.length}) ===`);
networkErrors.slice(0, 5).forEach((e, i) => console.log(` [${i + 1}] ${e.url} — ${e.err}`));
}
// ── Boost API requests ────────────────────────────────────────────────────
const boostApiReqs = boostRequests.filter(r => r.url.includes('boost') && r.type === 'fetch' || r.url.includes('search') || r.url.includes('filter'));
if (boostApiReqs.length > 0) {
console.log(`\n=== BOOST API REQUESTS (${boostApiReqs.length}) ===`);
boostApiReqs.slice(0, 10).forEach((r, i) => console.log(` [${i + 1}] ${r.method} ${r.url}`));
}
// ── Final verdict ─────────────────────────────────────────────────────────
console.log('\n=== ROOT CAUSE VERDICT ===');
const overlapRatio = finalAnalysis.finalOverlayCount / Math.max(finalAnalysis.finalCardCount, 1);
const hasOverlayDuplicates = finalAnalysis.duplicatedCardCount > 0;
const hasMutThrash = finalSnap.mutObsCallbacks > 200;
const hasLongTasks = finalAnalysis.longTaskCount > 5;
const cardGrowthStopped = stalls >= 3;
if (hasOverlayDuplicates) {
console.log(`CONFIRMED: ${finalAnalysis.duplicatedCardCount} cards have DUPLICATE .dw-hover-overlay elements`);
console.log(' Root cause: run() is inserting overlays faster than its duplicate-check catches');
console.log(' OR: labelCard() sibling-insertion logic places overlay outside the card,');
console.log(' so card.querySelector(".dw-hover-overlay") returns null on next run() call');
console.log(' (the guard `if (card.querySelector(".dw-hover-overlay")) return;` misses sibling overlays)');
}
if (hasMutThrash) {
console.log(`CONFIRMED: MutationObserver fired ${finalSnap.mutObsCallbacks} times during scrolling`);
console.log(' Each fire schedules a full document.querySelectorAll() scan with 80ms debounce.');
console.log(' With Boost appending dozens of nodes per infinite-scroll batch, this fires continuously.');
}
if (hasLongTasks) {
console.log(`CONFIRMED: ${finalAnalysis.longTaskCount} long tasks (>${finalAnalysis.longTaskTotalMs}ms total)`);
console.log(' Long tasks block scroll-event processing, causing visible jank.');
}
if (cardGrowthStopped) {
console.log(`CONFIRMED: Infinite scroll STALLED — card count stopped growing for ${stalls} consecutive steps`);
}
if (!hasOverlayDuplicates && !hasMutThrash && !cardGrowthStopped) {
console.log('No dramatic failures found — glitch may be intermittent or require more scroll steps');
console.log(`Overlay/card ratio: ${overlapRatio.toFixed(2)} (should be ~1.0)`);
}
await browser.close();
console.log('\nDone.');
})();