nodebb-plugin-ezoic-infinite 1.8.40 → 1.8.41

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.
Files changed (3) hide show
  1. package/library.js +5 -21
  2. package/package.json +1 -1
  3. package/public/client.js +628 -348
package/public/client.js CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v3.0.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.0.0
3
3
  *
4
- * Proper Ezoic infinite scroll API flow:
4
+ * Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
5
5
  *
6
- * FIRST BATCH: ez.define(ids...) → ez.enable()
7
- * NEXT BATCHES: ez.define(ids...) → ez.displayMore(ids...)
8
- * RECYCLE: ez.destroyPlaceholders(id) new placeholder DOM
9
- * ez.define(id) → ez.displayMore(id)
10
- *
11
- * phState machine per placeholder ID:
12
- * newdefineddisplayed[destroyednew] (recycle loop)
6
+ * Key changes from v1.x:
7
+ * - TCF locator: debounced recreation instead of per-mutation, prevents CMP postMessage null errors
8
+ * - MutationObserver: scoped to content containers instead of document.body subtree
9
+ * - Console muting: regex-free, prefix-based matching
10
+ * - showAds batching: microtask-based flush instead of setTimeout
11
+ * - Warm network: runs once per session, not per navigation
12
+ * - State machine: clear lifecycle for placeholders (idle observedqueuedshownrecycled)
13
13
  */
14
14
  (function nbbEzoicInfinite() {
15
15
  'use strict';
@@ -19,6 +19,7 @@
19
19
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
20
20
  const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
21
21
 
22
+ // Data attributes
22
23
  const ATTR = {
23
24
  ANCHOR: 'data-ezoic-anchor',
24
25
  WRAPID: 'data-ezoic-wrapid',
@@ -26,19 +27,25 @@
26
27
  SHOWN: 'data-ezoic-shown',
27
28
  };
28
29
 
30
+ // Timing
29
31
  const TIMING = {
30
32
  EMPTY_CHECK_MS: 20_000,
31
33
  MIN_PRUNE_AGE_MS: 8_000,
32
34
  SHOW_THROTTLE_MS: 900,
33
35
  BURST_COOLDOWN_MS: 200,
34
36
  BLOCK_DURATION_MS: 1_500,
35
- BATCH_FLUSH_MS: 120,
36
- RECYCLE_DESTROY_MS: 300,
37
- RECYCLE_DEFINE_MS: 300,
37
+ SHOW_TIMEOUT_MS: 7_000,
38
+ SHOW_RELEASE_MS: 700,
39
+ BATCH_FLUSH_MS: 80,
40
+ RECYCLE_DELAY_MS: 450,
41
+
38
42
  };
39
43
 
44
+ // Limits
40
45
  const LIMITS = {
41
46
  MAX_INSERTS_RUN: 6,
47
+ MAX_INFLIGHT: 4,
48
+ BATCH_SIZE: 3,
42
49
  MAX_BURST_STEPS: 8,
43
50
  BURST_WINDOW_MS: 2_000,
44
51
  };
@@ -48,39 +55,47 @@
48
55
  MOBILE: '3500px 0px 3500px 0px',
49
56
  };
50
57
 
58
+ // Selectors
51
59
  const SEL = {
52
60
  post: '[component="post"][data-pid]',
53
61
  topic: 'li[component="category/topic"]',
54
62
  category: 'li[component="categories/category"]',
55
63
  };
56
64
 
65
+ // Kind configuration table — single source of truth per ad type
57
66
  const KIND = {
58
67
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
59
68
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
60
69
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
61
70
  };
62
71
 
72
+ // Selector for detecting filled ad slots
63
73
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
64
74
 
65
- const RECYCLE_MIN_AGE_MS = 5_000;
66
-
67
75
  // ── Utility ────────────────────────────────────────────────────────────────
68
76
 
69
77
  const now = () => Date.now();
70
78
  const isMobile = () => window.innerWidth < 768;
71
79
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
72
80
 
73
- function isFilled(node) { return node?.querySelector?.(FILL_SEL) != null; }
81
+ function isFilled(node) {
82
+ return node?.querySelector?.(FILL_SEL) != null;
83
+ }
84
+
74
85
  function isPlaceholderUsed(ph) {
75
86
  if (!ph?.isConnected) return false;
76
87
  return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
77
88
  }
78
89
 
79
90
  function parseIds(raw) {
80
- const out = [], seen = new Set();
91
+ const out = [];
92
+ const seen = new Set();
81
93
  for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
82
94
  const n = parseInt(line, 10);
83
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
95
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
96
+ seen.add(n);
97
+ out.push(n);
98
+ }
84
99
  }
85
100
  return out;
86
101
  }
@@ -88,29 +103,45 @@
88
103
  // ── State ──────────────────────────────────────────────────────────────────
89
104
 
90
105
  const state = {
91
- pageKey: null, kind: null, cfg: null,
106
+ // Page context
107
+ pageKey: null,
108
+ kind: null,
109
+ cfg: null,
92
110
 
111
+ // Pools
93
112
  poolsReady: false,
94
- pools: { topics: [], posts: [], categories: [] },
95
- cursors: { topics: 0, posts: 0, categories: 0 },
113
+ pools: { topics: [], posts: [], categories: [] },
114
+ cursors: { topics: 0, posts: 0, categories: 0 },
115
+
116
+ // Mounted placeholders
117
+ mountedIds: new Set(),
118
+ phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
+ lastShow: new Map(), // id → timestamp
96
120
 
97
- mountedIds: new Set(),
98
- // phState: 'new' | 'defined' | 'displayed' | 'destroyed'
99
- phState: new Map(),
100
- lastShow: new Map(),
101
- wrapByKey: new Map(),
102
- wrapsByClass: new Map(),
121
+ // Wrap registry
122
+ wrapByKey: new Map(), // anchorKey wrap element
123
+ wrapsByClass: new Map(), // kindClass → Set<wrap>
103
124
 
104
- io: null, domObs: null,
105
- mutGuard: 0, blockedUntil: 0,
125
+ // Observers
126
+ io: null,
127
+ domObs: null,
106
128
 
107
- // Ezoic batch queue: ids waiting for define+displayMore
108
- ezBatch: new Set(),
109
- ezFlushTimer: null,
129
+ // Guards
130
+ mutGuard: 0,
131
+ blockedUntil: 0,
110
132
 
111
- // Lifecycle
133
+ // Show queue
134
+ inflight: 0,
135
+ pending: [],
136
+ pendingSet: new Set(),
137
+
138
+ // Scheduler
112
139
  runQueued: false,
113
- burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstTs: 0,
140
+ burstActive: false,
141
+ burstDeadline: 0,
142
+ burstCount: 0,
143
+ lastBurstTs: 0,
144
+ firstShown: false,
114
145
  };
115
146
 
116
147
  const isBlocked = () => now() < state.blockedUntil;
@@ -124,10 +155,15 @@
124
155
 
125
156
  async function fetchConfig() {
126
157
  if (state.cfg) return state.cfg;
158
+ // Prefer inline config injected by server (zero latency)
127
159
  try {
128
160
  const inline = window.__nbbEzoicCfg;
129
- if (inline && typeof inline === 'object') { state.cfg = inline; return state.cfg; }
161
+ if (inline && typeof inline === 'object') {
162
+ state.cfg = inline;
163
+ return state.cfg;
164
+ }
130
165
  } catch (_) {}
166
+ // Fallback to API
131
167
  try {
132
168
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
133
169
  if (r.ok) state.cfg = await r.json();
@@ -159,22 +195,27 @@
159
195
  if (/^\/topic\//.test(p)) return 'topic';
160
196
  if (/^\/category\//.test(p)) return 'categoryTopics';
161
197
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
+ // DOM fallback
162
199
  if (document.querySelector(SEL.category)) return 'categories';
163
200
  if (document.querySelector(SEL.post)) return 'topic';
164
201
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
165
202
  return 'other';
166
203
  }
167
204
 
168
- function getKind() { return state.kind || (state.kind = detectKind()); }
205
+ function getKind() {
206
+ return state.kind || (state.kind = detectKind());
207
+ }
169
208
 
170
209
  // ── DOM queries ────────────────────────────────────────────────────────────
171
210
 
172
211
  function getPosts() {
173
- const all = document.querySelectorAll(SEL.post), out = [];
212
+ const all = document.querySelectorAll(SEL.post);
213
+ const out = [];
174
214
  for (let i = 0; i < all.length; i++) {
175
215
  const el = all[i];
176
216
  if (!el.isConnected) continue;
177
217
  if (!el.querySelector('[component="post/content"]')) continue;
218
+ // Skip nested quotes / parent posts
178
219
  const parent = el.parentElement?.closest(SEL.post);
179
220
  if (parent && parent !== el) continue;
180
221
  if (el.getAttribute('component') === 'post/parent') continue;
@@ -182,6 +223,7 @@
182
223
  }
183
224
  return out;
184
225
  }
226
+
185
227
  function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
186
228
  function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
187
229
 
@@ -189,10 +231,16 @@
189
231
 
190
232
  function stableId(klass, el) {
191
233
  const attr = KIND[klass]?.anchorAttr;
192
- if (attr) { const v = el.getAttribute(attr); if (v != null && v !== '') return v; }
234
+ if (attr) {
235
+ const v = el.getAttribute(attr);
236
+ if (v != null && v !== '') return v;
237
+ }
238
+ // Positional fallback
193
239
  const children = el.parentElement?.children;
194
240
  if (!children) return 'i0';
195
- for (let i = 0; i < children.length; i++) { if (children[i] === el) return `i${i}`; }
241
+ for (let i = 0; i < children.length; i++) {
242
+ if (children[i] === el) return `i${i}`;
243
+ }
196
244
  return 'i0';
197
245
  }
198
246
 
@@ -204,30 +252,95 @@
204
252
  }
205
253
 
206
254
  function getWrapSet(klass) {
207
- let s = state.wrapsByClass.get(klass);
208
- if (!s) { s = new Set(); state.wrapsByClass.set(klass, s); }
209
- return s;
255
+ let set = state.wrapsByClass.get(klass);
256
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
257
+ return set;
258
+ }
259
+
260
+ // ── Garbage collection (NodeBB post virtualization safe) ─────────────────
261
+ //
262
+ // NodeBB can remove large portions of the DOM during infinite scrolling
263
+ // (especially posts in topics). If a wrap is removed outside of our own
264
+ // dropWrap(), we must free its placeholder id; otherwise the pool appears
265
+ // exhausted and ads won't re-insert when the user scrolls back up.
266
+
267
+ function gcDisconnectedWraps() {
268
+ // 1) Clean wrapByKey
269
+ for (const [key, w] of Array.from(state.wrapByKey.entries())) {
270
+ if (!w?.isConnected) state.wrapByKey.delete(key);
271
+ }
272
+
273
+ // 2) Clean wrapsByClass sets and free ids
274
+ for (const [klass, set] of Array.from(state.wrapsByClass.entries())) {
275
+ for (const w of Array.from(set)) {
276
+ if (w?.isConnected) continue;
277
+ set.delete(w);
278
+ try {
279
+ const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
280
+ if (Number.isFinite(id)) {
281
+ state.mountedIds.delete(id);
282
+ state.phState.delete(id);
283
+ state.lastShow.delete(id);
284
+ }
285
+ } catch (_) {}
286
+ }
287
+ if (!set.size) state.wrapsByClass.delete(klass);
288
+ }
289
+
290
+ // 3) Authoritative rebuild of mountedIds from the live DOM
291
+ try {
292
+ const live = new Set();
293
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
294
+ const id = parseInt(w.getAttribute(ATTR.WRAPID) || '0', 10);
295
+ if (id > 0) live.add(id);
296
+ }
297
+ // Drop any ids that are no longer live
298
+ for (const id of Array.from(state.mountedIds)) {
299
+ if (!live.has(id)) {
300
+ state.mountedIds.delete(id);
301
+ state.phState.delete(id);
302
+ state.lastShow.delete(id);
303
+ }
304
+ }
305
+ } catch (_) {}
210
306
  }
211
307
 
212
- // ── Wrap lifecycle ─────────────────────────────────────────────────────────
308
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
213
309
 
310
+ /**
311
+ * Check if a wrap element still has its corresponding anchor in the DOM.
312
+ * Uses O(1) registry lookup first, then sibling scan, then global querySelector.
313
+ */
214
314
  function wrapIsLive(wrap) {
215
315
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
216
316
  const key = wrap.getAttribute(ATTR.ANCHOR);
217
317
  if (!key) return false;
318
+
319
+ // Fast path: registry match
218
320
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
321
+
322
+ // Parse key
219
323
  const colonIdx = key.indexOf(':');
220
- const klass = key.slice(0, colonIdx), anchorId = key.slice(colonIdx + 1);
324
+ const klass = key.slice(0, colonIdx);
325
+ const anchorId = key.slice(colonIdx + 1);
221
326
  const cfg = KIND[klass];
222
327
  if (!cfg) return false;
328
+
329
+ // Sibling scan (cheap for adjacent anchors)
223
330
  const parent = wrap.parentElement;
224
331
  if (parent) {
225
332
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
226
333
  for (const sib of parent.children) {
227
- if (sib !== wrap) { try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {} }
334
+ if (sib !== wrap) {
335
+ try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
336
+ }
228
337
  }
229
338
  }
230
- try { return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true; } catch (_) { return false; }
339
+
340
+ // Global fallback (expensive, rare)
341
+ try {
342
+ return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
343
+ } catch (_) { return false; }
231
344
  }
232
345
 
233
346
  function adjacentWrap(el) {
@@ -242,18 +355,21 @@
242
355
  if (!ph || !isFilled(ph)) return false;
243
356
  wrap.classList.remove('is-empty');
244
357
  const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
245
- if (id > 0) state.phState.set(id, 'displayed');
358
+ if (id > 0) state.phState.set(id, 'shown');
246
359
  return true;
247
360
  }
248
361
 
249
362
  function scheduleUncollapseChecks(wrap) {
250
363
  if (!wrap) return;
251
- for (const ms of [500, 1500, 3000, 7000, 15000]) {
252
- setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
364
+ const delays = [500, 1500, 3000, 7000, 15000];
365
+ for (const ms of delays) {
366
+ setTimeout(() => {
367
+ try { clearEmptyIfFilled(wrap); } catch (_) {}
368
+ }, ms);
253
369
  }
254
370
  }
255
371
 
256
- // ── Pool ───────────────────────────────────────────────────────────────────
372
+ // ── Pool management ────────────────────────────────────────────────────────
257
373
 
258
374
  function pickId(poolKey) {
259
375
  const pool = state.pools[poolKey];
@@ -267,153 +383,12 @@
267
383
  return null;
268
384
  }
269
385
 
270
- // ── Ezoic API layer ────────────────────────────────────────────────────────
271
- //
272
- // Correct Ezoic infinite scroll flow:
273
- // First batch: ez.cmd.push(() => { ez.define(...ids); ez.enable(); })
274
- // Next batches: ez.cmd.push(() => { ez.define(...ids); ez.displayMore(...ids); })
275
- // Recycle: ez.cmd.push(() => { ez.destroyPlaceholders(id); })
276
- // → wait → new DOM → ez.cmd.push(() => { ez.define(id); ez.displayMore(id); })
277
- //
278
- // We batch define+displayMore calls using ezBatch to avoid calling the
279
- // Ezoic API on every single placeholder insertion.
280
-
281
- function ezCmd(fn) {
282
- window.ezstandalone = window.ezstandalone || {};
283
- const ez = window.ezstandalone;
284
- if (Array.isArray(ez.cmd)) {
285
- ez.cmd.push(fn);
286
- } else {
287
- try { fn(); } catch (_) {}
288
- }
289
- }
290
-
291
- /**
292
- * Queue a placeholder ID for the next batched define+displayMore call.
293
- */
294
- function ezEnqueue(id) {
295
- if (isBlocked()) return;
296
- state.ezBatch.add(id);
297
- if (!state.ezFlushTimer) {
298
- state.ezFlushTimer = setTimeout(ezFlush, TIMING.BATCH_FLUSH_MS);
299
- }
300
- }
301
-
302
- /**
303
- * Flush: define new IDs with Ezoic, then call displayMore.
304
- *
305
- * IMPORTANT: We NEVER call ez.enable() — Ezoic's sa.min.js calls it
306
- * automatically on page load. Calling it again causes
307
- * "Enable should only ever be called once" error.
308
- *
309
- * We also check ez.getSelectedPlaceholders() or equivalent to skip
310
- * IDs that Ezoic already knows about (defined during initial enable).
311
- */
312
- function ezFlush() {
313
- state.ezFlushTimer = null;
314
- if (isBlocked() || !state.ezBatch.size) return;
315
-
316
- // Filter to only valid, connected placeholders
317
- const ids = [];
318
- for (const id of state.ezBatch) {
319
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
320
- if (!ph?.isConnected) { state.phState.delete(id); continue; }
321
- if (isPlaceholderUsed(ph)) { state.phState.set(id, 'displayed'); continue; }
322
- ids.push(id);
323
- }
324
- state.ezBatch.clear();
325
- if (!ids.length) return;
326
-
327
- ezCmd(() => {
328
- const ez = window.ezstandalone;
329
- if (!ez) return;
330
-
331
- // Check which IDs Ezoic already knows about to avoid "already defined"
332
- const alreadyDefined = new Set();
333
- try {
334
- // ez.allPlaceholders is an array of IDs Ezoic has already seen
335
- if (Array.isArray(ez.allPlaceholders)) {
336
- for (const p of ez.allPlaceholders) alreadyDefined.add(Number(p));
337
- }
338
- } catch (_) {}
339
-
340
- const toDefine = ids.filter(id => !alreadyDefined.has(id));
341
- // All IDs get displayMore, but only new ones get define
342
- const toDisplay = ids;
343
-
344
- // define() only for truly new placeholders
345
- if (toDefine.length) {
346
- try {
347
- if (typeof ez.define === 'function') {
348
- ez.define(...toDefine);
349
- }
350
- } catch (_) {}
351
- }
352
-
353
- for (const id of ids) state.phState.set(id, 'defined');
354
-
355
- // displayMore() for all IDs — this is the infinite scroll API
356
- if (toDisplay.length) {
357
- try {
358
- if (typeof ez.displayMore === 'function') {
359
- ez.displayMore(...toDisplay);
360
- }
361
- } catch (_) {}
362
- }
363
-
364
- // Mark as displayed and schedule fill checks
365
- for (const id of ids) {
366
- state.phState.set(id, 'displayed');
367
- state.lastShow.set(id, now());
368
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
369
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
370
- if (wrap) {
371
- wrap.setAttribute(ATTR.SHOWN, String(now()));
372
- scheduleUncollapseChecks(wrap);
373
- }
374
- scheduleEmptyCheck(id);
375
- }
376
- });
377
- }
386
+ // ── Recycling ──────────────────────────────────────────────────────────────
378
387
 
379
388
  /**
380
- * Destroy a placeholder in Ezoic (for recycling).
381
- * Returns a Promise that resolves after the destroy delay.
389
+ * When pool is exhausted, recycle a wrap far above the viewport.
390
+ * Sequence: destroy delay re-observe enqueueShow
382
391
  */
383
- function ezDestroy(id) {
384
- return new Promise(resolve => {
385
- ezCmd(() => {
386
- state.phState.set(id, 'destroyed');
387
- const ez = window.ezstandalone;
388
- try {
389
- if (typeof ez?.destroyPlaceholders === 'function') {
390
- ez.destroyPlaceholders(id);
391
- }
392
- } catch (_) {}
393
- setTimeout(resolve, TIMING.RECYCLE_DESTROY_MS);
394
- });
395
- });
396
- }
397
-
398
- function scheduleEmptyCheck(id) {
399
- const showTs = now();
400
- setTimeout(() => {
401
- try {
402
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
403
- const wrap = ph?.closest(`.${WRAP_CLASS}`);
404
- if (!wrap || !ph?.isConnected) return;
405
- if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
406
- if (clearEmptyIfFilled(wrap)) return;
407
- wrap.classList.add('is-empty');
408
- } catch (_) {}
409
- }, TIMING.EMPTY_CHECK_MS);
410
- }
411
-
412
- // ── Recycling ──────────────────────────────────────────────────────────────
413
- //
414
- // When pool is exhausted, find the farthest wrap above viewport,
415
- // destroy its Ezoic placeholder, recreate the DOM, then define+displayMore.
416
-
417
392
  function recycleWrap(klass, targetEl, newKey) {
418
393
  const ez = window.ezstandalone;
419
394
  if (typeof ez?.destroyPlaceholders !== 'function' ||
@@ -421,8 +396,7 @@
421
396
  typeof ez?.displayMore !== 'function') return null;
422
397
 
423
398
  const vh = window.innerHeight || 800;
424
- const threshold = -(3 * vh);
425
- const t = now();
399
+ const threshold = -vh;
426
400
  let bestEmpty = null, bestEmptyY = Infinity;
427
401
  let bestFull = null, bestFullY = Infinity;
428
402
 
@@ -431,12 +405,6 @@
431
405
 
432
406
  for (const wrap of wraps) {
433
407
  try {
434
- const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
435
- if (t - created < RECYCLE_MIN_AGE_MS) continue;
436
- const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
437
- const st = state.phState.get(wid);
438
- // Don't recycle placeholders that are still being processed
439
- if (st === 'new' || st === 'defined') continue;
440
408
  const bottom = wrap.getBoundingClientRect().bottom;
441
409
  if (bottom > threshold) continue;
442
410
  if (!isFilled(wrap)) {
@@ -455,46 +423,48 @@
455
423
 
456
424
  const oldKey = best.getAttribute(ATTR.ANCHOR);
457
425
 
458
- // Unobserve before moving
426
+ // Unobserve before moving to prevent stale showAds
459
427
  try {
460
428
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
461
429
  if (ph) state.io?.unobserve(ph);
462
430
  } catch (_) {}
463
431
 
464
- // Step 1: Destroy in Ezoic, then recreate DOM and re-define
465
- state.phState.set(id, 'destroyed');
432
+ // Move the wrap to new position
433
+ mutate(() => {
434
+ best.setAttribute(ATTR.ANCHOR, newKey);
435
+ best.setAttribute(ATTR.CREATED, String(now()));
436
+ best.setAttribute(ATTR.SHOWN, '0');
437
+ best.classList.remove('is-empty');
438
+ best.replaceChildren();
439
+
440
+ const fresh = document.createElement('div');
441
+ fresh.id = `${PH_PREFIX}${id}`;
442
+ fresh.setAttribute('data-ezoic-id', String(id));
443
+ fresh.style.minHeight = '1px';
444
+ best.appendChild(fresh);
445
+ targetEl.insertAdjacentElement('afterend', best);
446
+ });
466
447
 
467
- ezCmd(() => {
468
- try { ez.destroyPlaceholders(id); } catch (_) {}
448
+ // Update registry
449
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
450
+ state.wrapByKey.set(newKey, best);
469
451
 
452
+ // Ezoic recycle sequence
453
+ const doDestroy = () => {
454
+ state.phState.set(id, 'destroyed');
455
+ try { ez.destroyPlaceholders(id); } catch (_) {
456
+ try { ez.destroyPlaceholders([id]); } catch (_) {}
457
+ }
470
458
  setTimeout(() => {
471
- // Step 2: Recreate placeholder DOM at new position
472
- mutate(() => {
473
- best.setAttribute(ATTR.ANCHOR, newKey);
474
- best.setAttribute(ATTR.CREATED, String(now()));
475
- best.setAttribute(ATTR.SHOWN, '0');
476
- best.classList.remove('is-empty');
477
- best.replaceChildren();
478
-
479
- const fresh = document.createElement('div');
480
- fresh.id = `${PH_PREFIX}${id}`;
481
- fresh.setAttribute('data-ezoic-id', String(id));
482
- fresh.style.minHeight = '1px';
483
- best.appendChild(fresh);
484
- targetEl.insertAdjacentElement('afterend', best);
485
- });
486
-
487
- if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
488
- state.wrapByKey.set(newKey, best);
459
+ try { observePlaceholder(id); } catch (_) {}
460
+ state.phState.set(id, 'new');
461
+ try { enqueueShow(id); } catch (_) {}
462
+ }, TIMING.RECYCLE_DELAY_MS);
463
+ };
489
464
 
490
- // Step 3: Re-define + displayMore via the batch queue
491
- setTimeout(() => {
492
- state.phState.set(id, 'new');
493
- observePlaceholder(id);
494
- ezEnqueue(id);
495
- }, TIMING.RECYCLE_DEFINE_MS);
496
- }, TIMING.RECYCLE_DESTROY_MS);
497
- });
465
+ try {
466
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
467
+ } catch (_) {}
498
468
 
499
469
  return { id, wrap: best };
500
470
  }
@@ -509,6 +479,7 @@
509
479
  w.setAttribute(ATTR.CREATED, String(now()));
510
480
  w.setAttribute(ATTR.SHOWN, '0');
511
481
  w.style.cssText = 'width:100%;display:block';
482
+
512
483
  const ph = document.createElement('div');
513
484
  ph.id = `${PH_PREFIX}${id}`;
514
485
  ph.setAttribute('data-ezoic-id', String(id));
@@ -521,8 +492,10 @@
521
492
  if (!el?.insertAdjacentElement) return null;
522
493
  if (findWrap(key)) return null;
523
494
  if (state.mountedIds.has(id)) return null;
495
+ // Ensure no duplicate DOM element with same placeholder ID
524
496
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
525
497
  if (existing?.isConnected) return null;
498
+
526
499
  const w = makeWrap(id, klass, key);
527
500
  mutate(() => el.insertAdjacentElement('afterend', w));
528
501
  state.mountedIds.add(id);
@@ -536,13 +509,21 @@
536
509
  try {
537
510
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
538
511
  if (ph instanceof Element) state.io?.unobserve(ph);
512
+
539
513
  const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
540
- if (Number.isFinite(id)) { state.mountedIds.delete(id); state.phState.delete(id); }
514
+ if (Number.isFinite(id)) {
515
+ state.mountedIds.delete(id);
516
+ state.phState.delete(id);
517
+ }
518
+
541
519
  const key = w.getAttribute(ATTR.ANCHOR);
542
520
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
521
+
522
+ // Find the kind class to unregister
543
523
  for (const cls of w.classList) {
544
524
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
545
- state.wrapsByClass.get(cls)?.delete(w); break;
525
+ state.wrapsByClass.get(cls)?.delete(w);
526
+ break;
546
527
  }
547
528
  }
548
529
  w.remove();
@@ -550,24 +531,33 @@
550
531
  }
551
532
 
552
533
  // ── Prune (category topic lists only) ──────────────────────────────────────
534
+ //
535
+ // Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
536
+ // NOT safe for posts: NodeBB virtualizes posts off-viewport.
553
537
 
554
538
  function pruneOrphansBetween() {
555
539
  const klass = 'ezoic-ad-between';
556
540
  const cfg = KIND[klass];
557
541
  const wraps = state.wrapsByClass.get(klass);
558
542
  if (!wraps?.size) return;
543
+
544
+ // Build set of live anchor IDs
559
545
  const liveAnchors = new Set();
560
546
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
561
547
  const v = el.getAttribute(cfg.anchorAttr);
562
548
  if (v) liveAnchors.add(v);
563
549
  }
550
+
564
551
  const t = now();
565
552
  for (const w of wraps) {
566
553
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
567
554
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
555
+
568
556
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
569
557
  const sid = key.slice(klass.length + 1);
570
- if (!sid || !liveAnchors.has(sid)) mutate(() => dropWrap(w));
558
+ if (!sid || !liveAnchors.has(sid)) {
559
+ mutate(() => dropWrap(w));
560
+ }
571
561
  }
572
562
  }
573
563
 
@@ -579,6 +569,7 @@
579
569
  const v = el.getAttribute(attr);
580
570
  if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
581
571
  }
572
+ // Positional fallback
582
573
  const fullSel = KIND[klass]?.sel ?? '';
583
574
  let i = 0;
584
575
  for (const s of el.parentElement?.children ?? []) {
@@ -595,9 +586,11 @@
595
586
  for (const el of items) {
596
587
  if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
597
588
  if (!el?.isConnected) continue;
589
+
598
590
  const ord = ordinal(klass, el);
599
591
  if (!(showFirst && ord === 0) && (ord + 1) % interval !== 0) continue;
600
592
  if (adjacentWrap(el)) continue;
593
+
601
594
  const key = anchorKey(klass, el);
602
595
  if (findWrap(key)) continue;
603
596
 
@@ -606,13 +599,12 @@
606
599
  const w = insertAfter(el, id, klass, key);
607
600
  if (w) {
608
601
  observePlaceholder(id);
609
- // Queue for batched define+enable/displayMore
610
- ezEnqueue(id);
602
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
611
603
  inserted++;
612
604
  }
613
605
  } else {
614
606
  const recycled = recycleWrap(klass, el, key);
615
- if (!recycled) break;
607
+ if (!recycled) break; // Pool truly exhausted
616
608
  inserted++;
617
609
  }
618
610
  }
@@ -620,12 +612,6 @@
620
612
  }
621
613
 
622
614
  // ── IntersectionObserver ───────────────────────────────────────────────────
623
- //
624
- // The IO is used to eagerly observe placeholders so that when they enter
625
- // the viewport margin, we can queue them for Ezoic. However, the actual
626
- // Ezoic API calls (define/displayMore) happen in the batched flush.
627
- // The IO callback is mainly useful for re-triggering after NodeBB
628
- // virtualisation re-inserts posts.
629
615
 
630
616
  function getIO() {
631
617
  if (state.io) return state.io;
@@ -633,19 +619,9 @@
633
619
  state.io = new IntersectionObserver(entries => {
634
620
  for (const entry of entries) {
635
621
  if (!entry.isIntersecting) continue;
636
- const target = entry.target;
637
- if (target instanceof Element) state.io?.unobserve(target);
638
- const id = parseInt(target.getAttribute('data-ezoic-id'), 10);
639
- if (!id || id <= 0) continue;
640
- const st = state.phState.get(id);
641
- // Only enqueue if not yet processed by Ezoic
642
- if (st === 'new') {
643
- ezEnqueue(id);
644
- } else if (st === 'displayed') {
645
- // Already shown — check if the placeholder is actually filled.
646
- // If not (Ezoic had no ad), don't re-trigger — it won't help.
647
- // If yes, nothing to do.
648
- }
622
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
623
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
624
+ if (id > 0) enqueueShow(id);
649
625
  }
650
626
  }, {
651
627
  root: null,
@@ -658,16 +634,193 @@
658
634
 
659
635
  function observePlaceholder(id) {
660
636
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
661
- if (ph?.isConnected) { try { getIO()?.observe(ph); } catch (_) {} }
637
+ if (ph?.isConnected) {
638
+ try { getIO()?.observe(ph); } catch (_) {}
639
+ }
640
+ }
641
+
642
+ // ── Show queue ─────────────────────────────────────────────────────────────
643
+
644
+ function enqueueShow(id) {
645
+ if (!id || isBlocked()) return;
646
+ const st = state.phState.get(id);
647
+ if (st === 'show-queued' || st === 'shown') return;
648
+ if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
649
+
650
+ if (state.inflight >= LIMITS.MAX_INFLIGHT) {
651
+ if (!state.pendingSet.has(id)) {
652
+ state.pending.push(id);
653
+ state.pendingSet.add(id);
654
+ state.phState.set(id, 'show-queued');
655
+ }
656
+ return;
657
+ }
658
+ state.phState.set(id, 'show-queued');
659
+ startShow(id);
660
+ }
661
+
662
+ function drainQueue() {
663
+ if (isBlocked()) return;
664
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
665
+ const id = state.pending.shift();
666
+ state.pendingSet.delete(id);
667
+ startShow(id);
668
+ }
669
+ }
670
+
671
+ function startShow(id) {
672
+ if (!id || isBlocked()) return;
673
+ state.inflight++;
674
+
675
+ let released = false;
676
+ const release = () => {
677
+ if (released) return;
678
+ released = true;
679
+ state.inflight = Math.max(0, state.inflight - 1);
680
+ drainQueue();
681
+ };
682
+ const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
683
+
684
+ requestAnimationFrame(() => {
685
+ try {
686
+ if (isBlocked()) { clearTimeout(timer); return release(); }
687
+
688
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
689
+ if (!ph?.isConnected) {
690
+ state.phState.delete(id);
691
+ clearTimeout(timer);
692
+ return release();
693
+ }
694
+
695
+ if (isFilled(ph) || isPlaceholderUsed(ph)) {
696
+ state.phState.set(id, 'shown');
697
+ clearTimeout(timer);
698
+ return release();
699
+ }
700
+
701
+ const t = now();
702
+ if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
703
+ clearTimeout(timer);
704
+ return release();
705
+ }
706
+ state.lastShow.set(id, t);
707
+
708
+ try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
709
+ state.phState.set(id, 'shown');
710
+
711
+ window.ezstandalone = window.ezstandalone || {};
712
+ const ez = window.ezstandalone;
713
+
714
+ const doShow = () => {
715
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
716
+ try { ez.showAds(id); } catch (_) {}
717
+ if (wrap) scheduleUncollapseChecks(wrap);
718
+ scheduleEmptyCheck(id, t);
719
+ setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
720
+ };
721
+
722
+ Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
723
+ } catch (_) {
724
+ clearTimeout(timer);
725
+ release();
726
+ }
727
+ });
728
+ }
729
+
730
+ function scheduleEmptyCheck(id, showTs) {
731
+ setTimeout(() => {
732
+ try {
733
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
734
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
735
+ if (!wrap || !ph?.isConnected) return;
736
+ // Skip if a newer show happened since
737
+ if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
738
+ if (clearEmptyIfFilled(wrap)) return;
739
+ wrap.classList.add('is-empty');
740
+ } catch (_) {}
741
+ }, TIMING.EMPTY_CHECK_MS);
742
+ }
743
+
744
+ // ── Patch Ezoic showAds ────────────────────────────────────────────────────
745
+ //
746
+ // Intercepts ez.showAds() to:
747
+ // - block calls during navigation transitions
748
+ // - filter out disconnected placeholders
749
+ // - batch calls for efficiency
750
+
751
+ function patchShowAds() {
752
+ const apply = () => {
753
+ try {
754
+ window.ezstandalone = window.ezstandalone || {};
755
+ const ez = window.ezstandalone;
756
+ if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
757
+ window.__nbbEzPatched = true;
758
+
759
+ const orig = ez.showAds.bind(ez);
760
+ const queue = new Set();
761
+ let flushTimer = null;
762
+
763
+ const flush = () => {
764
+ flushTimer = null;
765
+ if (isBlocked() || !queue.size) return;
766
+
767
+ const ids = Array.from(queue).sort((a, b) => a - b);
768
+ queue.clear();
769
+
770
+ const valid = ids.filter(id => {
771
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
772
+ if (!ph?.isConnected) { state.phState.delete(id); return false; }
773
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
774
+ return true;
775
+ });
776
+
777
+ for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
778
+ const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
779
+ try { orig(...chunk); } catch (_) {
780
+ for (const cid of chunk) { try { orig(cid); } catch (_) {} }
781
+ }
782
+ }
783
+ };
784
+
785
+ ez.showAds = function (...args) {
786
+ if (isBlocked()) return;
787
+ const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
788
+ for (const v of ids) {
789
+ const id = parseInt(v, 10);
790
+ if (!Number.isFinite(id) || id <= 0) continue;
791
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
792
+ if (!ph?.isConnected) continue;
793
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
794
+ state.phState.set(id, 'show-queued');
795
+ queue.add(id);
796
+ }
797
+ if (queue.size && !flushTimer) {
798
+ flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
799
+ }
800
+ };
801
+ } catch (_) {}
802
+ };
803
+
804
+ apply();
805
+ if (!window.__nbbEzPatched) {
806
+ window.ezstandalone = window.ezstandalone || {};
807
+ (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
808
+ }
662
809
  }
663
810
 
664
811
  // ── Core ───────────────────────────────────────────────────────────────────
665
812
 
666
813
  async function runCore() {
667
814
  if (isBlocked()) return 0;
815
+
816
+ // Keep internal pools in sync with NodeBB DOM virtualization/removals
817
+ // so ads can re-insert correctly when users scroll back up.
818
+ try { gcDisconnectedWraps(); } catch (_) {}
819
+
668
820
  const cfg = await fetchConfig();
669
821
  if (!cfg || cfg.excluded) return 0;
670
822
  initPools(cfg);
823
+
671
824
  const kind = getKind();
672
825
  if (kind === 'other') return 0;
673
826
 
@@ -678,16 +831,24 @@
678
831
  };
679
832
 
680
833
  if (kind === 'topic') {
681
- return exec('ezoic-ad-message', getPosts,
682
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
834
+ return exec(
835
+ 'ezoic-ad-message', getPosts,
836
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
837
+ );
683
838
  }
839
+
684
840
  if (kind === 'categoryTopics') {
685
841
  pruneOrphansBetween();
686
- return exec('ezoic-ad-between', getTopics,
687
- cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
842
+ return exec(
843
+ 'ezoic-ad-between', getTopics,
844
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics'
845
+ );
688
846
  }
689
- return exec('ezoic-ad-categories', getCategories,
690
- cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
847
+
848
+ return exec(
849
+ 'ezoic-ad-categories', getCategories,
850
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories'
851
+ );
691
852
  }
692
853
 
693
854
  // ── Scheduler & burst ──────────────────────────────────────────────────────
@@ -711,68 +872,94 @@
711
872
  state.lastBurstTs = t;
712
873
  state.pageKey = pageKey();
713
874
  state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
875
+
714
876
  if (state.burstActive) return;
715
877
  state.burstActive = true;
716
878
  state.burstCount = 0;
879
+
717
880
  const step = () => {
718
881
  if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
719
- state.burstActive = false; return;
882
+ state.burstActive = false;
883
+ return;
720
884
  }
721
885
  state.burstCount++;
722
886
  scheduleRun(n => {
723
- if (!n && !state.ezBatch.size) { state.burstActive = false; return; }
887
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
724
888
  setTimeout(step, n > 0 ? 150 : 300);
725
889
  });
726
890
  };
727
891
  step();
728
892
  }
729
893
 
730
- // ── Cleanup ────────────────────────────────────────────────────────────────
894
+ // ── Cleanup on navigation ──────────────────────────────────────────────────
731
895
 
732
896
  function cleanup() {
733
897
  state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
734
-
735
- // Cancel pending Ezoic batch
736
- if (state.ezFlushTimer) { clearTimeout(state.ezFlushTimer); state.ezFlushTimer = null; }
737
- state.ezBatch.clear();
738
-
739
898
  mutate(() => {
740
- for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) dropWrap(w);
899
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
900
+ dropWrap(w);
901
+ }
741
902
  });
742
- state.cfg = null;
743
- state.poolsReady = false;
744
- state.pools = { topics: [], posts: [], categories: [] };
745
- state.cursors = { topics: 0, posts: 0, categories: 0 };
903
+ state.cfg = null;
904
+ state.poolsReady = false;
905
+ state.pools = { topics: [], posts: [], categories: [] };
906
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
746
907
  state.mountedIds.clear();
747
908
  state.lastShow.clear();
748
909
  state.wrapByKey.clear();
749
910
  state.wrapsByClass.clear();
750
- state.kind = null;
911
+ state.kind = null;
751
912
  state.phState.clear();
913
+ state.inflight = 0;
914
+ state.pending = [];
915
+ state.pendingSet.clear();
752
916
  state.burstActive = false;
753
917
  state.runQueued = false;
918
+ state.firstShown = false;
754
919
  }
755
920
 
756
921
  // ── MutationObserver ───────────────────────────────────────────────────────
922
+ //
923
+ // Scoped to detect: (1) ad fill events in wraps, (2) new content items
757
924
 
758
925
  function ensureDomObserver() {
759
926
  if (state.domObs) return;
927
+
760
928
  state.domObs = new MutationObserver(muts => {
761
929
  if (state.mutGuard > 0 || isBlocked()) return;
930
+
762
931
  let needsBurst = false;
932
+
933
+ // Determine relevant selectors for current page kind
763
934
  const kind = getKind();
764
935
  const relevantSels =
765
- kind === 'topic' ? [SEL.post] :
766
- kind === 'categoryTopics' ? [SEL.topic] :
767
- kind === 'categories' ? [SEL.category] :
768
- [SEL.post, SEL.topic, SEL.category];
936
+ kind === 'topic' ? [SEL.post] :
937
+ kind === 'categoryTopics'? [SEL.topic] :
938
+ kind === 'categories' ? [SEL.category] :
939
+ [SEL.post, SEL.topic, SEL.category];
769
940
 
770
941
  for (const m of muts) {
771
942
  if (m.type !== 'childList') continue;
943
+
944
+ // If NodeBB removed wraps as part of virtualization, free ids immediately
945
+ for (const node of m.removedNodes) {
946
+ if (!(node instanceof Element)) continue;
947
+ try {
948
+ if (node.classList?.contains(WRAP_CLASS)) {
949
+ dropWrap(node);
950
+ } else {
951
+ const wraps = node.querySelectorAll?.(`.${WRAP_CLASS}`);
952
+ if (wraps?.length) {
953
+ for (const w of wraps) dropWrap(w);
954
+ }
955
+ }
956
+ } catch (_) {}
957
+ }
958
+
772
959
  for (const node of m.addedNodes) {
773
960
  if (!(node instanceof Element)) continue;
774
961
 
775
- // Ad fill detection
962
+ // Check for ad fill events in wraps
776
963
  try {
777
964
  if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
778
965
  const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
@@ -781,121 +968,178 @@
781
968
  }
782
969
  } catch (_) {}
783
970
 
784
- // Re-observe wraps re-inserted by NodeBB virtualization
785
- try {
786
- const wraps = node.classList?.contains(WRAP_CLASS)
787
- ? [node]
788
- : Array.from(node.querySelectorAll?.(`.${WRAP_CLASS}`) || []);
789
- for (const wrap of wraps) {
790
- const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
791
- if (!id || id <= 0) continue;
792
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
793
- if (ph) { try { getIO()?.observe(ph); } catch (_) {} }
794
- }
795
- } catch (_) {}
796
-
797
- // New content detection
971
+ // Check for new content items (posts, topics, categories)
798
972
  if (!needsBurst) {
799
973
  for (const sel of relevantSels) {
800
974
  try {
801
- if (node.matches(sel) || node.querySelector(sel)) { needsBurst = true; break; }
975
+ if (node.matches(sel) || node.querySelector(sel)) {
976
+ needsBurst = true;
977
+ break;
978
+ }
802
979
  } catch (_) {}
803
980
  }
804
981
  }
805
982
  }
806
983
  if (needsBurst) break;
807
984
  }
985
+
808
986
  if (needsBurst) requestBurst();
809
987
  });
810
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
988
+
989
+ try {
990
+ state.domObs.observe(document.body, { childList: true, subtree: true });
991
+ } catch (_) {}
811
992
  }
812
993
 
813
- // ── TCF / CMP Protection ───────────────────────────────────────────────────
994
+ // ── TCF / CMP Protection ─────────────────────────────────────────────────
995
+ //
996
+ // Root cause of the CMP errors:
997
+ // "Cannot read properties of null (reading 'postMessage')"
998
+ // "Cannot set properties of null (setting 'addtlConsent')"
999
+ //
1000
+ // The CMP (Gatekeeper Consent) communicates via postMessage on the
1001
+ // __tcfapiLocator iframe's contentWindow. During NodeBB ajaxify navigation,
1002
+ // jQuery's html() or empty() on the content area can cascade and remove
1003
+ // iframes from <body>. The CMP then calls getTCData on a stale reference
1004
+ // where contentWindow is null.
1005
+ //
1006
+ // Strategy (3 layers):
1007
+ //
1008
+ // 1. PROTECT: Move the locator iframe into <head> where ajaxify never
1009
+ // touches it. The TCF spec only requires the iframe to exist in the
1010
+ // document with name="__tcfapiLocator" — it works from <head>.
1011
+ //
1012
+ // 2. GUARD: Wrap __tcfapi and __cmp with a safety layer that catches
1013
+ // errors in the CMP's internal getTCData, preventing the uncaught
1014
+ // TypeError from propagating.
1015
+ //
1016
+ // 3. RESTORE: MutationObserver on <body> childList (not subtree) to
1017
+ // immediately re-create the locator if something still removes it.
814
1018
 
815
1019
  function ensureTcfLocator() {
816
1020
  if (!window.__tcfapi && !window.__cmp) return;
1021
+
817
1022
  const LOCATOR_ID = '__tcfapiLocator';
1023
+
1024
+ // Create or relocate the locator iframe into <head> for protection
818
1025
  const ensureInHead = () => {
819
1026
  let existing = document.getElementById(LOCATOR_ID);
820
1027
  if (existing) {
1028
+ // If it's in <body>, move it to <head> where ajaxify can't reach it
821
1029
  if (existing.parentElement !== document.head) {
822
1030
  try { document.head.appendChild(existing); } catch (_) {}
823
1031
  }
824
- return;
1032
+ return existing;
825
1033
  }
1034
+ // Create fresh
826
1035
  const f = document.createElement('iframe');
827
- f.style.display = 'none'; f.id = f.name = LOCATOR_ID;
1036
+ f.style.display = 'none';
1037
+ f.id = f.name = LOCATOR_ID;
828
1038
  try { document.head.appendChild(f); } catch (_) {
1039
+ // Fallback to body if head insertion fails
829
1040
  (document.body || document.documentElement).appendChild(f);
830
1041
  }
1042
+ return f;
831
1043
  };
1044
+
832
1045
  ensureInHead();
1046
+
1047
+ // Layer 2: Guard the CMP API calls against null contentWindow
833
1048
  if (!window.__nbbCmpGuarded) {
834
1049
  window.__nbbCmpGuarded = true;
1050
+
1051
+ // Wrap __tcfapi
835
1052
  if (typeof window.__tcfapi === 'function') {
836
- const orig = window.__tcfapi;
837
- window.__tcfapi = function (cmd, ver, cb, param) {
838
- try { return orig.call(this, cmd, ver, function (...a) { try { cb?.(...a); } catch (_) {} }, param); }
839
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.call(this, cmd, ver, cb, param); } catch (_) {} } }
1053
+ const origTcf = window.__tcfapi;
1054
+ window.__tcfapi = function (cmd, version, cb, param) {
1055
+ try {
1056
+ return origTcf.call(this, cmd, version, function (...args) {
1057
+ try { cb?.(...args); } catch (_) {}
1058
+ }, param);
1059
+ } catch (e) {
1060
+ // If the error is the null postMessage/addtlConsent, swallow it
1061
+ if (e?.message?.includes('null')) {
1062
+ // Re-ensure locator exists, then retry once
1063
+ ensureInHead();
1064
+ try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
1065
+ }
1066
+ }
840
1067
  };
841
1068
  }
1069
+
1070
+ // Wrap __cmp (legacy CMP v1 API)
842
1071
  if (typeof window.__cmp === 'function') {
843
- const orig = window.__cmp;
844
- window.__cmp = function (...a) {
845
- try { return orig.apply(this, a); }
846
- catch (e) { if (e?.message?.includes('null')) { ensureInHead(); try { return orig.apply(this, a); } catch (_) {} } }
1072
+ const origCmp = window.__cmp;
1073
+ window.__cmp = function (...args) {
1074
+ try {
1075
+ return origCmp.apply(this, args);
1076
+ } catch (e) {
1077
+ if (e?.message?.includes('null')) {
1078
+ ensureInHead();
1079
+ try { return origCmp.apply(this, args); } catch (_) {}
1080
+ }
1081
+ }
847
1082
  };
848
1083
  }
849
1084
  }
1085
+
1086
+ // Layer 3: MutationObserver to immediately restore if removed
850
1087
  if (!window.__nbbTcfObs) {
851
- window.__nbbTcfObs = new MutationObserver(() => {
852
- if (!document.getElementById(LOCATOR_ID)) ensureInHead();
1088
+ window.__nbbTcfObs = new MutationObserver(muts => {
1089
+ // Fast check: still in document?
1090
+ if (document.getElementById(LOCATOR_ID)) return;
1091
+ // Something removed it — restore immediately (no debounce)
1092
+ ensureInHead();
853
1093
  });
854
- try { window.__nbbTcfObs.observe(document.body || document.documentElement, { childList: true, subtree: false }); } catch (_) {}
855
- try { if (document.head) window.__nbbTcfObs.observe(document.head, { childList: true, subtree: false }); } catch (_) {}
1094
+ // Observe body direct children only (the most likely removal point)
1095
+ try {
1096
+ window.__nbbTcfObs.observe(document.body || document.documentElement, {
1097
+ childList: true,
1098
+ subtree: false,
1099
+ });
1100
+ } catch (_) {}
1101
+ // Also observe <head> in case something cleans it
1102
+ try {
1103
+ if (document.head) {
1104
+ window.__nbbTcfObs.observe(document.head, {
1105
+ childList: true,
1106
+ subtree: false,
1107
+ });
1108
+ }
1109
+ } catch (_) {}
856
1110
  }
857
1111
  }
858
1112
 
859
- // ── aria-hidden protection ─────────────────────────────────────────────────
860
-
861
- function protectAriaHidden() {
862
- if (window.__nbbAriaObs) return;
863
- const remove = () => {
864
- try { if (document.body.getAttribute('aria-hidden') === 'true') document.body.removeAttribute('aria-hidden'); } catch (_) {}
865
- };
866
- remove();
867
- window.__nbbAriaObs = new MutationObserver(remove);
868
- try { window.__nbbAriaObs.observe(document.body, { attributes: true, attributeFilter: ['aria-hidden'] }); } catch (_) {}
869
- }
870
-
871
1113
  // ── Console muting ─────────────────────────────────────────────────────────
1114
+ //
1115
+ // Mute noisy Ezoic warnings that are expected in infinite scroll context.
1116
+ // Uses startsWith checks instead of includes for performance.
872
1117
 
873
1118
  function muteConsole() {
874
1119
  if (window.__nbbEzMuted) return;
875
1120
  window.__nbbEzMuted = true;
1121
+
876
1122
  const PREFIXES = [
877
1123
  '[EzoicAds JS]: Placeholder Id',
1124
+ 'No valid placeholders for loadMore',
878
1125
  'cannot call refresh on the same page',
879
1126
  'no placeholders are currently defined in Refresh',
880
1127
  'Debugger iframe already exists',
881
1128
  '[CMP] Error in custom getTCData',
882
1129
  'vignette: no interstitial API',
883
1130
  ];
884
- const PATTERNS = [
885
- `with id ${PH_PREFIX}`,
886
- 'adsbygoogle.push() error: All',
887
- 'has already been defined',
888
- 'No valid placeholders for loadMore',
889
- 'bad response. Status',
890
- ];
1131
+ const PH_PATTERN = `with id ${PH_PREFIX}`;
1132
+
891
1133
  for (const method of ['log', 'info', 'warn', 'error']) {
892
1134
  const orig = console[method];
893
1135
  if (typeof orig !== 'function') continue;
894
1136
  console[method] = function (...args) {
895
1137
  if (typeof args[0] === 'string') {
896
1138
  const msg = args[0];
897
- for (const p of PREFIXES) { if (msg.startsWith(p)) return; }
898
- for (const p of PATTERNS) { if (msg.includes(p)) return; }
1139
+ for (const prefix of PREFIXES) {
1140
+ if (msg.startsWith(prefix)) return;
1141
+ }
1142
+ if (msg.includes(PH_PATTERN)) return;
899
1143
  }
900
1144
  return orig.apply(console, args);
901
1145
  };
@@ -903,24 +1147,31 @@
903
1147
  }
904
1148
 
905
1149
  // ── Network warmup ─────────────────────────────────────────────────────────
1150
+ // Run once per session — preconnect hints are in <head> via server-side injection
906
1151
 
907
1152
  let _networkWarmed = false;
1153
+
908
1154
  function warmNetwork() {
909
1155
  if (_networkWarmed) return;
910
1156
  _networkWarmed = true;
1157
+
911
1158
  const head = document.head;
912
1159
  if (!head) return;
913
- for (const [rel, href, cors] of [
1160
+
1161
+ const hints = [
914
1162
  ['preconnect', 'https://g.ezoic.net', true ],
915
1163
  ['preconnect', 'https://go.ezoic.net', true ],
916
1164
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
917
1165
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
918
1166
  ['dns-prefetch', 'https://g.ezoic.net', false],
919
1167
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
920
- ]) {
1168
+ ];
1169
+
1170
+ for (const [rel, href, cors] of hints) {
921
1171
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
922
1172
  const link = document.createElement('link');
923
- link.rel = rel; link.href = href;
1173
+ link.rel = rel;
1174
+ link.href = href;
924
1175
  if (cors) link.crossOrigin = 'anonymous';
925
1176
  head.appendChild(link);
926
1177
  }
@@ -931,25 +1182,51 @@
931
1182
  function bindNodeBB() {
932
1183
  const $ = window.jQuery;
933
1184
  if (!$) return;
1185
+
934
1186
  $(window).off('.nbbEzoic');
1187
+
935
1188
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1189
+
936
1190
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
937
- state.pageKey = pageKey();
938
- state.kind = null;
1191
+ state.pageKey = pageKey();
1192
+ state.kind = null;
939
1193
  state.blockedUntil = 0;
940
- muteConsole(); ensureTcfLocator(); protectAriaHidden(); warmNetwork();
941
- getIO(); ensureDomObserver(); requestBurst();
1194
+
1195
+ // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1196
+ try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1197
+
1198
+ muteConsole();
1199
+ ensureTcfLocator();
1200
+ warmNetwork();
1201
+ patchShowAds();
1202
+ getIO();
1203
+ ensureDomObserver();
1204
+ requestBurst();
942
1205
  });
1206
+
1207
+ // Content-loaded events trigger burst
943
1208
  const burstEvents = [
944
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
945
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1209
+ 'action:ajaxify.contentLoaded',
1210
+ 'action:posts.loaded',
1211
+ 'action:topics.loaded',
1212
+ 'action:categories.loaded',
1213
+ 'action:category.loaded',
1214
+ 'action:topic.loaded',
946
1215
  ].map(e => `${e}.nbbEzoic`).join(' ');
1216
+
947
1217
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1218
+
1219
+ // Also bind via NodeBB hooks module (for compatibility)
948
1220
  try {
949
1221
  require(['hooks'], hooks => {
950
1222
  if (typeof hooks?.on !== 'function') return;
951
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
952
- 'action:categories.loaded', 'action:topic.loaded']) {
1223
+ for (const ev of [
1224
+ 'action:ajaxify.end',
1225
+ 'action:posts.loaded',
1226
+ 'action:topics.loaded',
1227
+ 'action:categories.loaded',
1228
+ 'action:topic.loaded',
1229
+ ]) {
953
1230
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
954
1231
  }
955
1232
  });
@@ -961,7 +1238,10 @@
961
1238
  window.addEventListener('scroll', () => {
962
1239
  if (ticking) return;
963
1240
  ticking = true;
964
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1241
+ requestAnimationFrame(() => {
1242
+ ticking = false;
1243
+ requestBurst();
1244
+ });
965
1245
  }, { passive: true });
966
1246
  }
967
1247
 
@@ -970,8 +1250,8 @@
970
1250
  state.pageKey = pageKey();
971
1251
  muteConsole();
972
1252
  ensureTcfLocator();
973
- protectAriaHidden();
974
1253
  warmNetwork();
1254
+ patchShowAds();
975
1255
  getIO();
976
1256
  ensureDomObserver();
977
1257
  bindNodeBB();