openvibers/openvibe.shared Commits

4.88.1: an aborted view transition is silent on the account hub (#243)

OP OpenVibers committed 11 days ago · 230e6f030d · 6 files, +136 −4

Signed out, /my and /themes navigate to /login, which does not load the kit
and so does not opt in to cross-document view transitions. Chrome aborts the
transition and leaves an uncaught InvalidStateError on the sign-in page
(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.

Patch release: package.json, STATUS.json, README status line and CHANGELOG.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cec3fc1..232f700 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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.**
ddiff --git a/README.md b/README.md
iindex de22e62..cf27d11 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 4064957..da12401 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/account-switcher.js b/account-switcher.js
iindex 8cc5912..a958f6a 100644
---- a/account-switcher.js
++++ b/account-switcher.js
@@@ -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_');
}
ddiff --git a/package.json b/package.json
iindex dbab1d8..46d76cc 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/account-switcher-view-transition.test.js b/test/account-switcher-view-transition.test.js
nnew file mode 100644
iindex 0000000..4a15241
---- /dev/null
++++ b/test/account-switcher-view-transition.test.js
@@@ -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;
++});