openvibers/openvibe.shared Commits

Shared 2.13.3: the navbar removes its previous render's document listeners (a boost page move leaked the old bar) (#33)

OP OpenVibers committed 8 Oct 2026 · 94966d9ce1 · 5 files, +35 −9

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 18c15b8..6f19410 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 2.13.3 — 2026-10-08
++
++- The navbar removes the document listeners of its previous render (the outside-click closers of the launcher, the
++ dropdowns, the drawer and the account menu) with an AbortController per render. Every re-render — each
++ `openvibe-shared/boost` page move re-renders the bar — had left them on document, keeping the removed bar and its
++ listeners alive: ovhost browser-check measured +25–40 listeners and +250–1100 nodes per navigation lap on
++ openvibe.space and openvibe.community (2026-10-08).
++
## 2.13.2 — 2026-10-08
- The navbar loads `ov-mark.js` only when no `<script src=".../ov-mark.js">` is on the page yet: a page carrying its own async
ddiff --git a/navbar.js b/navbar.js
iindex 11aeba5..cbae2a1 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -592,6 +592,10 @@
} catch { return null; }
}
++ // A render's document listeners; the next render (each boost page move) aborts them, so the old bar can go.
++ let _renderAbort = null;
++ const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
++
function bindLauncher(nav) {
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
let panel = null;
@@@ -646,7 +650,7 @@
ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
});
panel.addEventListener('click', (ev) => ev.stopPropagation());
-- document.addEventListener('click', close);
++ document.addEventListener('click', close, onDocument());
launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
}
panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
@@@ -697,7 +701,7 @@
}
closeDrawer();
});
-- document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); });
++ document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); }, onDocument());
// Dropdowns hang below the links row, which scrolls sideways and would clip them: fixed, under the link (less a filtered bar's offset).
const placeDd = (dd) => {
const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;
@@@ -710,7 +714,7 @@
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });
-- document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); });
++ document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());
regPanel(drawer, 'nav-drawer', closeDrawer);
}
}
@@@ -1319,6 +1323,8 @@
// A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them.
let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
if (_navEl) _navEl.remove();
++ if (_renderAbort) _renderAbort.abort();
++ _renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
@@@ -1335,8 +1341,7 @@
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
-- // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds). A page may already carry its own
-- // async <script src=".../ov-mark.js?v=…"> that has not run yet: never load a second copy.
++ // The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
@@@ -1444,7 +1449,7 @@
// Close on outside click
document.addEventListener('click', e => {
if (!nav.contains(e.target)) dropdown.classList.remove('open');
-- });
++ }, onDocument());
// Account switching
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
ddiff --git a/package-lock.json b/package-lock.json
iindex 67ab6e3..93ef8fa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.13.2",
++ "version": "2.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.13.2",
++ "version": "2.13.3",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 49512a7..bcf1f9b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.13.2",
++ "version": "2.13.3",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex 3683455..e35bc2b 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -130,6 +130,19 @@ function load(hostname, opts = {}) {
second.navbar.init({ service: 'coupons', user: null });
assert.strictEqual(second.created.filter((e) => e.tag === 'script' && e.id === 'ov-mark-loader').length, 0, 'the page\'s own tag: no second copy');
}
++// ── a re-render removes the previous render's document listener (it kept the removed bar alive) ────
++{
++ const { navbar } = load('openvibe.space');
++ const docListeners = [];
++ global.document.addEventListener = (type, fn, opts) => docListeners.push({ type, opts });
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ const first = docListeners.filter((l) => l.type === 'click');
++ assert.ok(first.length >= 1 && first.every((l) => l.opts && l.opts.signal), 'the outside-click listener carries an abort signal');
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ assert.ok(first.every((l) => l.opts.signal.aborted), 'the next render removed it');
++ const latest = docListeners.filter((l) => l.type === 'click').slice(first.length);
++ assert.ok(latest.length >= 1 && latest.every((l) => !l.opts.signal.aborted), 'the new render has its own, live');
++}
// ── rendered markup carries the segments, compact attr and the variant ────
{
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });