openvibers/openvibe.shared Commits

v1.19.0: the update matrix, complete (roadmap WS-P task 8): release-update sends ov:content-dispose before a region is replaced so a widget inside drops its listeners and timers (ov:content-updated follows to mount it again); tests cover every row (style swap and ov:styles-updated, revision-aware content with scroll kept, widget disposal, busy deferral, prompt, security minimum); the README lists the matrix

OP OpenVibers committed 26 Sept 2026 · d8693c939a · 6 files, +35 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 79c7ca8..a2b0850 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ 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).
++## 1.19.0 — 2026-09-26
++
++**The update matrix, complete** (roadmap WS-P task 8). `release-update.js` sends `ov:content-dispose` (bubbling, `{ component, release }`) on a region just before it is replaced, so a stateless widget inside can drop its listeners and timers; `ov:content-updated` follows as before, to mount it again. Tests now cover every row of the matrix: stylesheet swap and `ov:styles-updated`, revision-aware content with scroll kept, widget disposal and remount, deferral of busy regions, the prompt, and the security minimum. The README "Releases" section lists the rows.
++
## 1.18.0 — 2026-09-26
**Update telemetry** (roadmap WS-P task 14).
ddiff --git a/README.md b/README.md
iindex 0f610fc..b7715ed 100644
---- a/README.md
++++ b/README.md
@@@ -192,6 +192,18 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
`data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.
A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.
Mark anything that must not be replaced with `data-ov-protected`.
++- **The update matrix (WS-P task 8)**, what an open tab does with each kind of change, each row tested
++ (test/release-update.test.js, test/release.test.js):
++
++ | Change | What the tab does | Events / counts |
++ |---|---|---|
++ | Stylesheet or theme (`style`) | loads the new `<link>` beside the old, swaps when every one loaded; a failure or 15 s timeout keeps the old | `ov:styles-updated`; `applied` or `failed: style/style-timeout` |
++ | Content (`content`) | re-fetches the region, skips an unchanged `data-ov-rev`, keeps its scroll and what the reader sees above it | `ov:content-updated`; `applied` |
++ | A widget inside a region | told to let go before its region is replaced, then to mount again | `ov:content-dispose`, then `ov:content-updated` |
++ | A busy region (focus, unsent form, playing media, live camera/mic, `data-ov-protected`) | waits, and is replaced once it is not busy | `deferred` with the reason, once per release |
++ | Shell, router or any script (`script` and anything not in place) | prompts once; reloads only when it must | `prompted: optional` |
++ | Security minimum (`min_client_release`), mixed-version window over, contracts out of range | reloads when safe (hidden or idle, nothing busy), prompting meanwhile | `prompted` and `reloaded: required/window/contract` |
++ | Server only (`server`) | nothing to do in the tab | `applied: server` |
- **Release notifications (1.17.0).** When OpenVibe.Host announces a release, it publishes
`host.release.published` (public, `ovhost deploy` / `ovhost announce`). release-watch opens one
EventSource per tab, without credentials, on
ddiff --git a/package-lock.json b/package-lock.json
iindex bebb045..abd9f8d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.18.0",
++ "version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.18.0",
++ "version": "1.19.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex a5ac0b9..313a1b6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.18.0",
++ "version": "1.19.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/release-update.js b/release-update.js
iindex dd05b4a..d25994c 100644
---- a/release-update.js
++++ b/release-update.js
@@@ -9,7 +9,9 @@
* styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones
* regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),
* without scripts, frames or inline handlers; unchanged data-ov-rev is skipped
-- * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting
++ * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting; each
++ * gets ov:content-dispose first (a widget's code drops its listeners and timers) and
++ * ov:content-updated after (it mounts again), both bubbling with { component, release }
* apply(plan, next, busy, record) all of the above for an in-place plan
*/
(function (root) {
@@@ -127,13 +129,15 @@
if (why) { record('deferred', why, true); return true; }
const r = w.el.getBoundingClientRect();
const st = w.el.scrollTop;
++ const ev = (n) => { try { w.el.dispatchEvent(new root.CustomEvent(n, { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ } };
++ ev('ov:content-dispose');
w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
const rev = w.fresh.getAttribute('data-ov-rev');
if (rev) w.el.setAttribute('data-ov-rev', rev);
w.el.scrollTop = st;
// A region above the viewport that changed height must not move what the reader is looking at.
if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);
-- try { w.el.dispatchEvent(new root.CustomEvent('ov:content-updated', { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ }
++ ev('ov:content-updated');
return false;
});
}
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex c96556a..d1a01b3 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -91,6 +91,13 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
const p = await open();
let applied = 0; p.window.addEventListener('ov:release-applied', () => { applied++; });
let updated = null; p.document.querySelector('main').addEventListener('ov:content-updated', (e) => { updated = e.detail.component; });
++ // A stateless widget in the region is told to let go first (listeners, timers), then to mount again.
++ const order = [];
++ p.document.querySelector('main').addEventListener('ov:content-dispose', (e) => order.push(['dispose', e.detail.component, e.detail.release, /old text/.test(e.target.textContent)]));
++ p.document.querySelector('main').addEventListener('ov:content-updated', (e) => order.push(['mount', e.detail.component, e.detail.release, /new text/.test(e.target.textContent)]));
++ let stylesEvent = null; p.window.addEventListener('ov:styles-updated', (e) => { stylesEvent = e.detail.release; });
++ // Scroll inside the region is kept across the patch.
++ p.document.querySelector('main').scrollTop = 40;
assert.strictEqual(p.release.current, A);
assert.strictEqual(p.requests.length, 1, 'at load: only /release.json');
await update(p, M2);
@@@ -105,6 +112,9 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
assert.strictEqual(main.querySelector('img').getAttribute('src'), 'data:image/png;base64,AAAA', 'data: images stay');
assert.match(p.document.querySelector('aside').textContent, /^side$/, 'a region whose component did not change is left alone');
assert.strictEqual(updated, 'docs');
++ assert.deepStrictEqual(order, [['dispose', 'docs', B, true], ['mount', 'docs', B, true]], 'disposed while the old content is there, mounted with the new');
++ assert.strictEqual(stylesEvent, B, 'ov:styles-updated once the new stylesheets replaced the old');
++ assert.strictEqual(p.document.querySelector('main').scrollTop, 40, 'scroll inside the region is kept');
assert.strictEqual(applied, 1);
assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'no reload, no prompt');
assert.ok(p.document.querySelector('script[src="https://site.test/shared/release-update.js"]'), 'release-update.js was loaded from next to release-watch.js, on demand');