nodebb-plugin-ezoic-infinite 1.5.68 → 1.5.69

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.68",
3
+ "version": "1.5.69",
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
@@ -5,63 +5,41 @@
5
5
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
6
6
 
7
7
  // IMPORTANT: must NOT collide with Ezoic's own markup (they use `.ezoic-ad`).
8
- // If we reuse that class, cleanup/pruning can delete real ads and cause
9
- // "placeholder does not exist" spam + broken 1/X insertion.
10
8
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
11
9
  const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
10
+ const POOL_ID = 'nodebb-ezoic-placeholder-pool';
12
11
 
13
- // Insert at most N ads per run to keep the UI smooth on infinite scroll
12
+ // Smoothness caps
14
13
  const MAX_INSERTS_PER_RUN = 3;
15
14
 
16
- // Preload before viewport (earlier load for smoother scroll)
15
+ // Preload margins
17
16
  const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
18
17
  const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
19
-
20
- // When the user scrolls very fast, temporarily preload more aggressively.
21
- // This helps ensure ads are already in-flight before the user reaches them.
22
18
  const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
23
19
  const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
20
+
24
21
  const BOOST_DURATION_MS = 2500;
25
22
  const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
26
23
 
27
24
  const MAX_INFLIGHT_DESKTOP = 4;
28
25
  const MAX_INFLIGHT_MOBILE = 3;
29
26
 
30
- function isBoosted() {
31
- try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
32
- }
33
-
34
- function isMobile() {
35
- try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
36
- }
37
-
38
- function getPreloadRootMargin() {
39
- if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
40
- return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
41
- }
42
-
43
- function getMaxInflight() {
44
- const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
45
- return base + (isBoosted() ? 1 : 0);
46
- }
47
-
48
27
  const SELECTORS = {
49
28
  topicItem: 'li[component="category/topic"]',
50
29
  postItem: '[component="post"][data-pid]',
51
30
  categoryItem: 'li[component="categories/category"]',
52
31
  };
53
32
 
54
- // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
55
- let blockedUntil = 0;
56
- function isBlocked() {
57
- return Date.now() < blockedUntil;
58
- }
33
+ // Production build: debug disabled
34
+ function dbg() {}
35
+
36
+ // ---------------- state ----------------
59
37
 
60
38
  const state = {
61
39
  pageKey: null,
62
40
  cfg: null,
63
41
 
64
- // Full lists (never consumed) + cursors for round-robin reuse
42
+ // pools (full lists) + cursors
65
43
  allTopics: [],
66
44
  allPosts: [],
67
45
  allCategories: [],
@@ -69,93 +47,46 @@
69
47
  curPosts: 0,
70
48
  curCategories: 0,
71
49
 
72
- // throttle per placeholder id
50
+ // per-id throttle
73
51
  lastShowById: new Map(),
74
- internalDomChange: 0,
75
- lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
76
-
77
- // track placeholders that have been shown at least once in this pageview
78
- usedOnce: new Set(),
79
52
 
80
53
  // observers / schedulers
81
54
  domObs: null,
82
55
  io: null,
83
- runQueued: false,
56
+ ioMargin: null,
57
+
58
+ // internal mutations guard
59
+ internalDomChange: 0,
84
60
 
85
61
  // preloading budget
86
62
  inflight: 0,
87
63
  pending: [],
88
64
  pendingSet: new Set(),
89
65
 
90
- // fast scroll boosting
66
+ // scroll boost
91
67
  scrollBoostUntil: 0,
92
68
  lastScrollY: 0,
93
69
  lastScrollTs: 0,
94
- ioMargin: null,
95
70
 
96
- // hero)
71
+ // hero
97
72
  heroDoneForPage: false,
98
- burstRuns: 0,
73
+
74
+ // run scheduler
75
+ runQueued: false,
76
+ burstActive: false,
77
+ burstDeadline: 0,
78
+ burstCount: 0,
79
+ lastBurstReqTs: 0,
99
80
  };
100
81
 
82
+ // Soft block during navigation / heavy DOM churn
83
+ let blockedUntil = 0;
101
84
  const insertingIds = new Set();
102
85
 
103
- // Hidden pool where we keep placeholder DIVs when they are not currently
104
- // mounted in the content flow. This avoids:
105
- // - Ezoic trying to act on ids that were removed from the DOM ("does not exist")
106
- // - re-defining placeholders (we re-use the same node)
107
- const POOL_ID = 'nodebb-ezoic-placeholder-pool';
108
- function getPoolEl() {
109
- let el = document.getElementById(POOL_ID);
110
- if (el) return el;
111
- el = document.createElement('div');
112
- el.id = POOL_ID;
113
- el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
114
- (document.body || document.documentElement).appendChild(el);
115
- return el;
116
- }
117
-
118
- function isInPool(ph) {
119
- try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
120
- }
121
-
122
- function releaseWrapNode(wrap) {
123
- try {
124
- const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
125
- if (ph) {
126
- try { getPoolEl().appendChild(ph); } catch (e) {}
127
- try { if (state.io) state.io.unobserve(ph); } catch (e) {}
128
- }
129
- } catch (e) {}
130
- try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
131
- }
132
-
133
-
134
- function markEmptyWrapper(id) {
135
- try {
136
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
137
- if (!ph || !ph.isConnected) return;
138
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
139
- if (!wrap) return;
140
- // If still empty after a delay, collapse it.
141
- setTimeout(() => {
142
- try {
143
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
144
- if (!ph2 || !ph2.isConnected) return;
145
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
146
- if (!w2) return;
147
- // consider empty if only whitespace and no iframes/ins/img
148
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
149
- if (!hasAd) w2.classList.add('is-empty');
150
- } catch (e) {}
151
- }, 3500);
152
- } catch (e) {}
153
- }
154
-
155
- // Production build: debug disabled
156
- function dbg() {}
86
+ function now() { return Date.now(); }
87
+ function isBlocked() { return now() < blockedUntil; }
157
88
 
158
- // ---------- small utils ----------
89
+ // ---------------- utils ----------------
159
90
 
160
91
  function normalizeBool(v) {
161
92
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
@@ -194,19 +125,31 @@
194
125
  return window.location.pathname;
195
126
  }
196
127
 
197
- function getKind() {
198
- const p = window.location.pathname || '';
199
- if (/^\/topic\//.test(p)) return 'topic';
200
- if (/^\/category\//.test(p)) return 'categoryTopics';
201
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
128
+ function isMobile() {
129
+ try { return window.innerWidth < 768; } catch (e) { return false; }
130
+ }
202
131
 
203
- // fallback by DOM
204
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
205
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
206
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
207
- return 'other';
132
+ function isBoosted() {
133
+ return now() < (state.scrollBoostUntil || 0);
208
134
  }
209
135
 
136
+ function getPreloadRootMargin() {
137
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
138
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
139
+ }
140
+
141
+ function getMaxInflight() {
142
+ const base = isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
143
+ return base + (isBoosted() ? 1 : 0);
144
+ }
145
+
146
+ function withInternalDomChange(fn) {
147
+ state.internalDomChange++;
148
+ try { fn(); } finally { state.internalDomChange--; }
149
+ }
150
+
151
+ // ---------------- DOM helpers ----------------
152
+
210
153
  function getTopicItems() {
211
154
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
212
155
  }
@@ -227,7 +170,59 @@
227
170
  });
228
171
  }
229
172
 
230
- // ---------- warm-up & patching ----------
173
+ function getKind() {
174
+ const p = window.location.pathname || '';
175
+ if (/^\/topic\//.test(p)) return 'topic';
176
+ if (/^\/category\//.test(p)) return 'categoryTopics';
177
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
178
+
179
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
180
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
181
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
182
+ return 'other';
183
+ }
184
+
185
+ function isAdjacentAd(target) {
186
+ if (!target) return false;
187
+ const next = target.nextElementSibling;
188
+ if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
189
+ const prev = target.previousElementSibling;
190
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
191
+ return false;
192
+ }
193
+
194
+ function findWrap(kindClass, afterPos) {
195
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
196
+ }
197
+
198
+ // ---------------- placeholder pool ----------------
199
+
200
+ function getPoolEl() {
201
+ let el = document.getElementById(POOL_ID);
202
+ if (el) return el;
203
+ el = document.createElement('div');
204
+ el.id = POOL_ID;
205
+ el.style.cssText = 'position:fixed;left:-99999px;top:-99999px;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
206
+ (document.body || document.documentElement).appendChild(el);
207
+ return el;
208
+ }
209
+
210
+ function isInPool(ph) {
211
+ try { return !!(ph && ph.closest && ph.closest('#' + POOL_ID)); } catch (e) { return false; }
212
+ }
213
+
214
+ function releaseWrapNode(wrap) {
215
+ try {
216
+ const ph = wrap && wrap.querySelector && wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
217
+ if (ph) {
218
+ try { getPoolEl().appendChild(ph); } catch (e) {}
219
+ try { state.io && state.io.unobserve(ph); } catch (e) {}
220
+ }
221
+ } catch (e) {}
222
+ try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
223
+ }
224
+
225
+ // ---------------- network warmup ----------------
231
226
 
232
227
  const _warmLinksDone = new Set();
233
228
  function warmUpNetwork() {
@@ -235,10 +230,18 @@
235
230
  const head = document.head || document.getElementsByTagName('head')[0];
236
231
  if (!head) return;
237
232
  const links = [
233
+ // Ezoic
238
234
  ['preconnect', 'https://g.ezoic.net', true],
239
235
  ['dns-prefetch', 'https://g.ezoic.net', false],
240
236
  ['preconnect', 'https://go.ezoic.net', true],
241
237
  ['dns-prefetch', 'https://go.ezoic.net', false],
238
+ // Google ads
239
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
240
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
241
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
242
+ ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
243
+ ['preconnect', 'https://tpc.googlesyndication.com', true],
244
+ ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
242
245
  ];
243
246
  for (const [rel, href, cors] of links) {
244
247
  const key = `${rel}|${href}`;
@@ -253,7 +256,9 @@
253
256
  } catch (e) {}
254
257
  }
255
258
 
256
- // Patch showAds to avoid warnings when a placeholder disappears (infinite scroll, ajaxify)
259
+ // ---------------- Ezoic bridge ----------------
260
+
261
+ // Patch showAds to silently skip ids not in DOM. This prevents console spam.
257
262
  function patchShowAds() {
258
263
  const applyPatch = () => {
259
264
  try {
@@ -295,28 +300,7 @@
295
300
  }
296
301
  }
297
302
 
298
- const RECYCLE_COOLDOWN_MS = 1500;
299
-
300
- function kindKeyFromClass(kindClass) {
301
- if (kindClass === 'ezoic-ad-message') return 'topic';
302
- if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
303
- if (kindClass === 'ezoic-ad-categories') return 'categories';
304
- return 'topic';
305
- }
306
-
307
- function withInternalDomChange(fn) {
308
- state.internalDomChange++;
309
- try { fn(); } finally { state.internalDomChange--; }
310
- }
311
-
312
- function canRecycle(kind) {
313
- const now = Date.now();
314
- const last = state.lastRecycleAt[kind] || 0;
315
- if (now - last < RECYCLE_COOLDOWN_MS) return false;
316
- state.lastRecycleAt[kind] = now;
317
- return true;
318
- }
319
- // ---------- config & pools ----------
303
+ // ---------------- config ----------------
320
304
 
321
305
  async function fetchConfigOnce() {
322
306
  if (state.cfg) return state.cfg;
@@ -332,89 +316,14 @@ function withInternalDomChange(fn) {
332
316
 
333
317
  function initPools(cfg) {
334
318
  if (!cfg) return;
335
- if (state.allTopics.length === 0) state.allTopics = parsePool(cfg.placeholderIds);
336
- if (state.allPosts.length === 0) state.allPosts = parsePool(cfg.messagePlaceholderIds);
337
- if (state.allCategories.length === 0) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
338
- }
339
-
340
- // ---------- insertion primitives ----------
341
-
342
- function isAdjacentAd(target) {
343
- if (!target) return false;
344
- const next = target.nextElementSibling;
345
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
346
- const prev = target.previousElementSibling;
347
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
348
- return false;
349
- }
350
-
351
-
352
- function getWrapIdFromWrap(wrap) {
353
- try {
354
- const v = wrap.getAttribute('data-ezoic-wrapid');
355
- if (v) return String(v);
356
- const ph = wrap.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
357
- if (ph && ph.id) return ph.id.replace(PLACEHOLDER_PREFIX, '');
358
- } catch (e) {}
359
- return null;
360
- }
361
-
362
- function safeDestroyById(id) {
363
- // IMPORTANT:
364
- // Do NOT call ez.destroyPlaceholders here.
365
- // In NodeBB ajaxify/infinite-scroll flows, Ezoic can be mid-refresh.
366
- // Destroy calls can create churn, reduce fill, and generate "does not exist" spam.
367
- // We only remove our wrapper; Ezoic manages slot lifecycle.
368
- return;
369
- }
370
-
371
- function pruneOrphanWraps(kindClass, items) {
372
- if (!items || !items.length) return 0;
373
- const itemSet = new Set(items);
374
- const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
375
- let removed = 0;
376
-
377
- wraps.forEach((wrap) => {
378
- // NodeBB can insert separators/spacers; accept an anchor within a few previous siblings
379
- let ok = false;
380
- let prev = wrap.previousElementSibling;
381
- for (let i = 0; i < 3 && prev; i++) {
382
- if (itemSet.has(prev)) { ok = true; break; }
383
- prev = prev.previousElementSibling;
384
- }
385
-
386
- if (!ok) {
387
- withInternalDomChange(() => {
388
- try {
389
- // Do not destroy placeholders; move them back to the pool so
390
- // Ezoic won't log "does not exist" and we can reuse them later.
391
- releaseWrapNode(wrap);
392
- } catch (e) {}
393
- });
394
- removed++;
395
- }
396
- });
397
-
398
- if (removed) dbg('prune-orphan', kindClass, { removed });
399
- return removed;
319
+ if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
320
+ if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
321
+ if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
400
322
  }
401
323
 
402
- function refreshEmptyState(id) {
403
- // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
404
- window.setTimeout(() => {
405
- try {
406
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
407
- if (!ph || !ph.isConnected) return;
408
- const wrap = ph.closest(`.${WRAP_CLASS}`);
409
- if (!wrap) return;
410
- const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
411
- if (hasContent) wrap.classList.remove('is-empty');
412
- else wrap.classList.add('is-empty');
413
- } catch (e) {}
414
- }, 3500);
415
- }
324
+ // ---------------- insertion primitives ----------------
416
325
 
417
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
326
+ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
418
327
  const wrap = document.createElement('div');
419
328
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
420
329
  wrap.setAttribute('data-ezoic-after', String(afterPos));
@@ -431,10 +340,6 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
431
340
  return wrap;
432
341
  }
433
342
 
434
- function findWrap(kindClass, afterPos) {
435
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
436
- }
437
-
438
343
  function insertAfter(target, id, kindClass, afterPos) {
439
344
  if (!target || !target.insertAdjacentElement) return null;
440
345
  if (findWrap(kindClass, afterPos)) return null;
@@ -444,24 +349,18 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
444
349
 
445
350
  insertingIds.add(id);
446
351
  try {
447
- // If a placeholder already exists (either in content or in our pool),
448
- // do NOT create a new DOM node with the same id even temporarily.
449
352
  const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
450
353
  target.insertAdjacentElement('afterend', wrap);
451
354
 
452
- // If a placeholder with this id already exists elsewhere (some Ezoic flows
453
- // pre-create placeholders), move it into our wrapper instead of aborting.
454
- // replaceChild moves the node atomically (no detach window).
355
+ // If placeholder exists elsewhere (including pool), move it into the wrapper.
455
356
  if (existingPh) {
456
357
  try {
457
358
  existingPh.setAttribute('data-ezoic-id', String(id));
458
- // If we didn't create a placeholder, just append.
459
359
  if (!wrap.firstElementChild) wrap.appendChild(existingPh);
460
360
  else wrap.replaceChild(existingPh, wrap.firstElementChild);
461
- } catch (e) {
462
- // Keep the new placeholder if replace fails.
463
- }
361
+ } catch (e) {}
464
362
  }
363
+
465
364
  return wrap;
466
365
  } finally {
467
366
  insertingIds.delete(id);
@@ -472,138 +371,69 @@ function buildWrap(id, kindClass, afterPos, createPlaceholder) {
472
371
  const n = allIds.length;
473
372
  if (!n) return null;
474
373
 
475
- // Try at most n ids to find one that's not already in the DOM
476
374
  for (let tries = 0; tries < n; tries++) {
477
375
  const idx = state[cursorKey] % n;
478
376
  state[cursorKey] = (state[cursorKey] + 1) % n;
479
-
480
377
  const id = allIds[idx];
378
+
481
379
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
482
- // If placeholder is currently mounted in the content flow, skip.
483
- // If it's in our hidden pool, it's available for reuse.
484
380
  if (ph && ph.isConnected && !isInPool(ph)) continue;
485
381
 
486
382
  return id;
487
383
  }
384
+
488
385
  return null;
489
386
  }
490
387
 
388
+ function pruneOrphanWraps(kindClass, items) {
389
+ if (!items || !items.length) return 0;
390
+ const itemSet = new Set(items);
391
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
392
+ let removed = 0;
491
393
 
492
- function removeOneOldWrap(kindClass) {
493
- try {
494
- const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
495
- if (!wraps.length) return false;
496
-
497
- // Prefer a wrap far above the viewport
498
- let victim = null;
499
- for (const w of wraps) {
500
- const r = w.getBoundingClientRect();
501
- if (r.bottom < -2000) { victim = w; break; }
394
+ wraps.forEach((wrap) => {
395
+ let ok = false;
396
+ let prev = wrap.previousElementSibling;
397
+ for (let i = 0; i < 3 && prev; i++) {
398
+ if (itemSet.has(prev)) { ok = true; break; }
399
+ prev = prev.previousElementSibling;
502
400
  }
503
- // Otherwise remove the earliest one in the document
504
- if (!victim) victim = wraps[0];
505
401
 
506
- releaseWrapNode(victim);
507
- return true;
508
- } catch (e) {
509
- return false;
510
- }
511
- }
512
-
513
- function enqueueShow(id) {
514
- if (!id || isBlocked()) return;
515
-
516
- // Basic per-id throttle (prevents rapid re-requests when DOM churns)
517
- const now = Date.now();
518
- const last = state.lastShowById.get(id) || 0;
519
- if (now - last < 900) return;
520
-
521
- const max = getMaxInflight();
522
- if (state.inflight >= max) {
523
- if (!state.pendingSet.has(id)) {
524
- state.pending.push(id);
525
- state.pendingSet.add(id);
526
- }
527
- return;
528
- }
529
- startShow(id);
530
- }
402
+ if (!ok) {
403
+ withInternalDomChange(() => releaseWrapNode(wrap));
404
+ removed++;
405
+ }
406
+ });
531
407
 
532
- function drainQueue() {
533
- if (isBlocked()) return;
534
- const max = getMaxInflight();
535
- while (state.inflight < max && state.pending.length) {
536
- const id = state.pending.shift();
537
- state.pendingSet.delete(id);
538
- startShow(id);
408
+ return removed;
539
409
  }
540
- }
541
-
542
- function startShow(id) {
543
- if (!id || isBlocked()) return;
544
-
545
- state.inflight++;
546
- let released = false;
547
- const release = () => {
548
- if (released) return;
549
- released = true;
550
- state.inflight = Math.max(0, state.inflight - 1);
551
- drainQueue();
552
- };
553
-
554
- const hardTimer = setTimeout(release, 6500);
555
-
556
- requestAnimationFrame(() => {
557
- try {
558
- if (isBlocked()) return;
559
-
560
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
561
- if (!ph || !ph.isConnected) return;
562
410
 
563
- const now2 = Date.now();
564
- const last2 = state.lastShowById.get(id) || 0;
565
- if (now2 - last2 < 900) return;
566
- state.lastShowById.set(id, now2);
567
-
568
- window.ezstandalone = window.ezstandalone || {};
569
- const ez = window.ezstandalone;
570
-
571
- const doShow = () => {
572
- // Do NOT call destroyPlaceholders here.
573
- // In ajaxify + infinite scroll flows, Ezoic can be in the middle of a refresh cycle.
574
- // Calling destroy on active placeholders is a common source of:
575
- // - "HTML element ... does not exist"
576
- // - "Placeholder Id ... already been defined"
577
- // Prefer a straight showAds; Ezoic will refresh as needed.
578
- try { ez.showAds(id); } catch (e) {}
579
- try { markEmptyWrapper(id); } catch (e) {}
580
-
581
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
582
- };
583
-
584
- if (Array.isArray(ez.cmd)) {
585
- try { ez.cmd.push(doShow); } catch (e) { doShow(); }
586
- } else {
587
- doShow();
411
+ function decluster(kindClass) {
412
+ // Remove consecutive wraps (keep the first)
413
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
414
+ if (wraps.length < 2) return 0;
415
+ let removed = 0;
416
+ for (const w of wraps) {
417
+ const prev = w.previousElementSibling;
418
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
419
+ withInternalDomChange(() => releaseWrapNode(w));
420
+ removed++;
588
421
  }
589
- } finally {
590
- // If we returned early, hardTimer will release.
591
422
  }
592
- });
593
- }
594
-
423
+ return removed;
424
+ }
595
425
 
596
- // ---------- preload / above-the-fold ----------
426
+ // ---------------- show (preload / fast fill) ----------------
597
427
 
598
428
  function ensurePreloadObserver() {
599
429
  const desiredMargin = getPreloadRootMargin();
600
430
  if (state.io && state.ioMargin === desiredMargin) return state.io;
601
431
 
602
- // Rebuild IO if margin changed (e.g., scroll boost toggled)
603
432
  if (state.io) {
604
433
  try { state.io.disconnect(); } catch (e) {}
605
434
  state.io = null;
606
435
  }
436
+
607
437
  try {
608
438
  state.io = new IntersectionObserver((entries) => {
609
439
  for (const ent of entries) {
@@ -616,29 +446,33 @@ function startShow(id) {
616
446
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
617
447
  }
618
448
  }, { root: null, rootMargin: desiredMargin, threshold: 0 });
449
+
619
450
  state.ioMargin = desiredMargin;
620
451
  } catch (e) {
621
452
  state.io = null;
622
453
  state.ioMargin = null;
623
454
  }
624
455
 
625
- // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
456
+ // Re-observe current placeholders
626
457
  try {
627
458
  if (state.io) {
628
459
  const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
629
460
  nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
630
461
  }
631
462
  } catch (e) {}
463
+
632
464
  return state.io;
633
465
  }
634
466
 
635
467
  function observePlaceholder(id) {
636
468
  const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
637
469
  if (!ph || !ph.isConnected) return;
470
+ ph.setAttribute('data-ezoic-id', String(id));
471
+
638
472
  const io = ensurePreloadObserver();
639
473
  try { io && io.observe(ph); } catch (e) {}
640
474
 
641
- // If already above fold, fire immediately
475
+ // If already near viewport, fire immediately.
642
476
  try {
643
477
  const r = ph.getBoundingClientRect();
644
478
  const screens = isBoosted() ? 5.0 : 3.0;
@@ -647,7 +481,104 @@ function startShow(id) {
647
481
  } catch (e) {}
648
482
  }
649
483
 
650
- // ---------- insertion logic ----------
484
+ function enqueueShow(id) {
485
+ if (!id || isBlocked()) return;
486
+
487
+ // per-id throttle
488
+ const t = now();
489
+ const last = state.lastShowById.get(id) || 0;
490
+ if (t - last < 900) return;
491
+
492
+ const max = getMaxInflight();
493
+ if (state.inflight >= max) {
494
+ if (!state.pendingSet.has(id)) {
495
+ state.pending.push(id);
496
+ state.pendingSet.add(id);
497
+ }
498
+ return;
499
+ }
500
+
501
+ startShow(id);
502
+ }
503
+
504
+ function drainQueue() {
505
+ if (isBlocked()) return;
506
+ const max = getMaxInflight();
507
+ while (state.inflight < max && state.pending.length) {
508
+ const id = state.pending.shift();
509
+ state.pendingSet.delete(id);
510
+ startShow(id);
511
+ }
512
+ }
513
+
514
+ function markEmptyWrapper(id) {
515
+ // If still empty after delay, mark empty for CSS (1px)
516
+ try {
517
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
518
+ if (!ph || !ph.isConnected) return;
519
+ const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
520
+ if (!wrap) return;
521
+
522
+ setTimeout(() => {
523
+ try {
524
+ const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
525
+ if (!ph2 || !ph2.isConnected) return;
526
+ const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
527
+ if (!w2) return;
528
+ const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
529
+ if (!hasAd) w2.classList.add('is-empty');
530
+ } catch (e) {}
531
+ }, 3500);
532
+ } catch (e) {}
533
+ }
534
+
535
+ function startShow(id) {
536
+ if (!id || isBlocked()) return;
537
+
538
+ state.inflight++;
539
+ let released = false;
540
+ const release = () => {
541
+ if (released) return;
542
+ released = true;
543
+ state.inflight = Math.max(0, state.inflight - 1);
544
+ drainQueue();
545
+ };
546
+
547
+ const hardTimer = setTimeout(release, 6500);
548
+
549
+ requestAnimationFrame(() => {
550
+ try {
551
+ if (isBlocked()) return;
552
+
553
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
554
+ if (!ph || !ph.isConnected) return;
555
+
556
+ const t = now();
557
+ const last = state.lastShowById.get(id) || 0;
558
+ if (t - last < 900) return;
559
+ state.lastShowById.set(id, t);
560
+
561
+ window.ezstandalone = window.ezstandalone || {};
562
+ const ez = window.ezstandalone;
563
+
564
+ const doShow = () => {
565
+ try { ez.showAds(id); } catch (e) {}
566
+ try { markEmptyWrapper(id); } catch (e) {}
567
+ setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
568
+ };
569
+
570
+ if (Array.isArray(ez.cmd)) {
571
+ try { ez.cmd.push(doShow); } catch (e) { doShow(); }
572
+ } else {
573
+ doShow();
574
+ }
575
+ } finally {
576
+ // hardTimer releases on early return
577
+ }
578
+ });
579
+ }
580
+
581
+ // ---------------- core injection ----------------
651
582
 
652
583
  function computeTargets(count, interval, showFirst) {
653
584
  const out = [];
@@ -656,6 +587,7 @@ function startShow(id) {
656
587
  for (let i = 1; i <= count; i++) {
657
588
  if (i % interval === 0) out.push(i);
658
589
  }
590
+ // unique + sorted
659
591
  return Array.from(new Set(out)).sort((a, b) => a - b);
660
592
  }
661
593
 
@@ -674,43 +606,87 @@ function startShow(id) {
674
606
  if (isAdjacentAd(el)) continue;
675
607
  if (findWrap(kindClass, afterPos)) continue;
676
608
 
677
- let id = pickIdFromAll(allIds, cursorKey);
678
- if (!id) {
679
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
680
- // Guard against tight observer loops.
681
- if (!canRecycle(kindKeyFromClass(kindClass))) {
682
- dbg('recycle-skip-cooldown', kindClass);
683
- break;
684
- }
685
- let recycled = false;
686
- withInternalDomChange(() => {
687
- recycled = removeOneOldWrap(kindClass);
688
- });
689
- dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
690
- // Stop this run after a recycle; the next mutation/scroll will retry injection.
691
- break;
692
- }
609
+ const id = pickIdFromAll(allIds, cursorKey);
610
+ if (!id) break;
611
+
693
612
  const wrap = insertAfter(el, id, kindClass, afterPos);
694
- if (!wrap) {
695
- continue;
696
- }
613
+ if (!wrap) continue;
697
614
 
698
615
  observePlaceholder(id);
699
- inserted += 1;
616
+ inserted++;
700
617
  }
701
618
 
702
619
  return inserted;
703
620
  }
704
621
 
705
- async function insertHeroAdEarly() {
706
- if (state.heroDoneForPage) return;
622
+ async function runCore() {
623
+ if (isBlocked()) return 0;
624
+
625
+ patchShowAds();
626
+
707
627
  const cfg = await fetchConfigOnce();
708
- if (!cfg) { dbg('no-config'); return; }
709
- if (cfg.excluded) { dbg('excluded'); return; }
628
+ if (!cfg || cfg.excluded) return 0;
629
+ initPools(cfg);
630
+
631
+ const kind = getKind();
632
+ let inserted = 0;
633
+
634
+ if (kind === 'topic') {
635
+ if (normalizeBool(cfg.enableMessageAds)) {
636
+ const items = getPostContainers();
637
+ pruneOrphanWraps('ezoic-ad-message', items);
638
+ inserted += injectBetween(
639
+ 'ezoic-ad-message',
640
+ items,
641
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
642
+ normalizeBool(cfg.showFirstMessageAd),
643
+ state.allPosts,
644
+ 'curPosts'
645
+ );
646
+ decluster('ezoic-ad-message');
647
+ }
648
+ } else if (kind === 'categoryTopics') {
649
+ if (normalizeBool(cfg.enableBetweenAds)) {
650
+ const items = getTopicItems();
651
+ pruneOrphanWraps('ezoic-ad-between', items);
652
+ inserted += injectBetween(
653
+ 'ezoic-ad-between',
654
+ items,
655
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
656
+ normalizeBool(cfg.showFirstTopicAd),
657
+ state.allTopics,
658
+ 'curTopics'
659
+ );
660
+ decluster('ezoic-ad-between');
661
+ }
662
+ } else if (kind === 'categories') {
663
+ if (normalizeBool(cfg.enableCategoryAds)) {
664
+ const items = getCategoryItems();
665
+ pruneOrphanWraps('ezoic-ad-categories', items);
666
+ inserted += injectBetween(
667
+ 'ezoic-ad-categories',
668
+ items,
669
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
670
+ normalizeBool(cfg.showFirstCategoryAd),
671
+ state.allCategories,
672
+ 'curCategories'
673
+ );
674
+ decluster('ezoic-ad-categories');
675
+ }
676
+ }
677
+
678
+ return inserted;
679
+ }
680
+
681
+ async function insertHeroAdEarly() {
682
+ if (state.heroDoneForPage || isBlocked()) return;
710
683
 
684
+ const cfg = await fetchConfigOnce();
685
+ if (!cfg || cfg.excluded) return;
711
686
  initPools(cfg);
712
687
 
713
688
  const kind = getKind();
689
+
714
690
  let items = [];
715
691
  let allIds = [];
716
692
  let cursorKey = '';
@@ -742,9 +718,8 @@ function startShow(id) {
742
718
  if (!items.length) return;
743
719
  if (!showFirst) { state.heroDoneForPage = true; return; }
744
720
 
745
- // Insert after the very first item (above-the-fold)
746
721
  const afterPos = 1;
747
- const el = items[afterPos - 1];
722
+ const el = items[0];
748
723
  if (!el || !el.isConnected) return;
749
724
  if (isAdjacentAd(el)) return;
750
725
  if (findWrap(kindClass, afterPos)) { state.heroDoneForPage = true; return; }
@@ -753,119 +728,76 @@ function startShow(id) {
753
728
  if (!id) return;
754
729
 
755
730
  const wrap = insertAfter(el, id, kindClass, afterPos);
756
- if (!wrap) {
757
- return;
758
- }
731
+ if (!wrap) return;
759
732
 
760
733
  state.heroDoneForPage = true;
761
734
  observePlaceholder(id);
762
735
  }
763
736
 
764
- async function runCore() {
765
- if (isBlocked()) { dbg('blocked'); return; }
766
- let insertedThisRun = 0;
767
-
768
- patchShowAds();
737
+ // ---------------- scheduler ----------------
769
738
 
770
- const cfg = await fetchConfigOnce();
771
- if (!cfg) { dbg('no-config'); return; }
772
- if (cfg.excluded) { dbg('excluded'); return; }
773
- initPools(cfg);
774
-
775
- const kind = getKind();
776
-
777
- if (kind === 'topic') {
778
- if (normalizeBool(cfg.enableMessageAds)) {
779
- const __items = getPostContainers();
780
- pruneOrphanWraps('ezoic-ad-message', __items);
781
- insertedThisRun += injectBetween(
782
- 'ezoic-ad-message',
783
- __items,
784
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
785
- normalizeBool(cfg.showFirstMessageAd),
786
- state.allPosts,
787
- 'curPosts'
788
- );
789
- }
790
- } else if (kind === 'categoryTopics') {
791
- if (normalizeBool(cfg.enableBetweenAds)) {
792
- const __items = getTopicItems();
793
- pruneOrphanWraps('ezoic-ad-between', __items);
794
- insertedThisRun += injectBetween(
795
- 'ezoic-ad-between',
796
- __items,
797
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
798
- normalizeBool(cfg.showFirstTopicAd),
799
- state.allTopics,
800
- 'curTopics'
801
- );
802
- }
803
- } else if (kind === 'categories') {
804
- if (normalizeBool(cfg.enableCategoryAds)) {
805
- const __items = getCategoryItems();
806
- pruneOrphanWraps('ezoic-ad-categories', __items);
807
- insertedThisRun += injectBetween(
808
- 'ezoic-ad-categories',
809
- __items,
810
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
811
- normalizeBool(cfg.showFirstCategoryAd),
812
- state.allCategories,
813
- 'curCategories'
814
- );
815
- }
816
- }
817
- }
818
-
819
- function scheduleRun(delayMs = 0) {
820
- // schedule a single run (coalesced)
739
+ function scheduleRun(delayMs = 0, cb) {
821
740
  if (state.runQueued) return;
822
741
  state.runQueued = true;
823
- const doRun = () => {
742
+
743
+ const run = async () => {
824
744
  state.runQueued = false;
825
745
  const pk = getPageKey();
826
746
  if (state.pageKey && pk !== state.pageKey) return;
827
- runCore().catch(() => {});
747
+ let inserted = 0;
748
+ try { inserted = await runCore(); } catch (e) { inserted = 0; }
749
+ try { cb && cb(inserted); } catch (e) {}
828
750
  };
829
- if (delayMs > 0) {
830
- window.setTimeout(() => window.requestAnimationFrame(doRun), delayMs);
831
- } else {
832
- window.requestAnimationFrame(doRun);
833
- }
751
+
752
+ const doRun = () => requestAnimationFrame(run);
753
+ if (delayMs > 0) setTimeout(doRun, delayMs);
754
+ else doRun();
834
755
  }
835
756
 
836
- function scheduleBurst() {
837
- // During ajaxify/infinite scroll, the DOM may arrive in waves.
838
- // We run a small, bounded burst to ensure all 1/X targets are reached.
757
+ function requestBurst() {
758
+ if (isBlocked()) return;
759
+
760
+ const t = now();
761
+ if (t - state.lastBurstReqTs < 120) return;
762
+ state.lastBurstReqTs = t;
763
+
839
764
  const pk = getPageKey();
840
765
  state.pageKey = pk;
841
- state.burstRuns = 0;
842
- const burst = () => {
843
- if (getPageKey() !== pk) return;
844
- if (state.burstRuns >= 6) return;
845
- state.burstRuns += 1;
846
- scheduleRun(0);
847
- // follow-up passes catch late-rendered items (especially on topics)
848
- window.setTimeout(() => scheduleRun(0), 180);
849
- window.setTimeout(() => scheduleRun(0), 650);
850
- window.setTimeout(() => scheduleRun(0), 1400);
766
+
767
+ state.burstDeadline = t + 1800;
768
+ if (state.burstActive) return;
769
+
770
+ state.burstActive = true;
771
+ state.burstCount = 0;
772
+
773
+ const step = () => {
774
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
775
+ if (isBlocked()) { state.burstActive = false; return; }
776
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
777
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
778
+
779
+ state.burstCount++;
780
+ scheduleRun(0, (inserted) => {
781
+ // Continue while we are still inserting or we have pending shows.
782
+ const hasWork = inserted > 0 || state.pending.length > 0;
783
+ if (!hasWork) { state.burstActive = false; return; }
784
+ // Short delay keeps UI smooth while catching late DOM waves.
785
+ setTimeout(step, inserted > 0 ? 120 : 220);
786
+ });
851
787
  };
852
- burst();
853
- }
854
788
 
789
+ step();
790
+ }
855
791
 
856
- // ---------- observers / lifecycle ----------
792
+ // ---------------- lifecycle ----------------
857
793
 
858
794
  function cleanup() {
859
- blockedUntil = Date.now() + 1200;
795
+ blockedUntil = now() + 1200;
860
796
 
861
- // remove all wrappers
862
797
  try {
863
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
864
- releaseWrapNode(el);
865
- });
798
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => releaseWrapNode(el));
866
799
  } catch (e) {}
867
800
 
868
- // reset state
869
801
  state.cfg = null;
870
802
  state.allTopics = [];
871
803
  state.allPosts = [];
@@ -874,21 +806,48 @@ function startShow(id) {
874
806
  state.curPosts = 0;
875
807
  state.curCategories = 0;
876
808
  state.lastShowById.clear();
809
+
877
810
  state.inflight = 0;
878
811
  state.pending = [];
879
- try { state.pendingSet && state.pendingSet.clear(); } catch (e) {}
880
- try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
812
+ state.pendingSet.clear();
813
+
881
814
  state.heroDoneForPage = false;
882
815
 
883
- // keep observers alive (MutationObserver will re-trigger after navigation)
816
+ // keep observers alive
817
+ }
818
+
819
+ function shouldReactToMutations(mutations) {
820
+ // Fast filter: only react if relevant nodes were added/removed.
821
+ for (const m of mutations) {
822
+ if (!m.addedNodes || m.addedNodes.length === 0) continue;
823
+ for (const n of m.addedNodes) {
824
+ if (!n || n.nodeType !== 1) continue;
825
+ const el = /** @type {Element} */ (n);
826
+ if (
827
+ el.matches?.(SELECTORS.postItem) ||
828
+ el.matches?.(SELECTORS.topicItem) ||
829
+ el.matches?.(SELECTORS.categoryItem) ||
830
+ el.querySelector?.(SELECTORS.postItem) ||
831
+ el.querySelector?.(SELECTORS.topicItem) ||
832
+ el.querySelector?.(SELECTORS.categoryItem)
833
+ ) {
834
+ return true;
835
+ }
836
+ }
837
+ }
838
+ return false;
884
839
  }
885
840
 
886
841
  function ensureDomObserver() {
887
842
  if (state.domObs) return;
888
- state.domObs = new MutationObserver(() => {
843
+
844
+ state.domObs = new MutationObserver((mutations) => {
889
845
  if (state.internalDomChange > 0) return;
890
- if (!isBlocked()) scheduleBurst();
846
+ if (isBlocked()) return;
847
+ if (!shouldReactToMutations(mutations)) return;
848
+ requestBurst();
891
849
  });
850
+
892
851
  try {
893
852
  state.domObs.observe(document.body, { childList: true, subtree: true });
894
853
  } catch (e) {}
@@ -912,56 +871,52 @@ function startShow(id) {
912
871
  ensurePreloadObserver();
913
872
  ensureDomObserver();
914
873
 
915
- // Ultra-fast above-the-fold first
916
874
  insertHeroAdEarly().catch(() => {});
917
-
918
- // Then normal insertion
919
- scheduleBurst();
875
+ requestBurst();
920
876
  });
921
877
 
922
- // Infinite scroll / partial updates
923
- $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
924
- if (isBlocked()) return;
925
- scheduleBurst();
926
- });
878
+ $(window).on(
879
+ 'action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite',
880
+ () => {
881
+ if (isBlocked()) return;
882
+ requestBurst();
883
+ }
884
+ );
927
885
  }
928
886
 
929
887
  function bindScroll() {
930
888
  let ticking = false;
931
889
  window.addEventListener('scroll', () => {
932
- // Detect very fast scrolling and temporarily boost preload/parallelism.
890
+ // Fast-scroll boost
933
891
  try {
934
- const now = Date.now();
892
+ const t = now();
935
893
  const y = window.scrollY || window.pageYOffset || 0;
936
894
  if (state.lastScrollTs) {
937
- const dt = now - state.lastScrollTs;
895
+ const dt = t - state.lastScrollTs;
938
896
  const dy = Math.abs(y - (state.lastScrollY || 0));
939
897
  if (dt > 0) {
940
- const speed = dy / dt; // px/ms
898
+ const speed = dy / dt;
941
899
  if (speed >= BOOST_SPEED_PX_PER_MS) {
942
900
  const wasBoosted = isBoosted();
943
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
944
- if (!wasBoosted) {
945
- // margin changed -> rebuild IO so existing placeholders get earlier preload
946
- ensurePreloadObserver();
947
- }
901
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
902
+ if (!wasBoosted) ensurePreloadObserver();
948
903
  }
949
904
  }
950
905
  }
951
906
  state.lastScrollY = y;
952
- state.lastScrollTs = now;
907
+ state.lastScrollTs = t;
953
908
  } catch (e) {}
954
909
 
955
910
  if (ticking) return;
956
911
  ticking = true;
957
- window.requestAnimationFrame(() => {
912
+ requestAnimationFrame(() => {
958
913
  ticking = false;
959
- if (!isBlocked()) scheduleBurst();
914
+ requestBurst();
960
915
  });
961
916
  }, { passive: true });
962
917
  }
963
918
 
964
- // ---------- boot ----------
919
+ // ---------------- boot ----------------
965
920
 
966
921
  state.pageKey = getPageKey();
967
922
  warmUpNetwork();
@@ -972,8 +927,7 @@ function startShow(id) {
972
927
  bindNodeBB();
973
928
  bindScroll();
974
929
 
975
- // First paint: try hero + run
976
930
  blockedUntil = 0;
977
931
  insertHeroAdEarly().catch(() => {});
978
- scheduleBurst();
932
+ requestBurst();
979
933
  })();
package/public/style.css CHANGED
@@ -13,7 +13,8 @@
13
13
  .nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
14
14
  margin: 0 !important;
15
15
  padding: 0 !important;
16
- min-height: 1px; /* keeps placeholder measurable for IO */
16
+ /* Keep the placeholder measurable (IO) but visually negligible */
17
+ min-height: 1px;
17
18
  }
18
19
 
19
20
  /* If Ezoic wraps inside our wrapper, keep it tight */
@@ -23,25 +24,36 @@
23
24
  padding: 0 !important;
24
25
  }
25
26
 
27
+ /* Remove the classic "gap under iframe" (baseline/inline-block) */
28
+ .nodebb-ezoic-wrap,
29
+ .nodebb-ezoic-wrap * {
30
+ line-height: 0 !important;
31
+ font-size: 0 !important;
32
+ }
33
+
34
+ .nodebb-ezoic-wrap iframe,
35
+ .nodebb-ezoic-wrap div[id$="__container__"] iframe {
36
+ display: block !important;
37
+ vertical-align: top !important;
38
+ }
39
+
40
+ .nodebb-ezoic-wrap div[id$="__container__"] {
41
+ display: block !important;
42
+ line-height: 0 !important;
43
+ }
44
+
26
45
 
27
46
  /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
28
47
  .nodebb-ezoic-wrap.is-empty {
29
48
  display: block !important;
30
49
  margin: 0 !important;
31
50
  padding: 0 !important;
32
- height: 0 !important;
33
- min-height: 0 !important;
51
+ /* Don't fully collapse (can prevent fill / triggers "unused"), keep it at 1px */
52
+ height: 1px !important;
53
+ min-height: 1px !important;
34
54
  overflow: hidden !important;
35
55
  }
36
56
 
37
- .nodebb-ezoic-wrap {
38
- min-height: 0 !important;
39
- }
40
-
41
- .nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
42
- min-height: 0 !important;
43
- }
44
-
45
57
  /*
46
58
  Optional: also neutralize spacing on native Ezoic `.ezoic-ad` blocks.
47
59
  (Keeps your previous "CSS very good" behavior.)