nodebb-plugin-ezoic-infinite 1.6.68 → 1.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +106 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.68",
3
+ "version": "1.6.69",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -119,6 +119,10 @@
119
119
  burstDeadline:0,
120
120
  burstCount: 0,
121
121
  lastBurstTs: 0,
122
+
123
+ // Detached wraps cache (to survive NodeBB virtual/infinite scroll recycling)
124
+ // kindClass -> Map(afterPos -> wrapNode)
125
+ detached: new Map(),
122
126
  };
123
127
 
124
128
  let blockedUntil = 0;
@@ -394,6 +398,54 @@
394
398
  try { wrap.remove(); } catch (e) {}
395
399
  }
396
400
 
401
+ function getDetachedMap(kindClass) {
402
+ let m = st.detached.get(kindClass);
403
+ if (!m) { m = new Map(); st.detached.set(kindClass, m); }
404
+ return m;
405
+ }
406
+
407
+ function detachWrap(kindClass, wrap) {
408
+ // Keep a wrap node in memory so we can re-attach it later without
409
+ // re-requesting the same placement (avoids GPT "format already created" and
410
+ // prevents ads from vanishing permanently when NodeBB recycles DOM nodes).
411
+ try {
412
+ const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
413
+ if (!afterPos) return dropWrap(wrap);
414
+
415
+ // Unobserve placeholder while detached
416
+ try {
417
+ const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
418
+ if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
419
+ } catch (e) {}
420
+
421
+ const m = getDetachedMap(kindClass);
422
+ // If a cached wrap for that position already exists, prefer the newest
423
+ // and fully drop the old one.
424
+ if (m.has(afterPos)) {
425
+ try { dropWrap(m.get(afterPos)); } catch (e) {}
426
+ }
427
+ m.set(afterPos, wrap);
428
+
429
+ // Hard cap cache size per kind to avoid unbounded memory growth.
430
+ if (m.size > 80) {
431
+ const oldest = [...m.entries()].sort((a, b) => {
432
+ const ta = parseInt((a[1] && a[1].getAttribute('data-created')) || '0', 10);
433
+ const tb = parseInt((b[1] && b[1].getAttribute('data-created')) || '0', 10);
434
+ return ta - tb;
435
+ }).slice(0, m.size - 80);
436
+ oldest.forEach(([pos, node]) => {
437
+ m.delete(pos);
438
+ try { dropWrap(node); } catch (e) {}
439
+ });
440
+ }
441
+
442
+ // Actually detach from DOM
443
+ try { wrap.remove(); } catch (e) {}
444
+ } catch (e) {
445
+ try { dropWrap(wrap); } catch (x) {}
446
+ }
447
+ }
448
+
397
449
  function pickId(poolKey, cursorKey) {
398
450
  const pool = st.pools[poolKey];
399
451
  if (!pool.length) return null;
@@ -573,16 +625,38 @@
573
625
  if (itemSet.has(el)) { found = true; break; }
574
626
  }
575
627
 
576
- if (!found) withInternal(() => dropWrap(wrap));
628
+ if (!found) {
629
+ // Detach instead of destroy so ads can survive DOM recycling.
630
+ withInternal(() => detachWrap(kindClass, wrap));
631
+ }
577
632
  });
578
633
  }
579
634
 
635
+ function reapDetached(kindClass) {
636
+ // Soft-prune very old detached nodes.
637
+ try {
638
+ const m = st.detached.get(kindClass);
639
+ if (!m || !m.size) return;
640
+ for (const [pos, wrap] of m.entries()) {
641
+ if (!wrap) { m.delete(pos); continue; }
642
+ if (wrap.isConnected) { m.delete(pos); continue; }
643
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
644
+ if (created && (now() - created) > 15 * 60 * 1000) {
645
+ m.delete(pos);
646
+ try { dropWrap(wrap); } catch (e) {}
647
+ }
648
+ }
649
+ } catch (e) {}
650
+ }
651
+
580
652
  function injectWraps(kindClass, items, interval, showFirst, poolKey, cursorKey) {
581
653
  if (!items.length) return 0;
582
654
  const { map, targets } = computeTargets(items, interval, showFirst);
583
655
  const max = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
584
656
  let n = 0;
585
657
 
658
+ const detachedMap = st.detached.get(kindClass);
659
+
586
660
  for (const pos of targets) {
587
661
  if (n >= max) break;
588
662
  const el = map.get(pos);
@@ -597,6 +671,27 @@
597
671
  if (isAdjacentWrap(el)) continue;
598
672
  if (findWrapAt(kindClass, pos)) continue;
599
673
 
674
+ // If we previously had a wrap at this exact position but NodeBB recycled
675
+ // the DOM, re-attach the cached wrap instead of creating a new one.
676
+ if (detachedMap && detachedMap.has(pos)) {
677
+ const cached = detachedMap.get(pos);
678
+ detachedMap.delete(pos);
679
+ if (cached) {
680
+ withInternal(() => el.insertAdjacentElement('afterend', cached));
681
+ // Re-observe placeholder and trigger show only if the cached wrap is empty.
682
+ try {
683
+ const ph = cached.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
684
+ const id = ph ? parseInt(ph.getAttribute('data-ezoic-id') || (ph.id || '').split('-').pop(), 10) : 0;
685
+ if (id > 0) {
686
+ observePh(id);
687
+ if (!isFilled(cached)) enqueueShow(id);
688
+ }
689
+ } catch (e) {}
690
+ n++;
691
+ continue;
692
+ }
693
+ }
694
+
600
695
  const id = pickId(poolKey, cursorKey);
601
696
  if (!id) break;
602
697
 
@@ -684,6 +779,7 @@
684
779
  const items = getItems('topic');
685
780
  const set = new Set(items);
686
781
  removeOrphanWraps('ezoic-ad-message', set);
782
+ reapDetached('ezoic-ad-message');
687
783
  inserted += injectWraps('ezoic-ad-message', items,
688
784
  Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
689
785
  normBool(cfg.showFirstMessageAd), 'post', 'post');
@@ -692,6 +788,7 @@
692
788
  const items = getItems('categoryTopics');
693
789
  const set = new Set(items);
694
790
  removeOrphanWraps('ezoic-ad-between', set);
791
+ reapDetached('ezoic-ad-between');
695
792
  inserted += injectWraps('ezoic-ad-between', items,
696
793
  Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
697
794
  normBool(cfg.showFirstTopicAd), 'topic', 'topic');
@@ -701,6 +798,7 @@
701
798
  const items = getItems('categories');
702
799
  const set = new Set(items);
703
800
  removeOrphanWraps('ezoic-ad-categories', set);
801
+ reapDetached('ezoic-ad-categories');
704
802
  inserted += injectWraps('ezoic-ad-categories', items,
705
803
  Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
706
804
  normBool(cfg.showFirstCategoryAd), 'category', 'category');
@@ -792,6 +890,13 @@
792
890
  function cleanup() {
793
891
  blockedUntil = now() + 1200;
794
892
  try { document.querySelectorAll('.' + WRAP_CLASS).forEach(dropWrap); } catch (e) {}
893
+ try {
894
+ for (const m of st.detached.values()) {
895
+ for (const w of m.values()) try { dropWrap(w); } catch (e) {}
896
+ m.clear();
897
+ }
898
+ st.detached.clear();
899
+ } catch (e) {}
795
900
  st.cfg = null;
796
901
  st.pools = { topic: [], post: [], category: [] };
797
902
  st.cursors = { topic: 0, post: 0, category: 0 };