openvibers/openvibe.shared Commits
Shared 2.14.0: a boost page move no longer keeps the old navbar alive (panels + probe) (#34)
Changes
@@@ -4,6 +4,27 @@ 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.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@@@ -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.@@@ -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(),@@@ -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');@@@ -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"@@@ -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": {@@@ -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);nnew file mode 100644@@@ -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); });@@@ -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' });@@@ -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');
@@ -4,6 +4,27 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 2.13.3 — 2026-10-08
- The navbar removes the document listeners of its previous render (the outside-click closers of the launcher, the
- The navbar removes the document listeners of its previous render (the outside-click closers of the launcher, the
@@ -634,6 +634,19 @@ Menus are modular: every site keeps the shared account menu and adds its own
@@ -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()
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
/ 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.
"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.
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.
@@ -186,7 +186,9 @@ async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, hea
@@ -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
// 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 = `(() => {
const PROBE = `(() => {
if (window.__ovProbe) return;
if (window.__ovProbe) return;
const w = window; const intervals = new Set(), timeouts = new Set(), sockets = new Set();
const w = window; const intervals = new Set(), timeouts = new Set(), sockets = new Set();
@@ -208,11 +210,16 @@ const PROBE = `(() => {
@@ -208,11 +210,16 @@ const PROBE = `(() => {
const add = target.addEventListener, rem = target.removeEventListener; const key = target === w ? 'window' : 'document';
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 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 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) {
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);
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,
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(),
sockets: [...sockets].filter((s) => s.readyState < 2).length, windowListeners: counts.window(), documentListeners: counts.document(),
@@ -8,17 +8,7 @@
@@ -8,17 +8,7 @@
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
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: [] };
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
let _runtimeLinks = null;
@@ -592,7 +582,7 @@
@@ -592,7 +582,7 @@
} 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.
+ // 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;
let _renderAbort = null;
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
@@ -724,7 +714,7 @@
@@ -724,7 +714,7 @@
const _panelQueue = [];
const _panelQueue = [];
function regPanel(el, id, close) {
function regPanel(el, id, close) {
if (!el) return;
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);
if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
_panelQueue.push(opts);
_panelQueue.push(opts);
if (document.getElementById('ov-panels-loader')) return;
if (document.getElementById('ov-panels-loader')) return;
@@ -749,7 +739,7 @@
@@ -749,7 +739,7 @@
const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
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]] || ''; }); };
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(); }));
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). */
/** OpenCoins balance in the menu header (one request when the menu first opens). */
@@ -788,7 +778,7 @@
@@ -788,7 +778,7 @@
box.hidden = false;
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(); });
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();
paint();
}
}
@@ -1323,7 +1313,7 @@
@@ -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.
// 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();
+ if (_renderAbort) { _renderAbort.abort(); _panelQueue.length = 0; }
_renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
_renderAbort = typeof AbortController === 'function' ? new AbortController() : null;
const nav = document.createElement('nav');
const nav = document.createElement('nav');
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.13.3",
+ "version": "2.14.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "2.13.3",
+ "version": "2.14.0",
"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.3",
+ "version": "2.14.0",
"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": {
@@ -8,18 +8,22 @@
@@ -8,18 +8,22 @@
* • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
* • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
* • a freshly opened panel starts scrolled to the top
* • 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
* 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.
* 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) {
(function (root) {
'use strict';
'use strict';
if (root.OpenVibePanels) return;
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 isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);
const anyOpen = () => panels.some(isOpen);
const anyOpen = () => panels.some(isOpen);
const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
@@ -32,22 +36,35 @@
@@ -32,22 +36,35 @@
}
}
function register(opts) {
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') };
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);
let was = isOpen(p);
- new MutationObserver(() => {
+ const watch = new MutationObserver(() => {
const now = isOpen(p); if (now === was) return; was = now;
const now = isOpen(p); if (now === was) return; was = now;
if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
syncBody();
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();
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(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });
root.addEventListener('orientationchange', () => closeAll(), { passive: true });
root.addEventListener('orientationchange', () => closeAll(), { passive: true });
- const api = { register, closeAll, anyOpen };
+ const api = { register, unregister, closeAll, anyOpen };
root.OpenVibePanels = api;
root.OpenVibePanels = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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); });
@@ -143,6 +143,39 @@ function load(hostname, opts = {}) {
@@ -143,6 +143,39 @@ function load(hostname, opts = {}) {
const latest = docListeners.filter((l) => l.type === 'click').slice(first.length);
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');
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 ────
// ── 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' });
@@ -7,7 +7,7 @@ const mk = (name, parent) => { const cls = new Set(); const el = { name, parent,
@@ -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; };
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 fire = (el) => observers.filter(o => o.el === el).forEach(o => o.cb());
const body = mk('body');
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; } } });
document: { body, addEventListener: (t, f) => { listeners[t] = f; } } });
ctx.window = ctx; ctx.globalThis = ctx; ctx.addEventListener = () => {};
ctx.window = ctx; ctx.globalThis = ctx; ctx.addEventListener = () => {};
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'panels.js'), 'utf8'), ctx);
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'));
@@ -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');
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');
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');
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');
console.log('panels: all checks passed');