nodebb-plugin-ezoic-infinite 1.5.47 → 1.5.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.47",
3
+ "version": "1.5.49",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -14,66 +14,32 @@
14
14
  const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
15
15
  const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
16
16
 
17
+ // When the user scrolls very fast, temporarily preload more aggressively.
18
+ // This helps ensure ads are already in-flight before the user reaches them.
19
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
20
+ const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
21
+ const BOOST_DURATION_MS = 2500;
22
+ const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
23
+
17
24
  const MAX_INFLIGHT_DESKTOP = 4;
18
25
  const MAX_INFLIGHT_MOBILE = 3;
19
26
 
20
-
21
- // Adaptive performance profile (device/network aware)
22
- const PERF_DEFAULTS = Object.freeze({
23
- maxInflightDesktop: MAX_INFLIGHT_DESKTOP,
24
- maxInflightMobile: MAX_INFLIGHT_MOBILE,
25
- maxInsertsPerRun: MAX_INSERTS_PER_RUN,
26
- });
27
-
28
- function getPerfProfile() {
29
- // Cache result for this pageview; recomputed on navigation via cleanup()
30
- if (state.perfProfile) return state.perfProfile;
31
- const p = {
32
- maxInflightDesktop: PERF_DEFAULTS.maxInflightDesktop,
33
- maxInflightMobile: PERF_DEFAULTS.maxInflightMobile,
34
- maxInsertsPerRun: PERF_DEFAULTS.maxInsertsPerRun,
35
- };
36
-
37
- try {
38
- const mem = typeof navigator !== 'undefined' ? navigator.deviceMemory : undefined; // GB
39
- const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined;
40
-
41
- // Conservative tuning for low-end devices
42
- if (typeof mem === 'number' && mem > 0 && mem <= 2) {
43
- p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
44
- p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
45
- p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
46
- }
47
- if (typeof cores === 'number' && cores > 0 && cores <= 4) {
48
- p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
49
- p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
50
- p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
51
- }
52
-
53
- const conn = typeof navigator !== 'undefined' ? navigator.connection : undefined;
54
- const eff = conn && conn.effectiveType ? String(conn.effectiveType).toLowerCase() : '';
55
- // Slow connections: don't ramp concurrency too high (keeps page responsive).
56
- if (eff.includes('2g')) {
57
- p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
58
- p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
59
- }
60
- } catch (e) {}
61
-
62
- state.perfProfile = p;
63
- return p;
64
- }
27
+ function isBoosted() {
28
+ try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
29
+ }
65
30
 
66
31
  function isMobile() {
67
32
  try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
68
33
  }
69
34
 
70
35
  function getPreloadRootMargin() {
71
- return isMobile() ? PRELOAD_MARGIN_MOBILE : PRELOAD_MARGIN_DESKTOP;
36
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
37
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
72
38
  }
73
39
 
74
40
  function getMaxInflight() {
75
- const perf = getPerfProfile();
76
- return isMobile() ? perf.maxInflightMobile : perf.maxInflightDesktop;
41
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
42
+ return base + (isBoosted() ? 1 : 0);
77
43
  }
78
44
 
79
45
  const SELECTORS = {
@@ -82,31 +48,6 @@ function getPerfProfile() {
82
48
  categoryItem: 'li[component="categories/category"]',
83
49
  };
84
50
 
85
- const RELEVANT_MATCHERS = [
86
- SELECTORS.postItem,
87
- SELECTORS.topicItem,
88
- SELECTORS.categoryItem,
89
- ];
90
-
91
- function mutationHasRelevantAddedNodes(mutations) {
92
- try {
93
- for (const m of mutations) {
94
- if (!m || !m.addedNodes || !m.addedNodes.length) continue;
95
- for (const n of m.addedNodes) {
96
- if (!n || n.nodeType !== 1) continue;
97
- const el = /** @type {Element} */ (n);
98
- for (const sel of RELEVANT_MATCHERS) {
99
- if (el.matches && el.matches(sel)) return true;
100
- if (el.querySelector && el.querySelector(sel)) return true;
101
- }
102
- }
103
- }
104
- } catch (e) {}
105
- return false;
106
- }
107
-
108
-
109
-
110
51
  // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
111
52
  let blockedUntil = 0;
112
53
  function isBlocked() {
@@ -117,7 +58,6 @@ function mutationHasRelevantAddedNodes(mutations) {
117
58
  pageKey: null,
118
59
  cfg: null,
119
60
 
120
- navSeq: 0,
121
61
  // Full lists (never consumed) + cursors for round-robin reuse
122
62
  allTopics: [],
123
63
  allPosts: [],
@@ -136,8 +76,6 @@ function mutationHasRelevantAddedNodes(mutations) {
136
76
 
137
77
  // observers / schedulers
138
78
  domObs: null,
139
- tightenObs: null,
140
- fillObs: null,
141
79
  io: null,
142
80
  runQueued: false,
143
81
 
@@ -145,6 +83,11 @@ function mutationHasRelevantAddedNodes(mutations) {
145
83
  inflight: 0,
146
84
  pending: [],
147
85
  pendingSet: new Set(),
86
+
87
+ // fast scroll boosting
88
+ scrollBoostUntil: 0,
89
+ lastScrollY: 0,
90
+ lastScrollTs: 0,
148
91
  ioMargin: null,
149
92
 
150
93
  // hero)
@@ -152,6 +95,29 @@ function mutationHasRelevantAddedNodes(mutations) {
152
95
  };
153
96
 
154
97
  const insertingIds = new Set();
98
+
99
+
100
+ function markEmptyWrapper(id) {
101
+ try {
102
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
103
+ if (!ph || !ph.isConnected) return;
104
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
105
+ if (!wrap) return;
106
+ // If still empty after a delay, collapse it.
107
+ setTimeout(() => {
108
+ try {
109
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
110
+ if (!ph2 || !ph2.isConnected) return;
111
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
112
+ if (!w2) return;
113
+ // consider empty if only whitespace and no iframes/ins/img
114
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
115
+ if (!hasAd) w2.classList.add('is-empty');
116
+ } catch (e) {}
117
+ }, 3500);
118
+ } catch (e) {}
119
+ }
120
+
155
121
  // Production build: debug disabled
156
122
  function dbg() {}
157
123
 
@@ -266,7 +232,7 @@ function mutationHasRelevantAddedNodes(mutations) {
266
232
  const orig = ez.showAds;
267
233
 
268
234
  ez.showAds = function (...args) {
269
- if (isBlocked()) { return; }
235
+ if (isBlocked()) return;
270
236
 
271
237
  let ids = [];
272
238
  if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
@@ -295,124 +261,6 @@ function mutationHasRelevantAddedNodes(mutations) {
295
261
  }
296
262
  }
297
263
 
298
- // ---------- Ezoic min-height tightening (lightweight) ----------
299
- // Some Ezoic placements reserve a large min-height (e.g. 400px) even when
300
- // the rendered iframe/container is smaller (often 250px). That creates a
301
- // visible empty gap and can make creatives appear to "slide" inside the slot.
302
- // We correct ONLY those cases, without scroll listeners or full-page rescans.
303
-
304
- function getRenderedAdHeight(adSpan) {
305
- try {
306
- const c = adSpan.querySelector('div[id$="__container__"]');
307
- if (c && c.offsetHeight) return c.offsetHeight;
308
- const f = adSpan.querySelector('iframe');
309
- if (!f) return 0;
310
- const attr = parseInt(f.getAttribute('height') || '', 10);
311
- if (Number.isFinite(attr) && attr > 0) return attr;
312
- if (f.offsetHeight) return f.offsetHeight;
313
- } catch (e) {}
314
- return 0;
315
- }
316
-
317
- function tightenMinHeight(adSpan) {
318
- try {
319
- if (!adSpan || adSpan.nodeType !== 1) return;
320
- if (adSpan.tagName !== 'SPAN') return;
321
- if (!adSpan.classList || !adSpan.classList.contains('ezoic-ad')) return;
322
-
323
- // Some Ezoic templates apply sticky/fixed positioning inside the ad slot
324
- // (e.g. .ezads-sticky-intradiv) which can make the creative appear to
325
- // "slide" within an oversized container. Neutralize it inside the slot.
326
- try {
327
- const sticky = adSpan.querySelectorAll('.ezads-sticky-intradiv');
328
- sticky.forEach((el) => {
329
- el.style.setProperty('position', 'static', 'important');
330
- el.style.setProperty('top', 'auto', 'important');
331
- el.style.setProperty('bottom', 'auto', 'important');
332
- });
333
-
334
- // Safety net: any descendant that ends up sticky/fixed via inline style
335
- // (rare, but causes "floating" creatives).
336
- const positioned = adSpan.querySelectorAll('[style*="position: sticky"], [style*="position:sticky"], [style*="position: fixed"], [style*="position:fixed"]');
337
- positioned.forEach((el) => {
338
- el.style.setProperty('position', 'static', 'important');
339
- el.style.setProperty('top', 'auto', 'important');
340
- el.style.setProperty('bottom', 'auto', 'important');
341
- });
342
- } catch (e) {}
343
-
344
- const mhStr = adSpan.style && adSpan.style.minHeight ? String(adSpan.style.minHeight) : '';
345
- const mh = mhStr ? parseInt(mhStr, 10) : 0;
346
- if (!mh || mh < 350) return; // only fix the "400px"-style reservations
347
-
348
- const h = getRenderedAdHeight(adSpan);
349
- if (!h || h <= 0) return;
350
- if (h >= mh) return;
351
-
352
- adSpan.style.setProperty('min-height', `${h}px`, 'important');
353
- } catch (e) {}
354
- }
355
-
356
- function closestEzoicAdSpan(node) {
357
- try {
358
- if (!node || node.nodeType !== 1) return null;
359
- const el = /** @type {Element} */ (node);
360
- if (el.tagName === 'SPAN' && el.classList && el.classList.contains('ezoic-ad')) return el;
361
- if (el.closest) return el.closest('span.ezoic-ad');
362
- } catch (e) {}
363
- return null;
364
- }
365
-
366
- function ensureTightenObserver() {
367
- if (state.tightenObs) return;
368
-
369
- let raf = 0;
370
- const pending = new Set();
371
- const schedule = (adSpan) => {
372
- if (!adSpan) return;
373
- pending.add(adSpan);
374
- if (raf) return;
375
- raf = requestAnimationFrame(() => {
376
- raf = 0;
377
- for (const el of pending) tightenMinHeight(el);
378
- pending.clear();
379
- });
380
- };
381
-
382
- state.tightenObs = new MutationObserver((mutations) => {
383
- try {
384
- for (const m of mutations) {
385
- if (m.type === 'attributes') {
386
- const ad = closestEzoicAdSpan(m.target);
387
- if (ad) schedule(ad);
388
- continue;
389
- }
390
- if (!m.addedNodes || !m.addedNodes.length) continue;
391
- for (const n of m.addedNodes) {
392
- const ad = closestEzoicAdSpan(n);
393
- if (ad) schedule(ad);
394
- if (n && n.nodeType === 1 && n.querySelectorAll) {
395
- n.querySelectorAll('span.ezoic-ad').forEach(schedule);
396
- }
397
- }
398
- }
399
- } catch (e) {}
400
- });
401
-
402
- try {
403
- state.tightenObs.observe(document.documentElement, {
404
- subtree: true,
405
- childList: true,
406
- attributes: true,
407
- attributeFilter: ['style', 'class', 'data-load-complete', 'height'],
408
- });
409
- } catch (e) {}
410
-
411
- try {
412
- document.querySelectorAll('span.ezoic-ad[style*="min-height"]').forEach(tightenMinHeight);
413
- } catch (e) {}
414
- }
415
-
416
264
  const RECYCLE_COOLDOWN_MS = 1500;
417
265
 
418
266
  function kindKeyFromClass(kindClass) {
@@ -486,33 +334,7 @@ function withInternalDomChange(fn) {
486
334
  } catch (e) {}
487
335
  }
488
336
 
489
-
490
- function findWrapById(id) {
491
- try {
492
- return document.querySelector(`.${WRAP_CLASS}[data-ezoic-wrapid="${id}"]`);
493
- } catch (e) {}
494
- return null;
495
- }
496
-
497
- function armPlaceholder(wrap, id) {
498
- try {
499
- if (!wrap || !wrap.isConnected) return null;
500
- const domId = `${PLACEHOLDER_PREFIX}${id}`;
501
-
502
- // If the id is already present somewhere, do not reassign it.
503
- const existing = document.getElementById(domId);
504
- if (existing && existing.isConnected) return existing;
505
-
506
- const ph = wrap.querySelector('[data-ezoic-ph="1"]');
507
- if (!ph) return null;
508
-
509
- if (!ph.id) ph.id = domId;
510
- return ph;
511
- } catch (e) {}
512
- return null;
513
- }
514
-
515
- function pruneOrphanWraps(kindClass, items) {
337
+ function pruneOrphanWraps(kindClass, items) {
516
338
  if (!items || !items.length) return 0;
517
339
  const itemSet = new Set(items);
518
340
  const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
@@ -542,120 +364,37 @@ function pruneOrphanWraps(kindClass, items) {
542
364
  if (removed) dbg('prune-orphan', kindClass, { removed });
543
365
  return removed;
544
366
  }
367
+
368
+ function refreshEmptyState(id) {
369
+ // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
370
+ window.setTimeout(() => {
371
+ try {
372
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
373
+ if (!ph || !ph.isConnected) return;
374
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
375
+ if (!wrap) return;
376
+ const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
377
+ if (hasContent) wrap.classList.remove('is-empty');
378
+ else wrap.classList.add('is-empty');
379
+ } catch (e) {}
380
+ }, 3500);
381
+ }
382
+
545
383
  function buildWrap(id, kindClass, afterPos) {
546
384
  const wrap = document.createElement('div');
547
- wrap.className = `${WRAP_CLASS} ${kindClass} ez-pending`;
385
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
548
386
  wrap.setAttribute('data-ezoic-after', String(afterPos));
549
387
  wrap.setAttribute('data-ezoic-wrapid', String(id));
550
388
  wrap.style.width = '100%';
551
389
 
552
390
  const ph = document.createElement('div');
553
- // Do not assign the Ezoic placeholder id until we actually want to load the ad.
554
- // This avoids Ezoic defining placeholders too early during DOM churn/infinite scroll.
391
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
555
392
  ph.setAttribute('data-ezoic-id', String(id));
556
- ph.setAttribute('data-ezoic-ph', '1');
557
393
  wrap.appendChild(ph);
558
394
 
559
395
  return wrap;
560
396
  }
561
397
 
562
- // ---------- Fill detection & collapse handling (lightweight) ----------
563
- // If ad fill is slow, showing a big empty slot is visually jarring. We keep
564
- // our injected wrapper collapsed (ez-pending) until a creative is present,
565
- // then mark it ez-ready.
566
-
567
- function wrapHasFilledCreative(wrap) {
568
- try {
569
- if (!wrap || !wrap.isConnected) return false;
570
- // Safeframe container (most common)
571
- const c = wrap.querySelector('div[id$="__container__"]');
572
- if (c && c.offsetHeight > 10) return true;
573
- // Any iframe with non-trivial height
574
- const f = wrap.querySelector('iframe');
575
- if (!f) return false;
576
- if (f.getAttribute('data-load-complete') === 'true') return true;
577
- if (f.offsetHeight > 10) return true;
578
- return false;
579
- } catch (e) {}
580
- return false;
581
- }
582
-
583
- function markWrapFilledIfNeeded(wrap) {
584
- try {
585
- if (!wrap || !wrap.isConnected) return;
586
- if (!wrap.classList || !wrap.classList.contains(WRAP_CLASS)) return;
587
- // Only our injected wrappers are DIVs with data-ezoic-wrapid.
588
- if (wrap.tagName !== 'DIV') return;
589
- if (!wrap.getAttribute('data-ezoic-wrapid')) return;
590
-
591
- if (wrapHasFilledCreative(wrap)) {
592
- wrap.classList.remove('ez-pending');
593
- wrap.classList.add('ez-ready');
594
- }
595
- } catch (e) {}
596
- }
597
-
598
- function ensureFillObserver() {
599
- if (state.fillObs) return;
600
-
601
- let raf = 0;
602
- const pending = new Set();
603
- const schedule = (wrap) => {
604
- if (!wrap) return;
605
- pending.add(wrap);
606
- if (raf) return;
607
- raf = requestAnimationFrame(() => {
608
- raf = 0;
609
- for (const w of pending) markWrapFilledIfNeeded(w);
610
- pending.clear();
611
- });
612
- };
613
-
614
- const closestWrap = (node) => {
615
- try {
616
- if (!node || node.nodeType !== 1) return null;
617
- const el = /** @type {Element} */ (node);
618
- if (el.tagName === 'DIV' && el.classList && el.classList.contains(WRAP_CLASS) && el.getAttribute('data-ezoic-wrapid')) return el;
619
- if (el.closest) return el.closest(`div.${WRAP_CLASS}[data-ezoic-wrapid]`);
620
- } catch (e) {}
621
- return null;
622
- };
623
-
624
- state.fillObs = new MutationObserver((mutations) => {
625
- try {
626
- for (const m of mutations) {
627
- if (m.type === 'attributes') {
628
- const w = closestWrap(m.target);
629
- if (w) schedule(w);
630
- continue;
631
- }
632
- if (!m.addedNodes || !m.addedNodes.length) continue;
633
- for (const n of m.addedNodes) {
634
- const w = closestWrap(n);
635
- if (w) schedule(w);
636
- if (n && n.nodeType === 1 && n.querySelectorAll) {
637
- n.querySelectorAll(`div.${WRAP_CLASS}[data-ezoic-wrapid]`).forEach(schedule);
638
- }
639
- }
640
- }
641
- } catch (e) {}
642
- });
643
-
644
- try {
645
- state.fillObs.observe(document.documentElement, {
646
- subtree: true,
647
- childList: true,
648
- attributes: true,
649
- attributeFilter: ['style', 'class', 'data-load-complete', 'height', 'src'],
650
- });
651
- } catch (e) {}
652
-
653
- // Kick once for already-present wraps
654
- try {
655
- document.querySelectorAll(`div.${WRAP_CLASS}[data-ezoic-wrapid]`).forEach(markWrapFilledIfNeeded);
656
- } catch (e) {}
657
- }
658
-
659
398
  function findWrap(kindClass, afterPos) {
660
399
  return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
661
400
  }
@@ -665,8 +404,8 @@ function buildWrap(id, kindClass, afterPos) {
665
404
  if (findWrap(kindClass, afterPos)) return null;
666
405
  if (insertingIds.has(id)) return null;
667
406
 
668
- const existingWrap = findWrapById(id);
669
- if (existingWrap && existingWrap.isConnected) return null;
407
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
408
+ if (existingPh && existingPh.isConnected) return null;
670
409
 
671
410
  insertingIds.add(id);
672
411
  try {
@@ -688,8 +427,8 @@ function buildWrap(id, kindClass, afterPos) {
688
427
  state[cursorKey] = (state[cursorKey] + 1) % n;
689
428
 
690
429
  const id = allIds[idx];
691
- const w = findWrapById(id);
692
- if (w && w.isConnected) continue;
430
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
431
+ if (ph && ph.isConnected) continue;
693
432
 
694
433
  return id;
695
434
  }
@@ -711,20 +450,13 @@ function buildWrap(id, kindClass, afterPos) {
711
450
  // Otherwise remove the earliest one in the document
712
451
  if (!victim) victim = wraps[0];
713
452
 
714
- const id = getWrapIdFromWrap(victim);
715
-
716
- withInternalDomChange(() => {
717
- // Unobserve placeholder if still observed
718
- try {
719
- const t = victim;
720
- if (t && state.io) state.io.unobserve(t);
721
- } catch (e) {}
722
-
723
- try { if (id) safeDestroyById(id); } catch (e) {}
724
-
725
- try { victim.remove(); } catch (e) {}
726
- });
453
+ // Unobserve placeholder if still observed
454
+ try {
455
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
456
+ if (ph && state.io) state.io.unobserve(ph);
457
+ } catch (e) {}
727
458
 
459
+ victim.remove();
728
460
  return true;
729
461
  } catch (e) {
730
462
  return false;
@@ -763,7 +495,6 @@ function drainQueue() {
763
495
  function startShow(id) {
764
496
  if (!id || isBlocked()) return;
765
497
 
766
- const seq = state.navSeq;
767
498
  state.inflight++;
768
499
  let released = false;
769
500
  const release = () => {
@@ -779,50 +510,27 @@ function startShow(id) {
779
510
  try {
780
511
  if (isBlocked()) return;
781
512
 
782
- // Ensure placeholder is armed (arm-on-load). On some NodeBB transitions the
783
- // wrapper may exist but the placeholder id is not yet assigned.
784
- const wrap = findWrapById(id);
785
- if (wrap && wrap.isConnected) {
786
- try { armPlaceholder(wrap, id); } catch (e) {}
787
- }
788
-
789
- const domId = `${PLACEHOLDER_PREFIX}${id}`;
790
- const ph = document.getElementById(domId);
791
- if (!ph || !ph.isConnected) { try { clearTimeout(hardTimer); } catch (e) {} release(); return; }
513
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
514
+ if (!ph || !ph.isConnected) return;
792
515
 
793
516
  const now2 = Date.now();
794
517
  const last2 = state.lastShowById.get(id) || 0;
795
- if (now2 - last2 < 900) { try { clearTimeout(hardTimer); } catch (e) {} release(); return; }
518
+ if (now2 - last2 < 900) return;
796
519
  state.lastShowById.set(id, now2);
797
520
 
798
521
  window.ezstandalone = window.ezstandalone || {};
799
522
  const ez = window.ezstandalone;
800
523
 
801
524
  const doShow = () => {
802
- // Re-check right before showing: the placeholder can disappear between
803
- // scheduling and execution (ajaxify/infinite scroll DOM churn).
804
- if (seq !== state.navSeq) { try { clearTimeout(hardTimer); } catch (e) {} release(); return; }
805
- const phNow = document.getElementById(domId);
806
- if (!phNow || !phNow.isConnected) {
807
- try { clearTimeout(hardTimer); } catch (e) {}
808
- release();
809
- return;
810
- }
811
-
812
525
  try {
813
- if (state.usedOnce && state.usedOnce.has(id)) {
814
- safeDestroyById(id);
526
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
527
+ try { ez.destroyPlaceholders(id); } catch (e) {}
815
528
  }
816
529
  } catch (e) {}
817
530
 
818
- // Let the DOM settle for one macrotask; NodeBB can reflow/replace nodes right after mutations.
819
- setTimeout(() => {
820
- if (seq !== state.navSeq) { return; }
821
- const phFinal = document.getElementById(domId);
822
- if (!phFinal || !phFinal.isConnected) { return; }
823
- try { ez.showAds(id); } catch (e) {}
824
- }, 0);
531
+ try { ez.showAds(id); } catch (e) {}
825
532
  try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
533
+ try { markEmptyWrapper(id); } catch (e) {}
826
534
 
827
535
  setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
828
536
  };
@@ -857,12 +565,9 @@ function startShow(id) {
857
565
  const el = ent.target;
858
566
  try { state.io && state.io.unobserve(el); } catch (e) {}
859
567
 
860
- const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-wrapid');
568
+ const idAttr = el && el.getAttribute && el.getAttribute('data-ezoic-id');
861
569
  const id = parseInt(idAttr, 10);
862
- if (Number.isFinite(id) && id > 0) {
863
- armPlaceholder(el, id);
864
- enqueueShow(id);
865
- }
570
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
866
571
  }
867
572
  }, { root: null, rootMargin: desiredMargin, threshold: 0 });
868
573
  state.ioMargin = desiredMargin;
@@ -874,7 +579,7 @@ function startShow(id) {
874
579
  // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
875
580
  try {
876
581
  if (state.io) {
877
- const nodes = document.querySelectorAll(`.${WRAP_CLASS}[data-ezoic-wrapid]`);
582
+ const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
878
583
  nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
879
584
  }
880
585
  } catch (e) {}
@@ -882,20 +587,17 @@ function startShow(id) {
882
587
  }
883
588
 
884
589
  function observePlaceholder(id) {
885
- const wrap = findWrapById(id);
886
- if (!wrap || !wrap.isConnected) return;
590
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
591
+ if (!ph || !ph.isConnected) return;
887
592
  const io = ensurePreloadObserver();
888
- try { io && io.observe(wrap); } catch (e) {}
593
+ try { io && io.observe(ph); } catch (e) {}
889
594
 
890
- // If already near the fold, arm & fire immediately
595
+ // If already above fold, fire immediately
891
596
  try {
892
- const r = wrap.getBoundingClientRect();
893
- const screens = 3.0;
894
- const h = (window.innerHeight || 800);
895
- if (r.top < screens * h && r.bottom > -screens * h) {
896
- armPlaceholder(wrap, id);
897
- enqueueShow(id);
898
- }
597
+ const r = ph.getBoundingClientRect();
598
+ const screens = isBoosted() ? 5.0 : 3.0;
599
+ const minBottom = isBoosted() ? -1500 : -800;
600
+ if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
899
601
  } catch (e) {}
900
602
  }
901
603
 
@@ -916,8 +618,7 @@ function startShow(id) {
916
618
 
917
619
  const targets = computeTargets(items.length, interval, showFirst);
918
620
  let inserted = 0;
919
- const perf = getPerfProfile();
920
- const maxInserts = perf.maxInsertsPerRun;
621
+ const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
921
622
 
922
623
  for (const afterPos of targets) {
923
624
  if (inserted >= maxInserts) break;
@@ -1084,27 +785,13 @@ function startShow(id) {
1084
785
  function cleanup() {
1085
786
  blockedUntil = Date.now() + 1200;
1086
787
 
1087
- // invalidate any queued showAds from previous view
1088
- state.navSeq++;
1089
- state.inflight = 0;
1090
- state.pending = [];
1091
- state.pendingSet = new Set();
1092
-
1093
788
  // remove all wrappers
1094
789
  try {
1095
- withInternalDomChange(() => {
1096
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
1097
- try { el.remove(); } catch (e) {}
1098
- });
790
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
791
+ try { el.remove(); } catch (e) {}
1099
792
  });
1100
793
  } catch (e) {}
1101
794
 
1102
- // reset perf profile cache
1103
- state.perfProfile = null;
1104
-
1105
- // tighten observer is global; keep it across ajaxify navigation but ensure it exists
1106
- // (do not disconnect here to avoid missing late style rewrites during transitions)
1107
-
1108
795
  // reset state
1109
796
  state.cfg = null;
1110
797
  state.allTopics = [];
@@ -1125,11 +812,9 @@ function startShow(id) {
1125
812
 
1126
813
  function ensureDomObserver() {
1127
814
  if (state.domObs) return;
1128
- state.domObs = new MutationObserver((mutations) => {
815
+ state.domObs = new MutationObserver(() => {
1129
816
  if (state.internalDomChange > 0) return;
1130
- if (isBlocked()) return;
1131
- // Only rescan when NodeBB actually added posts/topics/categories.
1132
- if (mutationHasRelevantAddedNodes(mutations)) scheduleRun();
817
+ if (!isBlocked()) scheduleRun();
1133
818
  });
1134
819
  try {
1135
820
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -1151,8 +836,6 @@ function startShow(id) {
1151
836
 
1152
837
  warmUpNetwork();
1153
838
  patchShowAds();
1154
- ensureTightenObserver();
1155
- ensureFillObserver();
1156
839
  ensurePreloadObserver();
1157
840
  ensureDomObserver();
1158
841
 
@@ -1170,35 +853,51 @@ function startShow(id) {
1170
853
  });
1171
854
  }
1172
855
 
856
+ function bindScroll() {
857
+ let ticking = false;
858
+ window.addEventListener('scroll', () => {
859
+ // Detect very fast scrolling and temporarily boost preload/parallelism.
860
+ try {
861
+ const now = Date.now();
862
+ const y = window.scrollY || window.pageYOffset || 0;
863
+ if (state.lastScrollTs) {
864
+ const dt = now - state.lastScrollTs;
865
+ const dy = Math.abs(y - (state.lastScrollY || 0));
866
+ if (dt > 0) {
867
+ const speed = dy / dt; // px/ms
868
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
869
+ const wasBoosted = isBoosted();
870
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
871
+ if (!wasBoosted) {
872
+ // margin changed -> rebuild IO so existing placeholders get earlier preload
873
+ ensurePreloadObserver();
874
+ }
875
+ }
876
+ }
877
+ }
878
+ state.lastScrollY = y;
879
+ state.lastScrollTs = now;
880
+ } catch (e) {}
881
+
882
+ if (ticking) return;
883
+ ticking = true;
884
+ window.requestAnimationFrame(() => {
885
+ ticking = false;
886
+ if (!isBlocked()) scheduleRun();
887
+ });
888
+ }, { passive: true });
889
+ }
890
+
1173
891
  // ---------- boot ----------
1174
892
 
1175
893
  state.pageKey = getPageKey();
1176
894
  warmUpNetwork();
1177
895
  patchShowAds();
1178
- ensureTightenObserver();
1179
- ensureFillObserver();
1180
896
  ensurePreloadObserver();
1181
897
  ensureDomObserver();
1182
- bindNodeBB();
1183
898
 
1184
- // Lightweight scroll kick: NodeBB infinite scroll can keep many nodes and only append occasionally.
1185
- // Without a scroll trigger, we might not inject new placeholders until another DOM mutation occurs.
1186
- // This is throttled and only triggers near the bottom to keep CPU usage minimal.
1187
- state.lastScrollKick = 0;
1188
- window.addEventListener('scroll', () => {
1189
- const now = Date.now();
1190
- if (now - state.lastScrollKick < 250) return;
1191
- state.lastScrollKick = now;
1192
-
1193
- // Only kick when user is approaching the end of currently rendered content
1194
- const doc = document.documentElement;
1195
- const scrollTop = window.pageYOffset || doc.scrollTop || 0;
1196
- const viewportH = window.innerHeight || doc.clientHeight || 0;
1197
- const fullH = Math.max(doc.scrollHeight, document.body ? document.body.scrollHeight : 0);
1198
- if (scrollTop + viewportH > fullH - 2000) {
1199
- if (!isBlocked()) scheduleRun();
1200
- }
1201
- }, { passive: true });
899
+ bindNodeBB();
900
+ bindScroll();
1202
901
 
1203
902
  // First paint: try hero + run
1204
903
  blockedUntil = 0;
package/public/style.css CHANGED
@@ -1,34 +1,40 @@
1
- /* Minimal styling for injected Ezoic wrappers.
2
- Spacing (margins/padding) is intentionally NOT forced here, because it can be
3
- configured inside Ezoic and may vary by placement/device.
4
- */
5
-
1
+ /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
6
2
  .ezoic-ad {
7
3
  display: block;
8
4
  width: 100%;
9
- clear: both;
10
- position: relative;
5
+ margin: 0 !important;
6
+ padding: 0 !important;
7
+ overflow: hidden;
11
8
  }
12
9
 
13
- /*
14
- UX: collapse injected wrapper slots until the creative is actually filled.
15
- This avoids showing a large empty placeholder when ad fill is slow.
16
- Only applies to our injected wrapper DIVs (not Ezoic's internal SPANs).
17
- */
18
- div.ezoic-ad.ez-pending {
19
- height: 1px !important;
20
- min-height: 1px !important;
21
- overflow: hidden !important;
10
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
+ margin: 0 !important;
12
+ padding: 0 !important;
13
+ min-height: 1px; /* keeps placeholder measurable for IO */
22
14
  }
23
15
 
24
- div.ezoic-ad.ez-ready {
25
- height: auto !important;
26
- overflow: visible !important;
16
+ /* Ezoic sometimes wraps in extra spans/divs with margins */
17
+ .ezoic-ad span.ezoic-ad,
18
+ .ezoic-ad .ezoic-ad {
19
+ margin: 0 !important;
20
+ padding: 0 !important;
27
21
  }
28
22
 
29
- /* Remove baseline gaps under iframes inside Ezoic creatives */
30
- span.ezoic-ad iframe,
31
- span.ezoic-ad div[id$="__container__"] iframe {
23
+
24
+ /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
25
+ .ezoic-ad.is-empty {
32
26
  display: block !important;
33
- vertical-align: top !important;
27
+ margin: 0 !important;
28
+ padding: 0 !important;
29
+ height: 0 !important;
30
+ min-height: 0 !important;
31
+ overflow: hidden !important;
32
+ }
33
+
34
+ .ezoic-ad {
35
+ min-height: 0 !important;
36
+ }
37
+
38
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
39
+ min-height: 0 !important;
34
40
  }