nodebb-plugin-ezoic-infinite 1.8.35 → 1.8.37

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 +17 -2
  2. package/package.json +1 -1
  3. package/public/client.js +145 -140
package/library.js CHANGED
@@ -173,7 +173,23 @@ plugin.injectEzoicHead = async (data) => {
173
173
  const settings = await getSettings();
174
174
  const uid = data.req?.uid ?? 0;
175
175
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
176
- if (!excluded) {
176
+
177
+ if (excluded) {
178
+ // Even for excluded users, inject a minimal stub so that any script
179
+ // referencing ezstandalone doesn't throw a ReferenceError, plus
180
+ // the config with excluded=true so client.js bails early.
181
+ const cfg = buildClientConfig(settings, true);
182
+ const stub = [
183
+ '<script>',
184
+ 'window.ezstandalone = window.ezstandalone || {};',
185
+ 'ezstandalone.cmd = ezstandalone.cmd || [];',
186
+ '</script>',
187
+ ].join('\n');
188
+ data.templateData.customHTML =
189
+ stub + '\n' +
190
+ serializeInlineConfig(cfg) +
191
+ (data.templateData.customHTML || '');
192
+ } else {
177
193
  const cfg = buildClientConfig(settings, false);
178
194
  data.templateData.customHTML =
179
195
  HEAD_PRECONNECTS + '\n' +
@@ -182,7 +198,6 @@ plugin.injectEzoicHead = async (data) => {
182
198
  (data.templateData.customHTML || '');
183
199
  }
184
200
  } catch (err) {
185
- // Log but don't break rendering
186
201
  console.error('[ezoic-infinite] injectEzoicHead error:', err.message);
187
202
  }
188
203
  return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.35",
3
+ "version": "1.8.37",
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
@@ -1,15 +1,13 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v2.0.0
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.1.0
3
3
  *
4
- * Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
5
- *
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 → observed → queued → shown → recycled)
4
+ * Fixes in v2.1:
5
+ * - Early bail when user is excluded (no ezstandalone reference errors)
6
+ * - aria-hidden on <body>: MutationObserver removes it continuously
7
+ * - Recycle threshold raised to -3×vh to prevent mobile fast-scroll blank ads
8
+ * - Scroll-up: re-observe placeholders whose wraps come back into DOM
9
+ * - adsbygoogle "already have ads" error: clear ins before recycle
10
+ * - TCF/CMP 3-layer protection (head iframe, API guard, MutationObserver)
13
11
  */
14
12
  (function nbbEzoicInfinite() {
15
13
  'use strict';
@@ -19,7 +17,6 @@
19
17
  const WRAP_CLASS = 'nodebb-ezoic-wrap';
20
18
  const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
21
19
 
22
- // Data attributes
23
20
  const ATTR = {
24
21
  ANCHOR: 'data-ezoic-anchor',
25
22
  WRAPID: 'data-ezoic-wrapid',
@@ -27,7 +24,6 @@
27
24
  SHOWN: 'data-ezoic-shown',
28
25
  };
29
26
 
30
- // Timing
31
27
  const TIMING = {
32
28
  EMPTY_CHECK_MS: 20_000,
33
29
  MIN_PRUNE_AGE_MS: 8_000,
@@ -38,10 +34,8 @@
38
34
  SHOW_RELEASE_MS: 700,
39
35
  BATCH_FLUSH_MS: 80,
40
36
  RECYCLE_DELAY_MS: 450,
41
-
42
37
  };
43
38
 
44
- // Limits
45
39
  const LIMITS = {
46
40
  MAX_INSERTS_RUN: 6,
47
41
  MAX_INFLIGHT: 4,
@@ -55,21 +49,18 @@
55
49
  MOBILE: '3500px 0px 3500px 0px',
56
50
  };
57
51
 
58
- // Selectors
59
52
  const SEL = {
60
53
  post: '[component="post"][data-pid]',
61
54
  topic: 'li[component="category/topic"]',
62
55
  category: 'li[component="categories/category"]',
63
56
  };
64
57
 
65
- // Kind configuration table — single source of truth per ad type
66
58
  const KIND = {
67
59
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
68
60
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
69
61
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
70
62
  };
71
63
 
72
- // Selector for detecting filled ad slots
73
64
  const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
74
65
 
75
66
  // ── Utility ────────────────────────────────────────────────────────────────
@@ -103,45 +94,37 @@
103
94
  // ── State ──────────────────────────────────────────────────────────────────
104
95
 
105
96
  const state = {
106
- // Page context
107
97
  pageKey: null,
108
98
  kind: null,
109
99
  cfg: null,
110
100
 
111
- // Pools
112
101
  poolsReady: false,
113
102
  pools: { topics: [], posts: [], categories: [] },
114
103
  cursors: { topics: 0, posts: 0, categories: 0 },
115
104
 
116
- // Mounted placeholders
117
105
  mountedIds: new Set(),
118
- phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
- lastShow: new Map(), // id → timestamp
106
+ phState: new Map(),
107
+ lastShow: new Map(),
120
108
 
121
- // Wrap registry
122
- wrapByKey: new Map(), // anchorKey → wrap element
123
- wrapsByClass: new Map(), // kindClass → Set<wrap>
109
+ wrapByKey: new Map(),
110
+ wrapsByClass: new Map(),
124
111
 
125
- // Observers
126
112
  io: null,
127
113
  domObs: null,
128
114
 
129
- // Guards
130
- mutGuard: 0,
115
+ mutGuard: 0,
131
116
  blockedUntil: 0,
132
117
 
133
- // Show queue
134
118
  inflight: 0,
135
119
  pending: [],
136
120
  pendingSet: new Set(),
137
121
 
138
- // Scheduler
139
- runQueued: false,
140
- burstActive: false,
122
+ runQueued: false,
123
+ burstActive: false,
141
124
  burstDeadline: 0,
142
- burstCount: 0,
143
- lastBurstTs: 0,
144
- firstShown: false,
125
+ burstCount: 0,
126
+ lastBurstTs: 0,
127
+ firstShown: false,
145
128
  };
146
129
 
147
130
  const isBlocked = () => now() < state.blockedUntil;
@@ -155,7 +138,6 @@
155
138
 
156
139
  async function fetchConfig() {
157
140
  if (state.cfg) return state.cfg;
158
- // Prefer inline config injected by server (zero latency)
159
141
  try {
160
142
  const inline = window.__nbbEzoicCfg;
161
143
  if (inline && typeof inline === 'object') {
@@ -163,7 +145,6 @@
163
145
  return state.cfg;
164
146
  }
165
147
  } catch (_) {}
166
- // Fallback to API
167
148
  try {
168
149
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
169
150
  if (r.ok) state.cfg = await r.json();
@@ -195,7 +176,6 @@
195
176
  if (/^\/topic\//.test(p)) return 'topic';
196
177
  if (/^\/category\//.test(p)) return 'categoryTopics';
197
178
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
- // DOM fallback
199
179
  if (document.querySelector(SEL.category)) return 'categories';
200
180
  if (document.querySelector(SEL.post)) return 'topic';
201
181
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
@@ -215,7 +195,6 @@
215
195
  const el = all[i];
216
196
  if (!el.isConnected) continue;
217
197
  if (!el.querySelector('[component="post/content"]')) continue;
218
- // Skip nested quotes / parent posts
219
198
  const parent = el.parentElement?.closest(SEL.post);
220
199
  if (parent && parent !== el) continue;
221
200
  if (el.getAttribute('component') === 'post/parent') continue;
@@ -235,7 +214,6 @@
235
214
  const v = el.getAttribute(attr);
236
215
  if (v != null && v !== '') return v;
237
216
  }
238
- // Positional fallback
239
217
  const children = el.parentElement?.children;
240
218
  if (!children) return 'i0';
241
219
  for (let i = 0; i < children.length; i++) {
@@ -257,28 +235,21 @@
257
235
  return set;
258
236
  }
259
237
 
260
- // ── Wrap lifecycle detection ───────────────────────────────────────────────
238
+ // ── Wrap lifecycle ─────────────────────────────────────────────────────────
261
239
 
262
- /**
263
- * Check if a wrap element still has its corresponding anchor in the DOM.
264
- * Uses O(1) registry lookup first, then sibling scan, then global querySelector.
265
- */
266
240
  function wrapIsLive(wrap) {
267
241
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
268
242
  const key = wrap.getAttribute(ATTR.ANCHOR);
269
243
  if (!key) return false;
270
244
 
271
- // Fast path: registry match
272
245
  if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
273
246
 
274
- // Parse key
275
247
  const colonIdx = key.indexOf(':');
276
248
  const klass = key.slice(0, colonIdx);
277
249
  const anchorId = key.slice(colonIdx + 1);
278
250
  const cfg = KIND[klass];
279
251
  if (!cfg) return false;
280
252
 
281
- // Sibling scan (cheap for adjacent anchors)
282
253
  const parent = wrap.parentElement;
283
254
  if (parent) {
284
255
  const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
@@ -289,7 +260,6 @@
289
260
  }
290
261
  }
291
262
 
292
- // Global fallback (expensive, rare)
293
263
  try {
294
264
  return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
295
265
  } catch (_) { return false; }
@@ -313,8 +283,7 @@
313
283
 
314
284
  function scheduleUncollapseChecks(wrap) {
315
285
  if (!wrap) return;
316
- const delays = [500, 1500, 3000, 7000, 15000];
317
- for (const ms of delays) {
286
+ for (const ms of [500, 1500, 3000, 7000, 15000]) {
318
287
  setTimeout(() => {
319
288
  try { clearEmptyIfFilled(wrap); } catch (_) {}
320
289
  }, ms);
@@ -336,11 +305,17 @@
336
305
  }
337
306
 
338
307
  // ── Recycling ──────────────────────────────────────────────────────────────
308
+ //
309
+ // v2.1 changes:
310
+ // - Threshold raised to -(3 × vh) — on mobile fast scroll, -vh was too close
311
+ // and wraps got recycled before ads had time to load
312
+ // - Never recycle a wrap whose showAds is still inflight (show-queued state)
313
+ // - Clear ins.adsbygoogle children before re-creating placeholder to avoid
314
+ // "All ins elements already have ads" error
315
+ // - Skip wraps that were created less than 5s ago (give ads time to fill)
316
+
317
+ const RECYCLE_MIN_AGE_MS = 5_000;
339
318
 
340
- /**
341
- * When pool is exhausted, recycle a wrap far above the viewport.
342
- * Sequence: destroy → delay → re-observe → enqueueShow
343
- */
344
319
  function recycleWrap(klass, targetEl, newKey) {
345
320
  const ez = window.ezstandalone;
346
321
  if (typeof ez?.destroyPlaceholders !== 'function' ||
@@ -348,7 +323,9 @@
348
323
  typeof ez?.displayMore !== 'function') return null;
349
324
 
350
325
  const vh = window.innerHeight || 800;
351
- const threshold = -vh;
326
+ // Conservative threshold: viewport height above the top of the screen
327
+ const threshold = -(3 * vh);
328
+ const t = now();
352
329
  let bestEmpty = null, bestEmptyY = Infinity;
353
330
  let bestFull = null, bestFullY = Infinity;
354
331
 
@@ -357,8 +334,17 @@
357
334
 
358
335
  for (const wrap of wraps) {
359
336
  try {
337
+ // Don't recycle wraps that are too young (ads might still be loading)
338
+ const created = parseInt(wrap.getAttribute(ATTR.CREATED) || '0', 10);
339
+ if (t - created < RECYCLE_MIN_AGE_MS) continue;
340
+
341
+ // Don't recycle wraps with inflight showAds
342
+ const wid = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
343
+ if (wid > 0 && state.phState.get(wid) === 'show-queued') continue;
344
+
360
345
  const bottom = wrap.getBoundingClientRect().bottom;
361
346
  if (bottom > threshold) continue;
347
+
362
348
  if (!isFilled(wrap)) {
363
349
  if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
364
350
  } else {
@@ -375,18 +361,20 @@
375
361
 
376
362
  const oldKey = best.getAttribute(ATTR.ANCHOR);
377
363
 
378
- // Unobserve before moving to prevent stale showAds
364
+ // Unobserve before moving
379
365
  try {
380
366
  const ph = best.querySelector(`#${PH_PREFIX}${id}`);
381
367
  if (ph) state.io?.unobserve(ph);
382
368
  } catch (_) {}
383
369
 
384
- // Move the wrap to new position
370
+ // Move the wrap to new position with a clean placeholder
385
371
  mutate(() => {
386
372
  best.setAttribute(ATTR.ANCHOR, newKey);
387
373
  best.setAttribute(ATTR.CREATED, String(now()));
388
374
  best.setAttribute(ATTR.SHOWN, '0');
389
375
  best.classList.remove('is-empty');
376
+ // Clear ALL children including ins.adsbygoogle to avoid
377
+ // "All ins elements already have ads" error on re-show
390
378
  best.replaceChildren();
391
379
 
392
380
  const fresh = document.createElement('div');
@@ -397,11 +385,10 @@
397
385
  targetEl.insertAdjacentElement('afterend', best);
398
386
  });
399
387
 
400
- // Update registry
401
388
  if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
402
389
  state.wrapByKey.set(newKey, best);
403
390
 
404
- // Ezoic recycle sequence
391
+ // Ezoic destroy → re-define → re-show sequence
405
392
  const doDestroy = () => {
406
393
  state.phState.set(id, 'destroyed');
407
394
  try { ez.destroyPlaceholders(id); } catch (_) {
@@ -444,7 +431,6 @@
444
431
  if (!el?.insertAdjacentElement) return null;
445
432
  if (findWrap(key)) return null;
446
433
  if (state.mountedIds.has(id)) return null;
447
- // Ensure no duplicate DOM element with same placeholder ID
448
434
  const existing = document.getElementById(`${PH_PREFIX}${id}`);
449
435
  if (existing?.isConnected) return null;
450
436
 
@@ -471,7 +457,6 @@
471
457
  const key = w.getAttribute(ATTR.ANCHOR);
472
458
  if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
473
459
 
474
- // Find the kind class to unregister
475
460
  for (const cls of w.classList) {
476
461
  if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
477
462
  state.wrapsByClass.get(cls)?.delete(w);
@@ -483,9 +468,6 @@
483
468
  }
484
469
 
485
470
  // ── Prune (category topic lists only) ──────────────────────────────────────
486
- //
487
- // Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
488
- // NOT safe for posts: NodeBB virtualizes posts off-viewport.
489
471
 
490
472
  function pruneOrphansBetween() {
491
473
  const klass = 'ezoic-ad-between';
@@ -493,7 +475,6 @@
493
475
  const wraps = state.wrapsByClass.get(klass);
494
476
  if (!wraps?.size) return;
495
477
 
496
- // Build set of live anchor IDs
497
478
  const liveAnchors = new Set();
498
479
  for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
499
480
  const v = el.getAttribute(cfg.anchorAttr);
@@ -504,7 +485,6 @@
504
485
  for (const w of wraps) {
505
486
  const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
506
487
  if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
507
-
508
488
  const key = w.getAttribute(ATTR.ANCHOR) ?? '';
509
489
  const sid = key.slice(klass.length + 1);
510
490
  if (!sid || !liveAnchors.has(sid)) {
@@ -521,7 +501,6 @@
521
501
  const v = el.getAttribute(attr);
522
502
  if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
523
503
  }
524
- // Positional fallback
525
504
  const fullSel = KIND[klass]?.sel ?? '';
526
505
  let i = 0;
527
506
  for (const s of el.parentElement?.children ?? []) {
@@ -556,7 +535,7 @@
556
535
  }
557
536
  } else {
558
537
  const recycled = recycleWrap(klass, el, key);
559
- if (!recycled) break; // Pool truly exhausted
538
+ if (!recycled) break;
560
539
  inserted++;
561
540
  }
562
541
  }
@@ -571,7 +550,10 @@
571
550
  state.io = new IntersectionObserver(entries => {
572
551
  for (const entry of entries) {
573
552
  if (!entry.isIntersecting) continue;
574
- if (entry.target instanceof Element) state.io?.unobserve(entry.target);
553
+ // DON'T unobserve we need to re-trigger on scroll-up when NodeBB
554
+ // re-inserts virtualized posts back into the DOM.
555
+ // Instead, the show throttle (SHOW_THROTTLE_MS) and phState check
556
+ // in enqueueShow prevent duplicate calls.
575
557
  const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
576
558
  if (id > 0) enqueueShow(id);
577
559
  }
@@ -685,7 +667,6 @@
685
667
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
686
668
  const wrap = ph?.closest(`.${WRAP_CLASS}`);
687
669
  if (!wrap || !ph?.isConnected) return;
688
- // Skip if a newer show happened since
689
670
  if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
690
671
  if (clearEmptyIfFilled(wrap)) return;
691
672
  wrap.classList.add('is-empty');
@@ -694,11 +675,6 @@
694
675
  }
695
676
 
696
677
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
697
- //
698
- // Intercepts ez.showAds() to:
699
- // - block calls during navigation transitions
700
- // - filter out disconnected placeholders
701
- // - batch calls for efficiency
702
678
 
703
679
  function patchShowAds() {
704
680
  const apply = () => {
@@ -760,6 +736,44 @@
760
736
  }
761
737
  }
762
738
 
739
+ // ── Re-observe on scroll-up ────────────────────────────────────────────────
740
+ //
741
+ // NodeBB virtualizes posts: removes them from DOM when off-viewport, then
742
+ // re-inserts them when scrolling back up. Our wraps stay in the DOM but
743
+ // the placeholder was already unobserved by IO (in v2.0) or the phState
744
+ // was 'shown'. We need to check if wraps that are back in viewport have
745
+ // unfilled placeholders and re-trigger show for them.
746
+
747
+ function reobserveVisibleWraps() {
748
+ const vh = window.innerHeight || 800;
749
+ for (const [key, wrap] of state.wrapByKey) {
750
+ if (!wrap.isConnected) continue;
751
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID), 10);
752
+ if (!id || id <= 0) continue;
753
+
754
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
755
+ if (!ph?.isConnected) continue;
756
+
757
+ // Already filled — nothing to do
758
+ if (isFilled(ph) || isPlaceholderUsed(ph)) continue;
759
+
760
+ // Check if the wrap is in or near the viewport
761
+ const rect = wrap.getBoundingClientRect();
762
+ if (rect.bottom < -vh || rect.top > 2 * vh) continue;
763
+
764
+ // This wrap is visible-ish but unfilled — re-trigger
765
+ const st = state.phState.get(id);
766
+ if (st === 'shown' || st === 'show-queued') {
767
+ // Reset state so enqueueShow accepts it again
768
+ state.phState.set(id, 'new');
769
+ state.lastShow.delete(id);
770
+ }
771
+ // Re-observe in IO (safe to call multiple times)
772
+ try { getIO()?.observe(ph); } catch (_) {}
773
+ enqueueShow(id);
774
+ }
775
+ }
776
+
763
777
  // ── Core ───────────────────────────────────────────────────────────────────
764
778
 
765
779
  async function runCore() {
@@ -772,6 +786,9 @@
772
786
  const kind = getKind();
773
787
  if (kind === 'other') return 0;
774
788
 
789
+ // Re-observe wraps that came back into viewport (scroll-up fix)
790
+ reobserveVisibleWraps();
791
+
775
792
  const exec = (klass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
776
793
  if (!normBool(cfgEnable)) return 0;
777
794
  const interval = Math.max(1, parseInt(cfgInterval, 10) || 3);
@@ -867,8 +884,6 @@
867
884
  }
868
885
 
869
886
  // ── MutationObserver ───────────────────────────────────────────────────────
870
- //
871
- // Scoped to detect: (1) ad fill events in wraps, (2) new content items
872
887
 
873
888
  function ensureDomObserver() {
874
889
  if (state.domObs) return;
@@ -878,13 +893,12 @@
878
893
 
879
894
  let needsBurst = false;
880
895
 
881
- // Determine relevant selectors for current page kind
882
896
  const kind = getKind();
883
897
  const relevantSels =
884
- kind === 'topic' ? [SEL.post] :
885
- kind === 'categoryTopics'? [SEL.topic] :
886
- kind === 'categories' ? [SEL.category] :
887
- [SEL.post, SEL.topic, SEL.category];
898
+ kind === 'topic' ? [SEL.post] :
899
+ kind === 'categoryTopics' ? [SEL.topic] :
900
+ kind === 'categories' ? [SEL.category] :
901
+ [SEL.post, SEL.topic, SEL.category];
888
902
 
889
903
  for (const m of muts) {
890
904
  if (m.type !== 'childList') continue;
@@ -900,7 +914,7 @@
900
914
  }
901
915
  } catch (_) {}
902
916
 
903
- // Check for new content items (posts, topics, categories)
917
+ // Check for new content items
904
918
  if (!needsBurst) {
905
919
  for (const sel of relevantSels) {
906
920
  try {
@@ -923,52 +937,30 @@
923
937
  } catch (_) {}
924
938
  }
925
939
 
926
- // ── TCF / CMP Protection ─────────────────────────────────────────────────
927
- //
928
- // Root cause of the CMP errors:
929
- // "Cannot read properties of null (reading 'postMessage')"
930
- // "Cannot set properties of null (setting 'addtlConsent')"
931
- //
932
- // The CMP (Gatekeeper Consent) communicates via postMessage on the
933
- // __tcfapiLocator iframe's contentWindow. During NodeBB ajaxify navigation,
934
- // jQuery's html() or empty() on the content area can cascade and remove
935
- // iframes from <body>. The CMP then calls getTCData on a stale reference
936
- // where contentWindow is null.
937
- //
938
- // Strategy (3 layers):
939
- //
940
- // 1. PROTECT: Move the locator iframe into <head> where ajaxify never
941
- // touches it. The TCF spec only requires the iframe to exist in the
942
- // document with name="__tcfapiLocator" — it works from <head>.
940
+ // ── TCF / CMP Protection ───────────────────────────────────────────────────
943
941
  //
944
- // 2. GUARD: Wrap __tcfapi and __cmp with a safety layer that catches
945
- // errors in the CMP's internal getTCData, preventing the uncaught
946
- // TypeError from propagating.
947
- //
948
- // 3. RESTORE: MutationObserver on <body> childList (not subtree) to
949
- // immediately re-create the locator if something still removes it.
942
+ // 3 layers:
943
+ // 1. PROTECT: locator iframe in <head> (ajaxify never touches <head>)
944
+ // 2. GUARD: wrap __tcfapi/__cmp to catch null contentWindow errors
945
+ // 3. RESTORE: MutationObserver for immediate re-creation
950
946
 
951
947
  function ensureTcfLocator() {
952
948
  if (!window.__tcfapi && !window.__cmp) return;
953
949
 
954
950
  const LOCATOR_ID = '__tcfapiLocator';
955
951
 
956
- // Create or relocate the locator iframe into <head> for protection
957
952
  const ensureInHead = () => {
958
953
  let existing = document.getElementById(LOCATOR_ID);
959
954
  if (existing) {
960
- // If it's in <body>, move it to <head> where ajaxify can't reach it
961
955
  if (existing.parentElement !== document.head) {
962
956
  try { document.head.appendChild(existing); } catch (_) {}
963
957
  }
964
958
  return existing;
965
959
  }
966
- // Create fresh
967
960
  const f = document.createElement('iframe');
968
961
  f.style.display = 'none';
969
962
  f.id = f.name = LOCATOR_ID;
970
963
  try { document.head.appendChild(f); } catch (_) {
971
- // Fallback to body if head insertion fails
972
964
  (document.body || document.documentElement).appendChild(f);
973
965
  }
974
966
  return f;
@@ -976,11 +968,10 @@
976
968
 
977
969
  ensureInHead();
978
970
 
979
- // Layer 2: Guard the CMP API calls against null contentWindow
971
+ // Guard CMP API calls
980
972
  if (!window.__nbbCmpGuarded) {
981
973
  window.__nbbCmpGuarded = true;
982
974
 
983
- // Wrap __tcfapi
984
975
  if (typeof window.__tcfapi === 'function') {
985
976
  const origTcf = window.__tcfapi;
986
977
  window.__tcfapi = function (cmd, version, cb, param) {
@@ -989,9 +980,7 @@
989
980
  try { cb?.(...args); } catch (_) {}
990
981
  }, param);
991
982
  } catch (e) {
992
- // If the error is the null postMessage/addtlConsent, swallow it
993
983
  if (e?.message?.includes('null')) {
994
- // Re-ensure locator exists, then retry once
995
984
  ensureInHead();
996
985
  try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
997
986
  }
@@ -999,7 +988,6 @@
999
988
  };
1000
989
  }
1001
990
 
1002
- // Wrap __cmp (legacy CMP v1 API)
1003
991
  if (typeof window.__cmp === 'function') {
1004
992
  const origCmp = window.__cmp;
1005
993
  window.__cmp = function (...args) {
@@ -1015,37 +1003,54 @@
1015
1003
  }
1016
1004
  }
1017
1005
 
1018
- // Layer 3: MutationObserver to immediately restore if removed
1019
1006
  if (!window.__nbbTcfObs) {
1020
- window.__nbbTcfObs = new MutationObserver(muts => {
1021
- // Fast check: still in document?
1007
+ window.__nbbTcfObs = new MutationObserver(() => {
1022
1008
  if (document.getElementById(LOCATOR_ID)) return;
1023
- // Something removed it — restore immediately (no debounce)
1024
1009
  ensureInHead();
1025
1010
  });
1026
- // Observe body direct children only (the most likely removal point)
1027
1011
  try {
1028
1012
  window.__nbbTcfObs.observe(document.body || document.documentElement, {
1029
- childList: true,
1030
- subtree: false,
1013
+ childList: true, subtree: false,
1031
1014
  });
1032
1015
  } catch (_) {}
1033
- // Also observe <head> in case something cleans it
1034
1016
  try {
1035
1017
  if (document.head) {
1036
1018
  window.__nbbTcfObs.observe(document.head, {
1037
- childList: true,
1038
- subtree: false,
1019
+ childList: true, subtree: false,
1039
1020
  });
1040
1021
  }
1041
1022
  } catch (_) {}
1042
1023
  }
1043
1024
  }
1044
1025
 
1045
- // ── Console muting ─────────────────────────────────────────────────────────
1026
+ // ── aria-hidden protection ─────────────────────────────────────────────────
1027
+ //
1028
+ // The CMP modal sets aria-hidden="true" on <body> when opening, and may not
1029
+ // remove it after ajaxify navigation. A simple removeAttribute in ajaxify.end
1030
+ // isn't enough because the CMP can re-set it asynchronously.
1046
1031
  //
1047
- // Mute noisy Ezoic warnings that are expected in infinite scroll context.
1048
- // Uses startsWith checks instead of includes for performance.
1032
+ // Use a MutationObserver on <body> attributes to remove it immediately.
1033
+
1034
+ function protectAriaHidden() {
1035
+ if (window.__nbbAriaObs) return;
1036
+ const remove = () => {
1037
+ try {
1038
+ if (document.body.getAttribute('aria-hidden') === 'true') {
1039
+ document.body.removeAttribute('aria-hidden');
1040
+ }
1041
+ } catch (_) {}
1042
+ };
1043
+ remove();
1044
+ window.__nbbAriaObs = new MutationObserver(remove);
1045
+ try {
1046
+ window.__nbbAriaObs.observe(document.body, {
1047
+ attributes: true,
1048
+ attributeFilter: ['aria-hidden'],
1049
+ });
1050
+ } catch (_) {}
1051
+ }
1052
+
1053
+ // ── Console muting ─────────────────────────────────────────────────────────
1049
1054
 
1050
1055
  function muteConsole() {
1051
1056
  if (window.__nbbEzMuted) return;
@@ -1060,7 +1065,10 @@
1060
1065
  '[CMP] Error in custom getTCData',
1061
1066
  'vignette: no interstitial API',
1062
1067
  ];
1063
- const PH_PATTERN = `with id ${PH_PREFIX}`;
1068
+ const PATTERNS = [
1069
+ `with id ${PH_PREFIX}`,
1070
+ 'adsbygoogle.push() error: All',
1071
+ ];
1064
1072
 
1065
1073
  for (const method of ['log', 'info', 'warn', 'error']) {
1066
1074
  const orig = console[method];
@@ -1071,7 +1079,9 @@
1071
1079
  for (const prefix of PREFIXES) {
1072
1080
  if (msg.startsWith(prefix)) return;
1073
1081
  }
1074
- if (msg.includes(PH_PATTERN)) return;
1082
+ for (const pat of PATTERNS) {
1083
+ if (msg.includes(pat)) return;
1084
+ }
1075
1085
  }
1076
1086
  return orig.apply(console, args);
1077
1087
  };
@@ -1079,7 +1089,6 @@
1079
1089
  }
1080
1090
 
1081
1091
  // ── Network warmup ─────────────────────────────────────────────────────────
1082
- // Run once per session — preconnect hints are in <head> via server-side injection
1083
1092
 
1084
1093
  let _networkWarmed = false;
1085
1094
 
@@ -1116,19 +1125,16 @@
1116
1125
  if (!$) return;
1117
1126
 
1118
1127
  $(window).off('.nbbEzoic');
1119
-
1120
1128
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1121
1129
 
1122
1130
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1123
- state.pageKey = pageKey();
1124
- state.kind = null;
1131
+ state.pageKey = pageKey();
1132
+ state.kind = null;
1125
1133
  state.blockedUntil = 0;
1126
1134
 
1127
- // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1128
- try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1129
-
1130
1135
  muteConsole();
1131
1136
  ensureTcfLocator();
1137
+ protectAriaHidden();
1132
1138
  warmNetwork();
1133
1139
  patchShowAds();
1134
1140
  getIO();
@@ -1136,7 +1142,6 @@
1136
1142
  requestBurst();
1137
1143
  });
1138
1144
 
1139
- // Content-loaded events trigger burst
1140
1145
  const burstEvents = [
1141
1146
  'action:ajaxify.contentLoaded',
1142
1147
  'action:posts.loaded',
@@ -1148,7 +1153,6 @@
1148
1153
 
1149
1154
  $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1150
1155
 
1151
- // Also bind via NodeBB hooks module (for compatibility)
1152
1156
  try {
1153
1157
  require(['hooks'], hooks => {
1154
1158
  if (typeof hooks?.on !== 'function') return;
@@ -1182,6 +1186,7 @@
1182
1186
  state.pageKey = pageKey();
1183
1187
  muteConsole();
1184
1188
  ensureTcfLocator();
1189
+ protectAriaHidden();
1185
1190
  warmNetwork();
1186
1191
  patchShowAds();
1187
1192
  getIO();