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
Changes
@@@ -4,6 +4,10 @@ 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).++## 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).@@@ -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 oneEventSource per tab, without credentials, on@@@ -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"@@@ -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": {@@@ -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;});}@@@ -91,6 +91,13 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objecconst 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 Objecassert.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');
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
+## 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
## 1.18.0 — 2026-09-26
**Update telemetry** (roadmap WS-P task 14).
**Update telemetry** (roadmap WS-P task 14).
@@ -192,6 +192,18 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -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`.
`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"`.
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`.
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
- **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
`host.release.published` (public, `ovhost deploy` / `ovhost announce`). release-watch opens one
EventSource per tab, without credentials, on
EventSource per tab, without credentials, on
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.18.0",
+ "version": "1.19.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.18.0",
+ "version": "1.19.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": "1.18.0",
+ "version": "1.19.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",
"exports": {
"exports": {
@@ -9,7 +9,9 @@
@@ -9,7 +9,9 @@
* styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones
* 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),
* 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
* 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
* apply(plan, next, busy, record) all of the above for an in-place plan
*/
*/
(function (root) {
(function (root) {
@@ -127,13 +129,15 @@
@@ -127,13 +129,15 @@
if (why) { record('deferred', why, true); return true; }
if (why) { record('deferred', why, true); return true; }
const r = w.el.getBoundingClientRect();
const r = w.el.getBoundingClientRect();
const st = w.el.scrollTop;
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)));
w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
const rev = w.fresh.getAttribute('data-ov-rev');
const rev = w.fresh.getAttribute('data-ov-rev');
if (rev) w.el.setAttribute('data-ov-rev', rev);
if (rev) w.el.setAttribute('data-ov-rev', rev);
w.el.scrollTop = st;
w.el.scrollTop = st;
// A region above the viewport that changed height must not move what the reader is looking at.
// 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);
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;
return false;
});
});
}
}
@@ -91,6 +91,13 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -91,6 +91,13 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
const p = await open();
const p = await open();
let applied = 0; p.window.addEventListener('ov:release-applied', () => { applied++; });
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; });
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.release.current, A);
assert.strictEqual(p.requests.length, 1, 'at load: only /release.json');
assert.strictEqual(p.requests.length, 1, 'at load: only /release.json');
await update(p, M2);
await update(p, M2);
@@ -105,6 +112,9 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -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.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.match(p.document.querySelector('aside').textContent, /^side$/, 'a region whose component did not change is left alone');
assert.strictEqual(updated, 'docs');
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.strictEqual(applied, 1);
assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'no reload, no prompt');
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');
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');