openvibers/openvibe.shared Commits

Shared 2.14.0: a boost page move no longer keeps the old navbar alive (panels + probe) (#34)

OP OpenVibers committed 8 Oct 2026 · 7cd39f881c · 10 files, +221 −32

* Shared 2.13.4: a boost page move no longer keeps the old navbar (panels.js held every old bar; the harness probe held every aborted listener)

panels.js kept each registered panel (the navbar's drawer on every render, the
account menu when signed in) in its list with a MutationObserver, so every
re-render left the removed bar, its ~130 nodes and its listeners alive.
OpenVibePanels.register() takes an AbortSignal and returns unregister();
OpenVibePanels.unregister(el). The navbar registers its panels and its
ov:display window listeners with the render's signal and clears a gone
render's queued registrations.

browser-harness's navigation probe kept every window/document listener added
with { signal } after the signal aborted (only removeEventListener was seen),
so with 2.13.3's navbar the growth check measured its own probe. It now
forgets a listener when its signal aborts.

ovhost browser-check (patched files served into the live pages, lap 2 -> 5):
codes +780 nodes/+64 listeners -> 0/0; services, space and community pass too.
New boost-navbar-chrome.test.js; panels and navbar-brand tests cover the
signal lifecycle. navbar.js option notes moved to the README (28.8 KB brotli,
budget 29). npm test 56/57 (+ floating-promises with acorn).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

* Shared 2.14.0, not 2.13.4: OpenVibePanels gains unregister and a signal option (new API, so a minor release).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6f19410..d749677 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,27 @@ 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.14.0 — 2026-10-08
++
++- A boost page move no longer keeps the old navbar. 2.13.3 removed the render's document listeners, but panels.js
++ still held every panel ever registered (the drawer on each render; signed in, the account menu too) in its list,
++ with a MutationObserver, so each re-render kept the whole removed bar and its listeners alive.
++ `OpenVibePanels.register()` now takes `signal` (an AbortSignal: the registration ends when it aborts, its observer
++ is disconnected) and returns its `unregister()`; `OpenVibePanels.unregister(el)` does the same by element. The
++ navbar registers its panels with the render's signal, gives the display rows' `ov:display` window listeners that
++ signal too, and drops a gone render's queued registrations.
++- browser-harness: the navigation probe forgets a window or document listener when its AbortSignal aborts, as the
++ browser does. It kept every one, and with it whatever the listener closed over, so the growth check also measured
++ its own probe: with 2.13.3's navbar alone it still saw the old bars.
++- Measured with ovhost browser-check's method (probe on, forced GC, lap 2 → 5), the patched files served into the
++ live pages: openvibe.codes +780 nodes, +60 listeners → 0, 0; openvibe.services +852, +60 → 0, 0; openvibe.space
++ +744, +72 → 0, 0; openvibe.community +3324, +72 → 0, 0. Both halves are needed: either one alone still grows.
++- New test boost-navbar-chrome.test.js (real Chrome: navbar, panels and boost over six laps, measured like
++ browser-check; no node or listener growth, no detached bar); panels.test.js and navbar-brand.test.js cover the
++ signal lifecycle.
++- The navbar's notes on `history`, `silentLogin`, `fedcm` and `fedcmLogin` moved to the README (navbar.js 28.8 KB
++ brotli, budget 29 KB).
++
## 2.13.3 — 2026-10-08
- The navbar removes the document listeners of its previous render (the outside-click closers of the launcher, the
ddiff --git a/README.md b/README.md
iindex 8c39e4b..9e76e72 100644
---- a/README.md
++++ b/README.md
@@@ -634,6 +634,19 @@ Menus are modular: every site keeps the shared account menu and adds its own
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
"Recently used" row fed by the shared history module when it is loaded.
++Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
++ title, url, icon } is recorded for the signed-in user once auth resolves
++ (cross-site "Recently used" / History on the Network). silentLogin:
++ 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
++ here but this browser has signed in to the network before (ov_sso_hint),
++ one silent prompt=none round trip per tab, so a session on one site becomes
++ a session on all. fedcm: 'optional' (default) shows the browser's native
++ chip the first time and re-authenticates silently afterwards, 'silent' only
++ re-authenticates, false opts out; fedcmLogin is the POST target for the
++ assertion (default: this site's /auth/fedcm).
++Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
++ previous render's document and window listeners and its panels.js
++ registrations, so the old bar can be collected.
```
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 4a76881..e4f5ce3 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -186,7 +186,9 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea
}
// Installed before any page script (navigation check only): live intervals, pending timeouts, open sockets
--// and window/document listeners as the browser keeps them (re-adding the same listener is a no-op).
++// and window/document listeners as the browser keeps them (re-adding the same listener is a no-op; `once` ones are
++// not counted; one added with an AbortSignal is forgotten when the signal aborts, as the browser forgets it: holding it
++// would keep alive whatever it closes over, and the check would measure its own probe).
const PROBE = `(() => {
if (window.__ovProbe) return;
const w = window; const intervals = new Set(), timeouts = new Set(), sockets = new Set();
@@@ -208,11 +210,16 @@ const PROBE = `(() => {
const add = target.addEventListener, rem = target.removeEventListener; const key = target === w ? 'window' : 'document';
const reg = new Map(); counts[key] = () => [...reg.values()].reduce((n, m) => n + [...m.values()].reduce((k, s) => k + s.size, 0), 0);
const cap = (o) => !!(o === true || (o && o.capture));
++ const forget = (t, fn, c) => { const m = reg.get(t); if (m && m.has(fn)) { m.get(fn).delete(c); if (!m.get(fn).size) m.delete(fn); } };
target.addEventListener = function (t, fn, o) {
-- if (fn && !(o && o.once)) { if (!reg.has(t)) reg.set(t, new Map()); const m = reg.get(t); if (!m.has(fn)) m.set(fn, new Set()); m.get(fn).add(cap(o)); }
++ const sig = o && typeof o === 'object' ? o.signal : null;
++ if (fn && !(o && o.once) && !(sig && sig.aborted)) {
++ if (!reg.has(t)) reg.set(t, new Map()); const m = reg.get(t); if (!m.has(fn)) m.set(fn, new Set());
++ const c = cap(o); if (!m.get(fn).has(c)) { m.get(fn).add(c); if (sig) sig.addEventListener('abort', () => forget(t, fn, c), { once: true }); }
++ }
return add.call(this, t, fn, o);
};
-- target.removeEventListener = function (t, fn, o) { const m = reg.get(t); if (m && m.has(fn)) { m.get(fn).delete(cap(o)); if (!m.get(fn).size) m.delete(fn); } return rem.call(this, t, fn, o); };
++ target.removeEventListener = function (t, fn, o) { forget(t, fn, cap(o)); return rem.call(this, t, fn, o); };
}
Object.defineProperty(w, '__ovProbe', { value: () => ({ intervals: intervals.size, timeouts: timeouts.size,
sockets: [...sockets].filter((s) => s.readyState < 2).length, windowListeners: counts.window(), documentListeners: counts.document(),
ddiff --git a/navbar.js b/navbar.js
iindex cbae2a1..e88b821 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -8,17 +8,7 @@
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
-- // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
-- // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
-- history: null,
-- // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
-- // but this browser has signed in to the network before (ov_sso_hint), try one silent
-- // prompt=none round trip per tab so a session on one site becomes a session on all.
-- silentLogin: null,
-- // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first
-- // time and re-authenticates silently afterwards; 'silent' only re-authenticates.
-- fedcm: 'optional',
-- fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)
++ history: null, silentLogin: null, fedcm: 'optional', fedcmLogin: null, // README.md, "The navbar"
};
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
@@@ -592,7 +582,7 @@
} catch { return null; }
}
-- // A render's document listeners; the next render (each boost page move) aborts them, so the old bar can go.
++ // A render's document/window listeners and panels; 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);
@@@ -724,7 +714,7 @@
const _panelQueue = [];
function regPanel(el, id, close) {
if (!el) return;
-- const opts = { el, openClass: 'open', id, close };
++ const opts = Object.assign({ el, openClass: 'open', id, close }, onDocument());
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
@@@ -749,7 +739,7 @@
const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
-- root.addEventListener('ov:display', paint); paint();
++ root.addEventListener('ov:display', paint, onDocument()); paint();
}
/** OpenCoins balance in the menu header (one request when the menu first opens). */
@@@ -788,7 +778,7 @@
box.hidden = false;
};
box.addEventListener('click', (e) => { const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint(); });
-- root.addEventListener('ov:display', paint);
++ root.addEventListener('ov:display', paint, onDocument());
paint();
}
@@@ -1323,7 +1313,7 @@
// 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();
++ if (_renderAbort) { _renderAbort.abort(); _panelQueue.length = 0; }
_renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
const nav = document.createElement('nav');
ddiff --git a/package-lock.json b/package-lock.json
iindex 93ef8fa..f910b78 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.13.3",
++ "version": "2.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.13.3",
++ "version": "2.14.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex bcf1f9b..2995fc4 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.13.3",
++ "version": "2.14.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/panels.js b/panels.js
iindex 6865d97..692df15 100644
---- a/panels.js
++++ b/panels.js
@@@ -8,18 +8,22 @@
* • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
* • a freshly opened panel starts scrolled to the top
*
-- * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })
-- * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()
++ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu', signal? }) → unregister()
++ * OpenVibePanels.unregister(el) · OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()
*
* It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a
* panel is "open" exactly when its element has `openClass`, however that came about.
++ *
++ * A registration holds its element until it is unregistered: markup that is thrown away and rebuilt (the navbar
++ * re-renders on every openvibe-shared/boost page move) passes an AbortSignal, or calls unregister, so the old
++ * element can be collected.
*/
(function (root) {
'use strict';
if (root.OpenVibePanels) return;
-- if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }
++ if (typeof document === 'undefined') { const stub = { register: () => () => {}, unregister() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }
-- const panels = []; // { el, openClass, close, id }
++ const panels = []; // { el, openClass, close, id, unregister }
const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);
const anyOpen = () => panels.some(isOpen);
const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
@@@ -32,22 +36,35 @@
}
function register(opts) {
-- const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;
++ const el = opts && opts.el, signal = opts && opts.signal;
++ if (!el || (signal && signal.aborted)) return () => {};
++ const known = panels.find(p => p.el === el); if (known) return known.unregister;
const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };
-- panels.push(p);
let was = isOpen(p);
-- new MutationObserver(() => {
++ const watch = new MutationObserver(() => {
const now = isOpen(p); if (now === was) return; was = now;
if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
syncBody();
-- }).observe(el, { attributes: true, attributeFilter: ['class'] });
++ });
++ p.unregister = () => {
++ const i = panels.indexOf(p); if (i < 0) return;
++ panels.splice(i, 1);
++ watch.disconnect();
++ syncBody();
++ };
++ panels.push(p);
++ watch.observe(el, { attributes: true, attributeFilter: ['class'] });
++ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', p.unregister, { once: true });
syncBody();
++ return p.unregister;
}
++ function unregister(el) { const p = panels.find(x => x.el === el); if (p) p.unregister(); }
++
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });
root.addEventListener('orientationchange', () => closeAll(), { passive: true });
-- const api = { register, closeAll, anyOpen };
++ const api = { register, unregister, closeAll, anyOpen };
root.OpenVibePanels = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/boost-navbar-chrome.test.js b/test/boost-navbar-chrome.test.js
nnew file mode 100644
iindex 0000000..6de1f34
---- /dev/null
++++ b/test/boost-navbar-chrome.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++// The navbar under openvibe-shared/boost in a real headless Chrome, measured the way ovhost browser-check measures a
++// site (the harness's probe installed, a forced GC, Performance.getMetrics): every boost page move re-renders the bar,
++// and nothing of the old bar may survive it. Before 2.13.4 the panels coordinator kept every old drawer (and with it
++// the whole old bar and its listeners) and the probe kept every listener removed through an AbortSignal: +250 nodes
++// and +20 listeners per lap on openvibe.codes (2026-10-08). Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('boost-navbar-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const LINKS = [{ label: 'Home', href: '/' }, { label: 'Two', href: '/two' }, { label: 'Three', href: '/three' }];
++const page = (title) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
++<meta name="ov-boost" content="site@r1"><link rel="icon" href="data:,">
++<script src="/shared/navbar.js" defer></script><script src="/shared/boost.js" data-main="#main" defer></script></head>
++<body><div id="navbar-mount"></div><main id="main"><h1>${title}</h1><p><a id="to-home" href="/">Home</a> <a id="to-two" href="/two">Two</a></p></main>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, networkLinks: false, countViews: false, notifications: false }); });</script>
++</body></html>`;
++const PAGES = { '/': page('Home'), '/two': page('Two') };
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (PAGES[u.pathname]) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); return res.end(PAGES[u.pathname]); }
++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boost-nav-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ try {
++ const context = await browser.newContext();
++ // Nothing leaves the machine (the bar's network data and release watch are optional).
++ const p = await h.openPage(browser, context, { width: 1280, probe: true, block: ['*openvibe.network*', '*openvibe.tools*', '*jsdelivr*'] });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (window.OVBoost && OVBoost.controller && document.querySelector('nav.openvibe-navbar') && window.OpenVibePanels) { clearInterval(t); r(); } }, 50); })`);
++
++ // The probe forgets a listener its AbortSignal removed, as the browser does (holding it kept what it closes over).
++ const probe = await p.evaluate(`(() => { const before = __ovProbe().documentListeners; const ac = new AbortController();
++ document.addEventListener('ov:test', () => {}, { signal: ac.signal }); const added = __ovProbe().documentListeners;
++ ac.abort(); return [added - before, __ovProbe().documentListeners - before]; })()`);
++ assert.deepStrictEqual(probe, [1, 0], 'the probe counts a signal listener, then forgets it on abort');
++
++ const move = (id) => p.evaluate(`new Promise((done) => { document.addEventListener('ov:boost:load', () => done(location.pathname), { once: true }); document.getElementById('${id}').click(); })`);
++ const sample = async () => {
++ await p.send('HeapProfiler.collectGarbage'); await wait(150); await p.send('HeapProfiler.collectGarbage');
++ const m = await p.metrics();
++ const pr = await p.evaluate('__ovProbe()');
++ return { nodes: m.Nodes, listeners: m.JSEventListeners, doc: pr.documentListeners, win: pr.windowListeners };
++ };
++ const samples = [];
++ for (let lap = 0; lap < 6; lap++) {
++ assert.strictEqual(await move('to-two'), '/two');
++ assert.strictEqual(await move('to-home'), '/');
++ samples.push(await sample());
++ }
++ assert.strictEqual(await p.evaluate('OVBoost.controller.stats().moves'), 12, 'every move was a boost swap (no page load)');
++ assert.strictEqual(await p.evaluate('document.querySelectorAll("nav.openvibe-navbar").length'), 1, 'one bar on the page');
++ const a = samples[1], b = samples[samples.length - 1];
++ const d = { nodes: b.nodes - a.nodes, listeners: b.listeners - a.listeners, doc: b.doc - a.doc, win: b.win - a.win };
++ // A re-rendered bar is ~60 nodes and ~10 listeners here: four laps (eight moves) of keeping it would be ~500 and ~80.
++ assert.ok(d.nodes <= 20, `no DOM growth from lap 2 to lap 6 (${JSON.stringify(d)}; ${JSON.stringify(samples)})`);
++ assert.ok(d.listeners <= 2, `no listener growth from lap 2 to lap 6 (${JSON.stringify(d)})`);
++ assert.ok(d.doc <= 0 && d.win <= 0, `the probe's window and document listeners do not grow (${JSON.stringify(d)})`);
++ try {
++ const { detachedNodes } = await p.send('DOM.getDetachedDomNodes');
++ const bars = detachedNodes.filter((n) => n.treeNode && n.treeNode.nodeName === 'NAV');
++ assert.strictEqual(bars.length, 0, `no old bar survives (${bars.length} detached)`);
++ } catch (e) { if (e instanceof assert.AssertionError) throw e; /* an older Chrome without DOM.getDetachedDomNodes */ }
++ console.log('boost-navbar-chrome: all checks passed');
++ } finally {
++ await browser.close().catch(() => {});
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex e35bc2b..5e675eb 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -143,6 +143,39 @@ function load(hostname, opts = {}) {
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');
}
++// ── a re-render also ends the previous render's panel registrations and window listeners (the panels coordinator
++// and the display rows' window listener held every old bar: ovhost browser-check, 2026-10-08) ────
++{
++ const { navbar } = load('openvibe.space');
++ const regs = [], winListeners = [];
++ // navbar.js's root is globalThis under node (the window in a browser).
++ global.OpenVibePanels = { register: (o) => { regs.push(o); } };
++ global.OpenVibeThemeLoader = { display: { get: () => ({ text: 100, motion: 'auto' }), set() {}, options: { text: [100], motion: ['auto'] } } };
++ global.addEventListener = (type, fn, opts) => winListeners.push({ type, opts });
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ const first = regs.slice(), firstDisplay = winListeners.filter((l) => l.type === 'ov:display');
++ assert.deepStrictEqual(first.map((r) => r.id).sort(), ['nav-drawer', 'user-menu'], 'the drawer and the account menu are registered');
++ assert.ok(first.every((r) => r.signal && !r.signal.aborted), 'each registration carries the render\'s signal');
++ assert.ok(firstDisplay.length === 1 && firstDisplay[0].opts && firstDisplay[0].opts.signal, 'the display rows\' window listener carries it too');
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ assert.ok(first.every((r) => r.signal.aborted) && firstDisplay[0].opts.signal.aborted, 'the next render ended them');
++ assert.ok(regs.slice(first.length).every((r) => !r.signal.aborted), 'the new render\'s registrations are live');
++ delete global.OpenVibePanels; delete global.OpenVibeThemeLoader; delete global.addEventListener;
++}
++// ── before panels.js loads, registrations queue; a render that is gone registers nothing when it arrives ────
++{
++ const { navbar, created } = load('openvibe.space');
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ navbar.init({ service: 'space', user: { id: 1, username: 'x' } });
++ const loaders = created.filter((e) => e.tag === 'script' && e.id === 'ov-panels-loader');
++ assert.ok(loaders.length >= 1, 'panels.js was requested');
++ const regs = [];
++ global.OpenVibePanels = { register: (o) => { regs.push(o); } };
++ loaders[loaders.length - 1].onload();
++ delete global.OpenVibePanels;
++ assert.deepStrictEqual(regs.map((r) => r.id).sort(), ['nav-drawer', 'user-menu'], 'only the live render\'s panels are registered');
++ assert.ok(regs.every((r) => !r.signal.aborted));
++}
// ── rendered markup carries the segments, compact attr and the variant ────
{
const { navbar, created } = load('pastes.openvibe.tools', { pathname: '/mine' });
ddiff --git a/test/panels.test.js b/test/panels.test.js
iindex 8698387..bdafebc 100644
---- a/test/panels.test.js
++++ b/test/panels.test.js
@@@ -7,7 +7,7 @@ const mk = (name, parent) => { const cls = new Set(); const el = { name, parent,
contains: (o) => { for (let x = o; x; x = x.parent) if (x === el) return true; return false; } }; return el; };
const fire = (el) => observers.filter(o => o.el === el).forEach(o => o.cb());
const body = mk('body');
--const ctx = vm.createContext({ console, MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb }); },
++const ctx = vm.createContext({ console, MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb, mo: this }); this.disconnect = () => { for (let i = observers.length - 1; i >= 0; i--) if (observers[i].mo === this) observers.splice(i, 1); }; },
document: { body, addEventListener: (t, f) => { listeners[t] = f; } } });
ctx.window = ctx; ctx.globalThis = ctx; ctx.addEventListener = () => {};
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'panels.js'), 'utf8'), ctx);
@@@ -18,4 +18,25 @@ menu.classList.add('show'); assert.ok(body.classList.contains('ov-panel-open'));
drawer.classList.add('show'); assert.ok(!menu.classList.contains('show'), 'opening one closes the other');
sub.classList.add('open'); assert.ok(drawer.classList.contains('show'), 'a nested panel keeps its container open');
listeners.keydown({ key: 'Escape' }); assert.ok(!drawer.classList.contains('show') && !sub.classList.contains('open') && !body.classList.contains('ov-panel-open'), 'Escape closes everything');
++
++// A registration ends with its signal (the navbar re-renders on every boost page move) or with unregister: the
++// coordinator lets go of the element (holding it kept every old bar alive), stops watching it and never closes it again.
++{
++ const ac = new AbortController(); const old = mk('old-drawer'); let closedOld = 0;
++ const off = P.register({ el: old, openClass: 'open', signal: ac.signal, close: () => { closedOld++; old.classList.remove('open'); } });
++ assert.strictEqual(typeof off, 'function', 'register returns its unregister');
++ assert.strictEqual(P.register({ el: old, openClass: 'open' }), off, 'registering the same element again: the same registration');
++ assert.ok(observers.some(o => o.el === old), 'watched while registered');
++ old.classList.add('open'); assert.ok(body.classList.contains('ov-panel-open'));
++ ac.abort();
++ assert.ok(!observers.some(o => o.el === old), 'the abort disconnected its observer');
++ assert.ok(!P.anyOpen() && !body.classList.contains('ov-panel-open'), 'an unregistered panel no longer counts as open');
++ menu.classList.add('show'); assert.strictEqual(closedOld, 0, 'opening another panel no longer closes it');
++ menu.classList.remove('show');
++ const late = mk('late'); P.register({ el: late, signal: ac.signal });
++ assert.ok(!observers.some(o => o.el === late), 'an already-aborted signal registers nothing');
++ const byEl = mk('by-el'); P.register({ el: byEl });
++ P.unregister(byEl); assert.ok(!observers.some(o => o.el === byEl), 'unregister(el) lets go too');
++ off(); P.unregister(mk('never-registered')); // both are no-ops
++}
console.log('panels: all checks passed');