nodebb-plugin-ezoic-infinite 1.8.10 → 1.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -71,8 +71,9 @@ async function getSettings() {
71
71
 
72
72
  async function isUserExcluded(uid, excludedGroups) {
73
73
  if (!uid || !excludedGroups.length) return false;
74
+ const excluded = new Set((excludedGroups || []).map(String));
74
75
  const userGroups = await groups.getUserGroups([uid]);
75
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
76
+ return (userGroups[0] || []).some(g => excluded.has(String(g.name)));
76
77
  }
77
78
 
78
79
  // ── Scripts Ezoic ──────────────────────────────────────────────────────────
@@ -113,13 +114,34 @@ plugin.injectEzoicHead = async (data) => {
113
114
  const uid = data.req?.uid ?? 0;
114
115
  const excluded = await isUserExcluded(uid, settings.excludedGroups);
115
116
  if (!excluded) {
116
- // Préfixer : nos scripts d'abord, puis le customHTML existant de l'admin
117
- data.templateData.customHTML = EZOIC_SCRIPTS + (data.templateData.customHTML || '');
117
+ const html = data.templateData.customHTML || '';
118
+ if (!html.includes('data-nbb-ezoic-head="1"')) {
119
+ data.templateData.customHTML = `<meta data-nbb-ezoic-head="1">` + EZOIC_SCRIPTS + html;
120
+ }
118
121
  }
119
122
  } catch (_) {}
120
123
  return data;
121
124
  };
122
125
 
126
+
127
+ function publicConfigPayload(settings, excluded) {
128
+ return {
129
+ excluded,
130
+ enableBetweenAds: settings.enableBetweenAds,
131
+ showFirstTopicAd: settings.showFirstTopicAd,
132
+ placeholderIds: settings.placeholderIds,
133
+ intervalPosts: settings.intervalPosts,
134
+ enableCategoryAds: settings.enableCategoryAds,
135
+ showFirstCategoryAd: settings.showFirstCategoryAd,
136
+ categoryPlaceholderIds: settings.categoryPlaceholderIds,
137
+ intervalCategories: settings.intervalCategories,
138
+ enableMessageAds: settings.enableMessageAds,
139
+ showFirstMessageAd: settings.showFirstMessageAd,
140
+ messagePlaceholderIds: settings.messagePlaceholderIds,
141
+ messageIntervalPosts: settings.messageIntervalPosts,
142
+ };
143
+ }
144
+
123
145
  plugin.init = async ({ router, middleware }) => {
124
146
  async function render(req, res) {
125
147
  const settings = await getSettings();
@@ -139,21 +161,7 @@ plugin.init = async ({ router, middleware }) => {
139
161
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
140
162
  const settings = await getSettings();
141
163
  const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
142
- res.json({
143
- excluded,
144
- enableBetweenAds: settings.enableBetweenAds,
145
- showFirstTopicAd: settings.showFirstTopicAd,
146
- placeholderIds: settings.placeholderIds,
147
- intervalPosts: settings.intervalPosts,
148
- enableCategoryAds: settings.enableCategoryAds,
149
- showFirstCategoryAd: settings.showFirstCategoryAd,
150
- categoryPlaceholderIds: settings.categoryPlaceholderIds,
151
- intervalCategories: settings.intervalCategories,
152
- enableMessageAds: settings.enableMessageAds,
153
- showFirstMessageAd: settings.showFirstMessageAd,
154
- messagePlaceholderIds: settings.messagePlaceholderIds,
155
- messageIntervalPosts: settings.messageIntervalPosts,
156
- });
164
+ res.json(publicConfigPayload(settings, excluded));
157
165
  });
158
166
  };
159
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.8.10",
3
+ "version": "1.8.12",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -18,4 +18,4 @@
18
18
  "compatibility": "^4.0.0"
19
19
  },
20
20
  "private": false
21
- }
21
+ }
package/public/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v52.1
3
3
  *
4
4
  * Historique des corrections majeures
5
5
  * ────────────────────────────────────
@@ -32,6 +32,8 @@
32
32
  *
33
33
  * v34 moveDistantWrap — voir v38.
34
34
  *
35
+ * v52 Clean architecture interne (modules logiques, cache DOM, bridge Ezoic).
36
+ *
35
37
  * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
38
  * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
39
  * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
@@ -81,11 +83,11 @@
81
83
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
84
  const MAX_INSERTS_RUN = 10; // plus réactif si NodeBB injecte en rafale
83
85
  const MAX_INFLIGHT = 2; // ids max simultanés en vol (garde-fou)
84
- const MAX_SHOW_BATCH = 3; // ids max par appel showAds(...ids)
86
+ const MAX_SHOW_BATCH = 4; // ids max par appel showAds(...ids)
85
87
  const SHOW_THROTTLE_MS = 500; // anti-spam showAds() par id (plus réactif)
86
88
  const SHOW_RELEASE_MS = 300; // relâche inflight après showAds() batché
87
89
  const SHOW_FAILSAFE_MS = 7000; // relâche forcée si stack pub lente
88
- const BATCH_FLUSH_MS = 40; // micro-buffer pour regrouper les ids proches
90
+ const BATCH_FLUSH_MS = 30; // micro-buffer pour regrouper les ids proches
89
91
  const MAX_DESTROY_BATCH = 4; // ids max par destroyPlaceholders(...ids)
90
92
  const DESTROY_FLUSH_MS = 30; // micro-buffer destroy pour lisser les rafales
91
93
  const BURST_COOLDOWN_MS = 100; // délai min entre deux déclenchements de burst
@@ -149,6 +151,10 @@
149
151
  burstDeadline: 0,
150
152
  burstCount: 0,
151
153
  lastBurstTs: 0,
154
+ ioViewportMode: isMobile() ? 'm' : 'd',
155
+ domRev: 0,
156
+ domCache: new Map(),
157
+ cacheFrameArmed: false,
152
158
  };
153
159
 
154
160
  let blockedUntil = 0;
@@ -157,7 +163,81 @@
157
163
  const isBlocked = () => ts() < blockedUntil;
158
164
  const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
159
165
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
160
- const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
166
+ const DEBUG = (() => { try { return localStorage.getItem('nbbEzDebug') === '1'; } catch (_) { return false; } })();
167
+ const dbg = (...a) => { if (DEBUG) { try { console.debug('[nbb-ezoic]', ...a); } catch (_) {} } };
168
+ const isFilled = n => {
169
+ try {
170
+ if (!n?.querySelector) return false;
171
+ if (n.querySelector('iframe, ins, img, video, [data-google-container-id], .ezoic-ad, [id$="__container__"]')) return true;
172
+ const r = n.getBoundingClientRect?.();
173
+ return !!(r && r.height > 8);
174
+ } catch (_) { return false; }
175
+ };
176
+
177
+ const Arch = {
178
+ cache: {
179
+ // v53: frame-only micro-cache (safe for NodeBB progressive renders).
180
+ // We only cache DOM query results until the next animation frame, and any mutation invalidates immediately.
181
+ _armFrameClear() {
182
+ if (S.cacheFrameArmed) return;
183
+ S.cacheFrameArmed = true;
184
+ requestAnimationFrame(() => {
185
+ S.cacheFrameArmed = false;
186
+ try { S.domCache.clear(); } catch (_) {}
187
+ dbg('dom-cache.clear', 'raf');
188
+ });
189
+ },
190
+ invalidate(reason) {
191
+ try { S.domRev++; S.domCache.clear(); } catch (_) {}
192
+ dbg('dom-cache.invalidate', reason || '');
193
+ },
194
+ queryAll(key, selector, filterFn) {
195
+ const k = `q:${S.domRev}:${key}:${selector}`;
196
+ try {
197
+ if (S.domCache.has(k)) return S.domCache.get(k);
198
+ } catch (_) {}
199
+ const arr = Array.from(document.querySelectorAll(selector));
200
+ const out = typeof filterFn === 'function' ? arr.filter(Boolean).filter(filterFn) : arr;
201
+ try {
202
+ this._armFrameClear();
203
+ S.domCache.set(k, out);
204
+ } catch (_) {}
205
+ return out;
206
+ },
207
+ get(key, buildFn) {
208
+ const k = `g:${S.domRev}:${key}`;
209
+ try {
210
+ if (S.domCache.has(k)) return S.domCache.get(k);
211
+ } catch (_) {}
212
+ const out = buildFn();
213
+ try {
214
+ this._armFrameClear();
215
+ S.domCache.set(k, out);
216
+ } catch (_) {}
217
+ return out;
218
+ },
219
+ },
220
+ ezoic: {
221
+ ensure() {
222
+ window.ezstandalone = window.ezstandalone || {};
223
+ return window.ezstandalone;
224
+ },
225
+ run(fn) {
226
+ const ez = this.ensure();
227
+ try { return (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(fn) : fn(); } catch (_) {}
228
+ },
229
+ call(method, ...args) {
230
+ const ez = this.ensure();
231
+ const fn = ez?.[method];
232
+ if (typeof fn !== 'function') return;
233
+ try { return fn.apply(ez, args); } catch (_) {}
234
+ },
235
+ },
236
+ page: {
237
+ kind() { return Arch.cache.get('kind', getKind); },
238
+ key() { return pageKey(); },
239
+ },
240
+ };
161
241
 
162
242
  function healFalseEmpty(root = document) {
163
243
  try {
@@ -215,7 +295,8 @@
215
295
 
216
296
  function mutate(fn) {
217
297
  S.mutGuard++;
218
- try { fn(); } finally { S.mutGuard--; }
298
+ try { fn(); }
299
+ finally { S.mutGuard--; Arch.cache.invalidate('mutate'); }
219
300
  }
220
301
  function scheduleDestroyFlush() {
221
302
  if (S.destroyBatchTimer) return;
@@ -235,11 +316,7 @@ function flushDestroyBatch() {
235
316
  ids.push(id);
236
317
  }
237
318
  if (ids.length) {
238
- try {
239
- const ez = window.ezstandalone;
240
- const run = () => { try { ez?.destroyPlaceholders?.(ids); } catch (_) {} };
241
- try { (typeof ez?.cmd?.push === 'function') ? ez.cmd.push(run) : run(); } catch (_) {}
242
- } catch (_) {}
319
+ try { Arch.ezoic.run(() => Arch.ezoic.call('destroyPlaceholders', ids)); } catch (_) {}
243
320
  }
244
321
  if (S.destroyPending.length) scheduleDestroyFlush();
245
322
  }
@@ -255,20 +332,28 @@ function destroyEzoicId(id) {
255
332
  scheduleDestroyFlush();
256
333
  }
257
334
 
258
- function destroyBeforeReuse(ids) {
335
+ function queueDestroyIds(ids) {
336
+ for (const raw of (ids || [])) {
337
+ const id = parseInt(raw, 10);
338
+ if (!Number.isFinite(id) || id <= 0) continue;
339
+ if (!S.ezActiveIds.has(id) && !S.ezShownSinceDestroy.has(id)) continue;
340
+ if (S.destroyPendingSet.has(id)) continue;
341
+ S.destroyPending.push(id);
342
+ S.destroyPendingSet.add(id);
343
+ S.ezActiveIds.delete(id);
344
+ S.ezShownSinceDestroy.delete(id);
345
+ }
346
+ if (S.destroyPending.length) scheduleDestroyFlush();
347
+ }
348
+
349
+ function prepareIdsForShow(ids) {
259
350
  const out = [];
260
- const toDestroy = [];
261
351
  const seen = new Set();
262
352
  for (const raw of (ids || [])) {
263
353
  const id = parseInt(raw, 10);
264
354
  if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
265
355
  seen.add(id);
266
356
  out.push(id);
267
- if (S.ezShownSinceDestroy.has(id)) toDestroy.push(id);
268
- }
269
- if (toDestroy.length) {
270
- try { window.ezstandalone?.destroyPlaceholders?.(toDestroy); } catch (_) {}
271
- for (const id of toDestroy) S.ezShownSinceDestroy.delete(id);
272
357
  }
273
358
  return out;
274
359
  }
@@ -327,7 +412,7 @@ function destroyBeforeReuse(ids) {
327
412
  // ── Items DOM ──────────────────────────────────────────────────────────────
328
413
 
329
414
  function getPosts() {
330
- return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
415
+ return Arch.cache.queryAll('posts', SEL.post, el => {
331
416
  if (!el.isConnected) return false;
332
417
  if (!el.querySelector('[component="post/content"]')) return false;
333
418
  const p = el.parentElement?.closest(SEL.post);
@@ -336,8 +421,8 @@ function destroyBeforeReuse(ids) {
336
421
  });
337
422
  }
338
423
 
339
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
340
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
424
+ const getTopics = () => Arch.cache.queryAll('topics', SEL.topic);
425
+ const getCategories = () => Arch.cache.queryAll('categories', SEL.category);
341
426
 
342
427
  // ── Wraps — détection ──────────────────────────────────────────────────────
343
428
 
@@ -438,6 +523,7 @@ function destroyBeforeReuse(ids) {
438
523
  S.mountedIds.delete(id);
439
524
  S.pendingSet.delete(id);
440
525
  S.lastShow.delete(id);
526
+ queueDestroyIds([id]);
441
527
  S.ezActiveIds.delete(id);
442
528
  }
443
529
  }
@@ -454,7 +540,7 @@ function destroyBeforeReuse(ids) {
454
540
  * Priorité : wraps vides d'abord, remplis si nécessaire.
455
541
  */
456
542
  function recycleAndMove(klass, targetEl, newKey) {
457
- const ez = window.ezstandalone;
543
+ const ez = Arch.ezoic.ensure();
458
544
  if (typeof ez?.destroyPlaceholders !== 'function' ||
459
545
  typeof ez?.define !== 'function' ||
460
546
  typeof ez?.displayMore !== 'function') return null;
@@ -526,7 +612,7 @@ function recycleAndMove(klass, targetEl, newKey) {
526
612
  };
527
613
  const doDefine = () => { try { ez.define([id]); } catch (_) {} setTimeout(doDisplay, 300); };
528
614
  const doDisplay = () => { try { ez.displayMore([id]); S.ezActiveIds.add(id); S.ezShownSinceDestroy.add(id); } catch (_) {} };
529
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
615
+ try { Arch.ezoic.run(doDestroy); } catch (_) {}
530
616
 
531
617
  return { id, wrap: best };
532
618
  }
@@ -566,7 +652,7 @@ function recycleAndMove(klass, targetEl, newKey) {
566
652
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
567
653
  if (ph instanceof Element) S.io?.unobserve(ph);
568
654
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
569
- if (Number.isFinite(id)) { S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
655
+ if (Number.isFinite(id)) { queueDestroyIds([id]); S.ezActiveIds.delete(id); S.mountedIds.delete(id); }
570
656
  const key = w.getAttribute(A_ANCHOR);
571
657
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
572
658
  w.remove();
@@ -589,19 +675,18 @@ function recycleAndMove(klass, targetEl, newKey) {
589
675
  // supprimerait les wraps, et provoquerait une réinjection en haut.
590
676
 
591
677
  function pruneOrphansBetween() {
678
+ if (S.burstActive || (S.scrollSpeed || 0) > 1800) return;
592
679
  const klass = 'ezoic-ad-between';
593
680
  const cfg = KIND[klass];
681
+ const liveAnchors = new Set(Arch.cache.queryAll('prune-between-anchors', cfg.sel).map(el => el.getAttribute(cfg.anchorAttr)).filter(Boolean));
594
682
 
595
683
  document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(w => {
596
684
  const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
597
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
598
-
685
+ if (ts() - created < MIN_PRUNE_AGE_MS) return;
599
686
  const key = w.getAttribute(A_ANCHOR) ?? '';
600
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
687
+ const sid = key.slice(klass.length + 1);
601
688
  if (!sid) { mutate(() => dropWrap(w)); return; }
602
-
603
- const anchorEl = document.querySelector(`${cfg.baseTag}[${cfg.anchorAttr}="${sid}"]`);
604
- if (!anchorEl?.isConnected) mutate(() => dropWrap(w));
689
+ if (!liveAnchors.has(sid)) mutate(() => dropWrap(w));
605
690
  });
606
691
  }
607
692
 
@@ -647,6 +732,8 @@ function recycleAndMove(klass, targetEl, newKey) {
647
732
  const w = insertAfter(el, id, klass, key);
648
733
  if (w) { observePh(id); inserted++; }
649
734
  } else {
735
+ // Recyclage agressif = source fréquente de churn visuel (surtout sticky/fixed)
736
+ if (klass === 'ezoic-ad-message') break;
650
737
  const recycled = recycleAndMove(klass, el, key);
651
738
  if (!recycled) break;
652
739
  inserted++;
@@ -657,15 +744,31 @@ function recycleAndMove(klass, targetEl, newKey) {
657
744
 
658
745
  // ── IntersectionObserver & Show ────────────────────────────────────────────
659
746
 
747
+ function ensureIOViewportMode() {
748
+ const mode = isMobile() ? 'm' : 'd';
749
+ if (S.io && S.ioViewportMode !== mode) {
750
+ try { S.io.disconnect(); } catch (_) {}
751
+ S.io = null;
752
+ }
753
+ S.ioViewportMode = mode;
754
+ }
755
+
660
756
  function getIO() {
757
+ ensureIOViewportMode();
661
758
  if (S.io) return S.io;
662
759
  try {
663
760
  S.io = new IntersectionObserver(entries => {
664
761
  for (const e of entries) {
665
762
  if (!e.isIntersecting) continue;
666
- if (e.target instanceof Element) S.io?.unobserve(e.target);
667
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
668
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
763
+ const target = e.target instanceof Element ? e.target : null;
764
+ const id = parseInt(target?.getAttribute('data-ezoic-id'), 10);
765
+ if (!Number.isFinite(id) || id <= 0) continue;
766
+ const accepted = enqueueShow(id);
767
+ if (accepted) {
768
+ try { S.io?.unobserve(target); } catch (_) {}
769
+ } else if (target?.isConnected) {
770
+ setTimeout(() => { try { if (target.isConnected) S.io?.observe(target); } catch (_) {} }, 300);
771
+ }
669
772
  }
670
773
  }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
671
774
  } catch (_) { S.io = null; }
@@ -686,12 +789,15 @@ function recycleAndMove(klass, targetEl, newKey) {
686
789
  }
687
790
 
688
791
  function enqueueShow(id) {
689
- if (!id || isBlocked()) return;
792
+ if (!id || isBlocked()) return false;
690
793
  const n = parseInt(id, 10);
691
- if (!Number.isFinite(n) || n <= 0) return;
692
- if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return;
794
+ if (!Number.isFinite(n) || n <= 0) return false;
795
+ if (ts() - (S.lastShow.get(n) ?? 0) < SHOW_THROTTLE_MS) return false;
796
+ const ph = phEl(n);
797
+ if (!ph?.isConnected || isFilled(ph) || !hasSinglePlaceholder(n)) return false;
693
798
  if (!S.pendingSet.has(n)) { S.pending.push(n); S.pendingSet.add(n); }
694
799
  scheduleDrainQueue();
800
+ return true;
695
801
  }
696
802
 
697
803
  function scheduleDrainQueue() {
@@ -757,19 +863,18 @@ function startShowBatch(ids) {
757
863
 
758
864
  if (!valid.length) { clearTimeout(timer); return release(); }
759
865
 
760
- window.ezstandalone = window.ezstandalone || {};
761
- const ez = window.ezstandalone;
866
+ const ez = Arch.ezoic.ensure();
762
867
  const doShow = () => {
763
- const prepared = destroyBeforeReuse(valid);
868
+ const prepared = prepareIdsForShow(valid);
764
869
  if (!prepared.length) { setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS); return; }
765
- try { ez.showAds(...prepared); } catch (_) {}
870
+ try { ez.showAds(...prepared); } catch (_) { dbg('showAds failed', prepared); }
766
871
  for (const id of prepared) {
767
872
  S.ezActiveIds.add(id);
768
873
  S.ezShownSinceDestroy.add(id);
769
874
  }
770
875
  setTimeout(() => { clearTimeout(timer); release(); }, SHOW_RELEASE_MS);
771
876
  };
772
- Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
877
+ Arch.ezoic.run(doShow);
773
878
  } catch (_) { clearTimeout(timer); release(); }
774
879
  });
775
880
  }
@@ -784,8 +889,7 @@ function startShowBatch(ids) {
784
889
  function patchShowAds() {
785
890
  const apply = () => {
786
891
  try {
787
- window.ezstandalone = window.ezstandalone || {};
788
- const ez = window.ezstandalone;
892
+ const ez = Arch.ezoic.ensure();
789
893
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
790
894
  window.__nbbEzPatched = true;
791
895
  const orig = ez.showAds.bind(ez);
@@ -812,8 +916,7 @@ function startShowBatch(ids) {
812
916
  };
813
917
  apply();
814
918
  if (!window.__nbbEzPatched) {
815
- window.ezstandalone = window.ezstandalone || {};
816
- (window.ezstandalone.cmd = window.ezstandalone.cmd || []).push(apply);
919
+ Arch.ezoic.run(apply);
817
920
  }
818
921
  }
819
922
 
@@ -920,8 +1023,10 @@ function startShowBatch(ids) {
920
1023
  S.runQueued = false;
921
1024
  S.sweepQueued = false;
922
1025
  S.scrollSpeed = 0;
1026
+ S.cacheFrameArmed = false;
923
1027
  S.lastScrollY = 0;
924
1028
  S.lastScrollTs = 0;
1029
+ Arch.cache.invalidate('cleanup');
925
1030
  }
926
1031
 
927
1032
  // ── MutationObserver ───────────────────────────────────────────────────────
@@ -939,14 +1044,14 @@ function startShowBatch(ids) {
939
1044
  sawWrapRemoval = true;
940
1045
  }
941
1046
  }
942
- if (sawWrapRemoval) queueSweepDeadWraps();
1047
+ if (sawWrapRemoval) { Arch.cache.invalidate('wrap-removed'); queueSweepDeadWraps(); }
943
1048
  for (const n of m.addedNodes) {
944
1049
  if (n.nodeType !== 1) continue;
945
1050
  try { healFalseEmpty(n); } catch (_) {}
946
1051
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
947
1052
  if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
948
1053
  allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
949
- requestBurst(); return;
1054
+ Arch.cache.invalidate('dom-added'); requestBurst(); return;
950
1055
  }
951
1056
  }
952
1057
  }
@@ -1025,7 +1130,8 @@ function startShowBatch(ids) {
1025
1130
  $(window).off('.nbbEzoic');
1026
1131
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1027
1132
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
1028
- S.pageKey = pageKey();
1133
+ S.pageKey = Arch.page.key();
1134
+ Arch.cache.invalidate('ajaxify.end');
1029
1135
  blockedUntil = 0;
1030
1136
  muteConsole(); ensureTcfLocator(); warmNetwork();
1031
1137
  patchShowAds(); getIO(); ensureDomObserver(); sweepDeadWraps(); requestBurst();
@@ -1048,6 +1154,17 @@ function startShowBatch(ids) {
1048
1154
  } catch (_) {}
1049
1155
  }
1050
1156
 
1157
+ function bindResize() {
1158
+ let rT = 0;
1159
+ window.addEventListener('resize', () => {
1160
+ if (rT) clearTimeout(rT);
1161
+ rT = setTimeout(() => {
1162
+ ensureIOViewportMode();
1163
+ requestBurst();
1164
+ }, 120);
1165
+ }, { passive: true });
1166
+ }
1167
+
1051
1168
  function bindScroll() {
1052
1169
  let ticking = false;
1053
1170
  try {
@@ -1074,16 +1191,29 @@ function startShowBatch(ids) {
1074
1191
 
1075
1192
  // ── Boot ───────────────────────────────────────────────────────────────────
1076
1193
 
1077
- S.pageKey = pageKey();
1078
- muteConsole();
1079
- ensureTcfLocator();
1080
- warmNetwork();
1081
- patchShowAds();
1082
- getIO();
1083
- ensureDomObserver();
1084
- bindNodeBB();
1085
- bindScroll();
1086
- blockedUntil = 0;
1087
- requestBurst();
1194
+ const App = {
1195
+ modules: {
1196
+ infra: { muteConsole, ensureTcfLocator, warmNetwork, patchShowAds },
1197
+ lifecycle: { ensureDomObserver, bindNodeBB, cleanup },
1198
+ viewport: { getIO, bindResize, bindScroll },
1199
+ scheduler: { requestBurst },
1200
+ },
1201
+ boot() {
1202
+ this.modules.infra.muteConsole();
1203
+ this.modules.infra.ensureTcfLocator();
1204
+ this.modules.infra.warmNetwork();
1205
+ this.modules.infra.patchShowAds();
1206
+ this.modules.viewport.getIO();
1207
+ this.modules.lifecycle.ensureDomObserver();
1208
+ this.modules.lifecycle.bindNodeBB();
1209
+ this.modules.viewport.bindResize();
1210
+ this.modules.viewport.bindScroll();
1211
+ blockedUntil = 0;
1212
+ this.modules.scheduler.requestBurst();
1213
+ },
1214
+ };
1215
+
1216
+ S.pageKey = Arch.page.key();
1217
+ App.boot();
1088
1218
 
1089
1219
  })();
package/public/style.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * NodeBB Ezoic Infinite Ads — style.css (v20)
2
+ * NodeBB Ezoic Infinite Ads — style.css (v21)
3
3
  */
4
4
 
5
5
  /* ── Wrapper ──────────────────────────────────────────────────────────────── */
@@ -8,8 +8,9 @@
8
8
  width: 100%;
9
9
  margin: 0 !important;
10
10
  padding: 0 !important;
11
- overflow: hidden;
12
- contain: layout style;
11
+ overflow: visible;
12
+ contain: none;
13
+ position: relative;
13
14
  }
14
15
 
15
16
  /* Placeholder : 1px minimum pour rester visible par l'IntersectionObserver */
@@ -50,11 +51,7 @@
50
51
  position: absolute !important;
51
52
  }
52
53
 
53
- /* Neutralise sticky dans nos wraps (évite l'effet "gliding") */
54
- .nodebb-ezoic-wrap .ezads-sticky-intradiv {
55
- position: static !important;
56
- top: auto !important;
57
- }
54
+ /* Compat sticky/fixed Ezoic : ne pas neutraliser globalement ici. */
58
55
 
59
56
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
60
57
  .ezoic-ad {