openvibers/openvibe.shared Commits
4.88.1: an aborted view transition is silent on the account hub (#243)
Changes
@@@ -4,6 +4,16 @@ 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).++## 4.88.1 — 2026-11-01++++- **An aborted view transition is silent on the account hub.**++ - Signed out, `/my` and `/themes` send the visitor to `/login`, a page that does not load the kit and so does not opt++ in to cross-document view transitions. Chrome aborted the transition and left an uncaught `InvalidStateError` on++ the sign-in page: "Transition was aborted because of invalid state. ViewTransition opt-in disabled".++ - The account switcher now handles `finished`, `ready` and `updateCallbackDone` in `pageswap`/`pagereveal`, and skips++ a `replace` navigation's transition before it starts: a redirect is not a page move, and a transition that cannot++ land has nothing to animate.++## 4.88.0 — 2026-11-01- **Name effects read on light themes.**@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.88.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.88.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.88.0",++ "version": "4.88.1","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -28,5 +28,5 @@"the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went","the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"],-- "updated": "2026-10-30"++ "updated": "2026-11-01"}@@@ -19,6 +19,26 @@let _config = { apiBase: 'https://openvibe.network', onSwitch: null };let _panelEl = null;++ // The account hub is a redirect when signed out: it loads this file, opts into cross-document view transitions++ // (kit.css) and then navigates to /login, which does not include the kit and so does not opt in. Chrome aborts++ // the transition and leaves an uncaught InvalidStateError on the sign-in page ("ViewTransition opt-in disabled",++ // 11-01). A replace navigation is that redirect, not a page move: skip its transition before it starts, and keep++ // the transition's promises handled so a transition that cannot complete is silent either way.++ function quietViewTransition(event) {++ const transition = event && event.viewTransition;++ if (!transition) return;++ for (const promise of [transition.finished, transition.ready, transition.updateCallbackDone]) {++ try { if (promise && typeof promise.catch === 'function') promise.catch(() => {}); } catch { /* not a promise */ }++ }++ const activation = event.activation;++ if (!activation || activation.navigationType !== 'replace') return;++ try { if (typeof transition.skipTransition === 'function') transition.skipTransition(); } catch { /* already gone */ }++ }++ if (typeof document !== 'undefined' && typeof root.addEventListener === 'function') {++ root.addEventListener('pageswap', quietViewTransition);++ root.addEventListener('pagereveal', quietViewTransition);++ }++function isAnonId(id) {return String(id) === 'anon' || String(id).startsWith('anon_');}@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.88.0",++ "version": "4.88.1","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// The account hub opts into cross-document view transitions through kit.css, then sends a signed-out visitor to++// /login, which does not include the kit and so does not opt in. Chrome aborts the transition and leaves an++// uncaught InvalidStateError on the sign-in page ("ViewTransition opt-in disabled", 11-01). The account switcher++// skips the transition on a replace navigation (the redirect), and handles the transition's promises so an aborted++// one never becomes an unhandled rejection. The file is loaded in a small fake browser, as in theme-try.test.js.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'account-switcher.js'), 'utf8');++++function load() {++ const listeners = {};++ const sandbox = {++ console, JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date,++ document: { cookie: '', addEventListener() {} },++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },++ location: { hostname: 'openvibe.network', protocol: 'https:' },++ addEventListener(type, fn) { listeners[type] = fn; },++ dispatchEvent() {},++ setTimeout() {}, clearTimeout() {},++ module: { exports: {} },++ };++ sandbox.window = sandbox;++ sandbox.globalThis = sandbox;++ vm.runInNewContext(SRC, sandbox, { filename: 'account-switcher.js' });++ return listeners;++}++++function fakeTransition() {++ const seen = { handled: [], skipped: 0 };++ const promise = (name) => ({ catch() { seen.handled.push(name); } });++ return {++ seen,++ transition: {++ finished: promise('finished'),++ ready: promise('ready'),++ updateCallbackDone: promise('updateCallbackDone'),++ skipTransition() { seen.skipped++; },++ },++ };++}++++(async () => {++ // 1. The redirect (a replace navigation) is skipped before it can be aborted, and its promises are handled.++ {++ const listeners = load();++ assert.strictEqual(typeof listeners.pageswap, 'function', 'the pageswap listener is installed');++ assert.strictEqual(typeof listeners.pagereveal, 'function', 'the pagereveal listener is installed');++ const { seen, transition } = fakeTransition();++ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'replace' } });++ assert.deepStrictEqual(seen.handled.sort(), ['finished', 'ready', 'updateCallbackDone'], 'all three promises are handled');++ assert.strictEqual(seen.skipped, 1, 'a replace navigation skips its transition');++ }++++ // 2. A normal page move keeps its transition: the promises are handled, skipTransition is not called.++ {++ const listeners = load();++ const { seen, transition } = fakeTransition();++ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'push' } });++ assert.strictEqual(seen.skipped, 0, 'a push navigation keeps its transition');++ assert.strictEqual(seen.handled.length, 3, 'and its promises are still handled');++ }++++ // 3. The arriving page handles the transition it was given too.++ {++ const listeners = load();++ const { seen, transition } = fakeTransition();++ listeners.pagereveal({ viewTransition: transition });++ assert.strictEqual(seen.handled.length, 3, 'pagereveal promises are handled');++ assert.strictEqual(seen.skipped, 0, 'arriving is not a redirect');++ }++++ // 4. Nothing to do without a transition, or without an activation.++ {++ const listeners = load();++ assert.doesNotThrow(() => listeners.pageswap({ activation: { navigationType: 'replace' } }));++ assert.doesNotThrow(() => listeners.pageswap({ viewTransition: {}, activation: null }));++ assert.doesNotThrow(() => listeners.pageswap(null));++ assert.doesNotThrow(() => listeners.pagereveal({}));++ }++++ // 5. A real rejected promise is handled, not left as an unhandled rejection on the sign-in page.++ {++ const listeners = load();++ const transition = {++ finished: Promise.reject(new Error('Transition was aborted because of invalid state')),++ ready: Promise.resolve(),++ updateCallbackDone: Promise.resolve(),++ skipTransition() {},++ };++ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'push' } });++ await new Promise((resolve) => setImmediate(resolve));++ }++++ console.log('account-switcher-view-transition: ok');++})().catch((err) => {++ console.error(err && err.stack ? err.stack : err);++ process.exitCode = 1;++});
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,16 @@ 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).
+## 4.88.1 — 2026-11-01
+
+- **An aborted view transition is silent on the account hub.**
+ - Signed out, `/my` and `/themes` send the visitor to `/login`, a page that does not load the kit and so does not opt
+ in to cross-document view transitions. Chrome aborted the transition and left an uncaught `InvalidStateError` on
+ the sign-in page: "Transition was aborted because of invalid state. ViewTransition opt-in disabled".
+ - The account switcher now handles `finished`, `ready` and `updateCallbackDone` in `pageswap`/`pagereveal`, and skips
+ a `replace` navigation's transition before it starts: a redirect is not a page move, and a transition that cannot
+ land has nothing to animate.
+
## 4.88.0 — 2026-11-01
## 4.88.0 — 2026-11-01
- **Name effects read on light themes.**
- **Name effects read on light themes.**
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.88.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.88.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.88.0",
+ "version": "4.88.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -28,5 +28,5 @@
@@ -28,5 +28,5 @@
"the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",
"the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went",
"the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"
"the Dock accepts a refreshing token (string, sync or async) and an optional read-only scope set: the extension hides approve/decline and the chat composer while keeping reads"
],
],
- "updated": "2026-10-30"
+ "updated": "2026-11-01"
}
}
@@ -19,6 +19,26 @@
@@ -19,6 +19,26 @@
let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
let _panelEl = null;
let _panelEl = null;
+ // The account hub is a redirect when signed out: it loads this file, opts into cross-document view transitions
+ // (kit.css) and then navigates to /login, which does not include the kit and so does not opt in. Chrome aborts
+ // the transition and leaves an uncaught InvalidStateError on the sign-in page ("ViewTransition opt-in disabled",
+ // 11-01). A replace navigation is that redirect, not a page move: skip its transition before it starts, and keep
+ // the transition's promises handled so a transition that cannot complete is silent either way.
+ function quietViewTransition(event) {
+ const transition = event && event.viewTransition;
+ if (!transition) return;
+ for (const promise of [transition.finished, transition.ready, transition.updateCallbackDone]) {
+ try { if (promise && typeof promise.catch === 'function') promise.catch(() => {}); } catch { /* not a promise */ }
+ }
+ const activation = event.activation;
+ if (!activation || activation.navigationType !== 'replace') return;
+ try { if (typeof transition.skipTransition === 'function') transition.skipTransition(); } catch { /* already gone */ }
+ }
+ if (typeof document !== 'undefined' && typeof root.addEventListener === 'function') {
+ root.addEventListener('pageswap', quietViewTransition);
+ root.addEventListener('pagereveal', quietViewTransition);
+ }
+
function isAnonId(id) {
function isAnonId(id) {
return String(id) === 'anon' || String(id).startsWith('anon_');
return String(id) === 'anon' || String(id).startsWith('anon_');
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.88.0",
+ "version": "4.88.1",
"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": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// The account hub opts into cross-document view transitions through kit.css, then sends a signed-out visitor to
+// /login, which does not include the kit and so does not opt in. Chrome aborts the transition and leaves an
+// uncaught InvalidStateError on the sign-in page ("ViewTransition opt-in disabled", 11-01). The account switcher
+// skips the transition on a replace navigation (the redirect), and handles the transition's promises so an aborted
+// one never becomes an unhandled rejection. The file is loaded in a small fake browser, as in theme-try.test.js.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'account-switcher.js'), 'utf8');
+
+function load() {
+ const listeners = {};
+ const sandbox = {
+ console, JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date,
+ document: { cookie: '', addEventListener() {} },
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ location: { hostname: 'openvibe.network', protocol: 'https:' },
+ addEventListener(type, fn) { listeners[type] = fn; },
+ dispatchEvent() {},
+ setTimeout() {}, clearTimeout() {},
+ module: { exports: {} },
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ vm.runInNewContext(SRC, sandbox, { filename: 'account-switcher.js' });
+ return listeners;
+}
+
+function fakeTransition() {
+ const seen = { handled: [], skipped: 0 };
+ const promise = (name) => ({ catch() { seen.handled.push(name); } });
+ return {
+ seen,
+ transition: {
+ finished: promise('finished'),
+ ready: promise('ready'),
+ updateCallbackDone: promise('updateCallbackDone'),
+ skipTransition() { seen.skipped++; },
+ },
+ };
+}
+
+(async () => {
+ // 1. The redirect (a replace navigation) is skipped before it can be aborted, and its promises are handled.
+ {
+ const listeners = load();
+ assert.strictEqual(typeof listeners.pageswap, 'function', 'the pageswap listener is installed');
+ assert.strictEqual(typeof listeners.pagereveal, 'function', 'the pagereveal listener is installed');
+ const { seen, transition } = fakeTransition();
+ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'replace' } });
+ assert.deepStrictEqual(seen.handled.sort(), ['finished', 'ready', 'updateCallbackDone'], 'all three promises are handled');
+ assert.strictEqual(seen.skipped, 1, 'a replace navigation skips its transition');
+ }
+
+ // 2. A normal page move keeps its transition: the promises are handled, skipTransition is not called.
+ {
+ const listeners = load();
+ const { seen, transition } = fakeTransition();
+ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'push' } });
+ assert.strictEqual(seen.skipped, 0, 'a push navigation keeps its transition');
+ assert.strictEqual(seen.handled.length, 3, 'and its promises are still handled');
+ }
+
+ // 3. The arriving page handles the transition it was given too.
+ {
+ const listeners = load();
+ const { seen, transition } = fakeTransition();
+ listeners.pagereveal({ viewTransition: transition });
+ assert.strictEqual(seen.handled.length, 3, 'pagereveal promises are handled');
+ assert.strictEqual(seen.skipped, 0, 'arriving is not a redirect');
+ }
+
+ // 4. Nothing to do without a transition, or without an activation.
+ {
+ const listeners = load();
+ assert.doesNotThrow(() => listeners.pageswap({ activation: { navigationType: 'replace' } }));
+ assert.doesNotThrow(() => listeners.pageswap({ viewTransition: {}, activation: null }));
+ assert.doesNotThrow(() => listeners.pageswap(null));
+ assert.doesNotThrow(() => listeners.pagereveal({}));
+ }
+
+ // 5. A real rejected promise is handled, not left as an unhandled rejection on the sign-in page.
+ {
+ const listeners = load();
+ const transition = {
+ finished: Promise.reject(new Error('Transition was aborted because of invalid state')),
+ ready: Promise.resolve(),
+ updateCallbackDone: Promise.resolve(),
+ skipTransition() {},
+ };
+ listeners.pageswap({ viewTransition: transition, activation: { navigationType: 'push' } });
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+
+ console.log('account-switcher-view-transition: ok');
+})().catch((err) => {
+ console.error(err && err.stack ? err.stack : err);
+ process.exitCode = 1;
+});