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)
Changes
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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 clickdocument.addEventListener('click', e => {if (!nav.contains(e.target)) dropdown.classList.remove('open');-- });++ }, onDocument());// Account switchingdropdown.querySelectorAll('[data-account-id]').forEach(el => {@@@ -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"@@@ -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": {@@@ -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' });
@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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
- 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
@@ -592,6 +592,10 @@
@@ -592,6 +592,10 @@
} catch { return null; }
} 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) {
function bindLauncher(nav) {
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
let panel = null;
let panel = null;
@@ -646,7 +650,7 @@
@@ -646,7 +650,7 @@
ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
});
});
panel.addEventListener('click', (ev) => ev.stopPropagation());
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()); } });
launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
}
}
panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
@@ -697,7 +701,7 @@
@@ -697,7 +701,7 @@
}
}
closeDrawer();
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).
// 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 placeDd = (dd) => {
const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;
const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;
@@ -710,7 +714,7 @@
@@ -710,7 +714,7 @@
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
if (burger && drawer) {
burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });
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);
regPanel(drawer, 'nav-drawer', closeDrawer);
}
}
}
}
@@ -1319,6 +1323,8 @@
@@ -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.
// 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 = []; }
let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; }
if (_navEl) _navEl.remove();
if (_navEl) _navEl.remove();
+ if (_renderAbort) _renderAbort.abort();
+ _renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
const nav = document.createElement('nav');
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
nav.className = 'openvibe-navbar';
@@ -1335,8 +1341,7 @@
@@ -1335,8 +1341,7 @@
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(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"]'))) {
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 { /* */ }
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 @@
@@ -1444,7 +1449,7 @@
// Close on outside click
// Close on outside click
document.addEventListener('click', e => {
document.addEventListener('click', e => {
if (!nav.contains(e.target)) dropdown.classList.remove('open');
if (!nav.contains(e.target)) dropdown.classList.remove('open');
- });
+ }, onDocument());
// Account switching
// Account switching
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
dropdown.querySelectorAll('[data-account-id]').forEach(el => {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.13.2",
+ "version": "2.13.3",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.13.2",
+ "version": "2.13.3",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -130,6 +130,19 @@ function load(hostname, opts = {}) {
@@ -130,6 +130,19 @@ function load(hostname, opts = {}) {
second.navbar.init({ service: 'coupons', user: null });
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');
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 ────
// ── rendered markup carries the segments, compact attr and the variant ────
{
{
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });