nodebb-plugin-ezoic-infinite 1.5.25 → 1.5.27

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 (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +106 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.25",
3
+ "version": "1.5.27",
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
@@ -19,8 +19,11 @@
19
19
  categoryItem: 'li[component="categories/category"]',
20
20
  };
21
21
 
22
- // Hard block during navigation to avoid “placeholder does not exist” spam
23
- let EZOIC_BLOCKED = false;
22
+ // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
23
+ let blockedUntil = 0;
24
+ function isBlocked() {
25
+ return Date.now() < blockedUntil;
26
+ }
24
27
 
25
28
  const state = {
26
29
  pageKey: null,
@@ -36,6 +39,11 @@
36
39
 
37
40
  // throttle per placeholder id
38
41
  lastShowById: new Map(),
42
+ internalDomChange: 0,
43
+ lastRecycleAt: { topic: 0, categoryTopics: 0, categories: 0 },
44
+
45
+ // track placeholders that have been shown at least once in this pageview
46
+ usedOnce: new Set(),
39
47
 
40
48
  // observers / schedulers
41
49
  domObs: null,
@@ -169,7 +177,7 @@
169
177
  const orig = ez.showAds;
170
178
 
171
179
  ez.showAds = function (...args) {
172
- if (EZOIC_BLOCKED) return;
180
+ if (isBlocked()) return;
173
181
 
174
182
  let ids = [];
175
183
  if (args.length === 1 && Array.isArray(args[0])) ids = args[0];
@@ -198,7 +206,28 @@
198
206
  }
199
207
  }
200
208
 
201
- // ---------- config & pools ----------
209
+ const RECYCLE_COOLDOWN_MS = 1500;
210
+
211
+ function kindKeyFromClass(kindClass) {
212
+ if (kindClass === 'ezoic-ad-message') return 'topic';
213
+ if (kindClass === 'ezoic-ad-between') return 'categoryTopics';
214
+ if (kindClass === 'ezoic-ad-categories') return 'categories';
215
+ return 'topic';
216
+ }
217
+
218
+ function withInternalDomChange(fn) {
219
+ state.internalDomChange++;
220
+ try { fn(); } finally { state.internalDomChange--; }
221
+ }
222
+
223
+ function canRecycle(kind) {
224
+ const now = Date.now();
225
+ const last = state.lastRecycleAt[kind] || 0;
226
+ if (now - last < RECYCLE_COOLDOWN_MS) return false;
227
+ state.lastRecycleAt[kind] = now;
228
+ return true;
229
+ }
230
+ // ---------- config & pools ----------
202
231
 
203
232
  async function fetchConfigOnce() {
204
233
  if (state.cfg) return state.cfg;
@@ -313,43 +342,71 @@
313
342
  }
314
343
 
315
344
  function showAd(id) {
316
- if (!id || EZOIC_BLOCKED) return;
345
+ if (!id || isBlocked()) return;
317
346
 
318
347
  const now = Date.now();
319
348
  const last = state.lastShowById.get(id) || 0;
320
349
  if (now - last < 1500) return; // basic throttle
321
350
 
322
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
323
- if (!ph || !ph.isConnected) return;
351
+ // Defer one frame so the placeholder is definitely in DOM after insertion/recycle
352
+ requestAnimationFrame(() => {
353
+ if (isBlocked()) return;
324
354
 
325
- state.lastShowById.set(id, now);
355
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
356
+ if (!ph || !ph.isConnected) return;
326
357
 
327
- try {
328
- window.ezstandalone = window.ezstandalone || {};
329
- const ez = window.ezstandalone;
358
+ const now2 = Date.now();
359
+ const last2 = state.lastShowById.get(id) || 0;
360
+ if (now2 - last2 < 1200) return;
361
+ state.lastShowById.set(id, now2);
330
362
 
331
- // Fast path
332
- if (typeof ez.showAds === 'function') {
333
- ez.showAds(id);
334
- return;
335
- }
363
+ try {
364
+ window.ezstandalone = window.ezstandalone || {};
365
+ const ez = window.ezstandalone;
336
366
 
337
- // Queue once for when Ezoic is ready
338
- ez.cmd = ez.cmd || [];
339
- if (!ph.__ezoicQueued) {
340
- ph.__ezoicQueued = true;
341
- ez.cmd.push(() => {
367
+ const doShow = () => {
342
368
  try {
343
- if (EZOIC_BLOCKED) return;
344
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
345
- if (!el || !el.isConnected) return;
346
- window.ezstandalone.showAds(id);
369
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
370
+ // Avoid Ezoic caching state for reused placeholders
371
+ ez.destroyPlaceholders(id);
372
+ }
347
373
  } catch (e) {}
348
- });
349
- }
350
- } catch (e) {}
374
+ try { ez.showAds(id); } catch (e) {}
375
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
376
+ };
377
+
378
+ // Fast path
379
+ if (typeof ez.showAds === 'function') {
380
+ doShow();
381
+ return;
382
+ }
383
+
384
+ // Queue once for when Ezoic is ready
385
+ ez.cmd = ez.cmd || [];
386
+ if (!ph.__ezoicQueued) {
387
+ ph.__ezoicQueued = true;
388
+ ez.cmd.push(() => {
389
+ try {
390
+ if (isBlocked()) return;
391
+ const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
392
+ if (!el || !el.isConnected) return;
393
+ const ez2 = window.ezstandalone;
394
+ if (!ez2 || typeof ez2.showAds !== 'function') return;
395
+ try {
396
+ if (state.usedOnce && state.usedOnce.has(id) && typeof ez2.destroyPlaceholders === 'function') {
397
+ ez2.destroyPlaceholders(id);
398
+ }
399
+ } catch (e) {}
400
+ try { ez2.showAds(id); } catch (e) {}
401
+ try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
402
+ } catch (e) {}
403
+ });
404
+ }
405
+ } catch (e) {}
406
+ });
351
407
  }
352
408
 
409
+
353
410
  // ---------- preload / above-the-fold ----------
354
411
 
355
412
  function ensurePreloadObserver() {
@@ -413,12 +470,20 @@
413
470
 
414
471
  let id = pickIdFromAll(allIds, cursorKey);
415
472
  if (!id) {
416
- // No free ids: recycle an old ad wrapper so we can reuse its placeholder id
417
- const recycled = removeOneOldWrap(kindClass);
473
+ // No free ids: recycle an old ad wrapper so we can reuse its placeholder id.
474
+ // Guard against tight observer loops.
475
+ if (!canRecycle(kindKeyFromClass(kindClass))) {
476
+ dbg('recycle-skip-cooldown', kindClass);
477
+ break;
478
+ }
479
+ let recycled = false;
480
+ withInternalDomChange(() => {
481
+ recycled = removeOneOldWrap(kindClass);
482
+ });
418
483
  dbg('recycle-needed', kindClass, { recycled, ids: allIds.length });
419
- id = pickIdFromAll(allIds, cursorKey);
484
+ // Stop this run after a recycle; the next mutation/scroll will retry injection.
485
+ break;
420
486
  }
421
- if (!id) break;
422
487
  const wrap = insertAfter(el, id, kindClass, afterPos);
423
488
  if (!wrap) {
424
489
  continue;
@@ -491,7 +556,7 @@
491
556
  }
492
557
 
493
558
  async function runCore() {
494
- if (EZOIC_BLOCKED) { dbg('blocked'); return; }
559
+ if (isBlocked()) { dbg('blocked'); return; }
495
560
 
496
561
  patchShowAds();
497
562
 
@@ -552,7 +617,7 @@
552
617
  // ---------- observers / lifecycle ----------
553
618
 
554
619
  function cleanup() {
555
- EZOIC_BLOCKED = true;
620
+ blockedUntil = Date.now() + 1200;
556
621
 
557
622
  // remove all wrappers
558
623
  try {
@@ -570,6 +635,7 @@
570
635
  state.curPosts = 0;
571
636
  state.curCategories = 0;
572
637
  state.lastShowById.clear();
638
+ try { state.usedOnce && state.usedOnce.clear(); } catch (e) {}
573
639
  state.heroDoneForPage = false;
574
640
 
575
641
  // keep observers alive (MutationObserver will re-trigger after navigation)
@@ -578,7 +644,8 @@
578
644
  function ensureDomObserver() {
579
645
  if (state.domObs) return;
580
646
  state.domObs = new MutationObserver(() => {
581
- if (!EZOIC_BLOCKED) scheduleRun();
647
+ if (state.internalDomChange > 0) return;
648
+ if (!isBlocked()) scheduleRun();
582
649
  });
583
650
  try {
584
651
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -596,7 +663,7 @@
596
663
 
597
664
  $(window).on('action:ajaxify.end.ezoicInfinite', () => {
598
665
  state.pageKey = getPageKey();
599
- EZOIC_BLOCKED = false;
666
+ blockedUntil = 0;
600
667
 
601
668
  warmUpNetwork();
602
669
  patchShowAds();
@@ -612,7 +679,7 @@
612
679
 
613
680
  // Infinite scroll / partial updates
614
681
  $(window).on('action:posts.loaded.ezoicInfinite action:topics.loaded.ezoicInfinite action:category.loaded.ezoicInfinite action:topic.loaded.ezoicInfinite', () => {
615
- if (EZOIC_BLOCKED) return;
682
+ if (isBlocked()) return;
616
683
  scheduleRun();
617
684
  });
618
685
  }
@@ -624,7 +691,7 @@
624
691
  ticking = true;
625
692
  window.requestAnimationFrame(() => {
626
693
  ticking = false;
627
- if (!EZOIC_BLOCKED) scheduleRun();
694
+ if (!isBlocked()) scheduleRun();
628
695
  });
629
696
  }, { passive: true });
630
697
  }
@@ -641,7 +708,7 @@
641
708
  bindScroll();
642
709
 
643
710
  // First paint: try hero + run
644
- EZOIC_BLOCKED = false;
711
+ blockedUntil = 0;
645
712
  insertHeroAdEarly().catch(() => {});
646
713
  scheduleRun();
647
714
  })();