openvibers/openvibe.shared Commits
4.22.2: in-page links scroll instead of reloading the page (boost answered Chrome's state-less popstate with a reload) (#148)
Changes
@@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.22.2 — 2026-10-28++++- **In-page links scroll instead of reloading** (`boost.js`). A link to a part of the page (`href="#the-quests"`) made++ Chrome fire `popstate` with no state. boost answered every state-less `popstate` with `location.reload()`, so the page++ loaded again and landed at the top (openvibe.quest's "See the quests", on every boosted site).++ - boost now knows which page it is showing. A `popstate` on that same page (a part link, or Back and Forward between++ parts) is left to the browser: the part scrolls into view, and Back returns to where the person was.++ - `test/boost-chrome.test.js` covers both.++## 4.22.1 — 2026-10-28- **Search suggestions reach every site.** `search.js` asked `search.openvibe.network` from the page, and the@@@ -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.22.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.22.2 (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.22.1",++ "version": "4.22.2","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)",@@@ -34,6 +34,8 @@const scrolls = Object.create(null); // history key → yconst s = { moves: 0, fallbacks: 0, prefetches: 0, last: null };let inflight = null, stopped = false, key = 0;++ // The page whose <main> is shown (path and query) and where it was scrolled before a link to one of its parts.++ let shown = root.location.pathname + root.location.search, partFromY = null;const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail }));const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; };const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n');@@@ -151,6 +153,8 @@for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name);for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value);mainEl.replaceChildren(...imported.childNodes);++ try { const shownUrl = new URL(page.url, root.location.href); shown = shownUrl.pathname + shownUrl.search; } catch { shown = root.location.pathname + root.location.search; }++ partFromY = null;const fresh = mainEl;updateHead(doc);runInlineScripts(fresh);@@@ -173,6 +177,7 @@function onClick(ev) {const a = ev.target && ev.target.closest && ev.target.closest('a[href]');++ if (a && !ev.defaultPrevented && a.hash) { try { const t = new URL(a.href, root.location.href); if (t.origin === root.location.origin && t.pathname + t.search === shown) partFromY = root.scrollY; } catch { /* */ } }const u = boostable(a, ev);if (!u) return;ev.preventDefault();@@@ -181,6 +186,15 @@function onIntent(ev) { const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); const u = boostable(a); if (u) prefetch(u); }function onPop(ev) {const st = ev.state;++ // A link to a part of this page (#id), and Back or Forward between its parts, fire popstate too (with no state++ // in Chrome): that is the browser moving within the page it shows, not a page change. Every in-page link used++ // to reload the page and land at the top (openvibe.quest's "See the quests", 2026-10-28).++ if (root.location.pathname + root.location.search === shown && (!st || st.ovBoost == null || st.ovBoost === key)) {++ const part = root.location.hash && document.getElementById(decodeURIComponent(root.location.hash.slice(1)));++ if (part) part.scrollIntoView();++ else if (!root.location.hash && partFromY != null) root.scrollTo(0, partFromY);++ return;++ }if (!st || st.ovBoost == null) { root.location.reload(); return; }const y = scrolls[st.ovBoost];scrolls[key] = root.scrollY;@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.22.1",++ "version": "4.22.2","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -23,7 +23,7 @@ ${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boos<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';const PAGES = {-- '/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p>` }),++ '/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),@@@ -81,6 +81,22 @@ const server = http.createServer((req, res) => {assert.deepStrictEqual([v.alive, v.page, v.path], [1, 'Home', '/']);assert.ok(Math.abs(v.y - startY) < 5, `back restores the scroll (${v.y} vs ${startY})`);++ // A link to a part of this page scrolls to it, and Back returns to where the person was: no page load. Chrome fires++ // popstate (with no state) for both, and boost used to answer it with a reload, landing at the top++ // (openvibe.quest's "See the quests", 2026-10-28).++ await p.evaluate('window.alive = 3; window.scrollTo(0, 40); 1');++ await p.evaluate('document.getElementById("to-part").click(); 1');++ await wait(600);++ v = await p.evaluate('({ alive: window.alive, hash: location.hash, top: Math.round(document.getElementById("part").getBoundingClientRect().top), y: scrollY, h: document.documentElement.scrollHeight, ih: innerHeight })');++ assert.deepStrictEqual([v.alive, v.hash], [3, '#part'], 'an in-page link does not load the page again');++ assert.ok(v.y > 1000 && v.top >= -5 && v.top < v.ih, `and the page scrolled to the part (y ${v.y}, part at ${v.top} of ${v.ih})`);++ await p.evaluate('history.back(); 1');++ await wait(600);++ v = await p.evaluate('({ alive: window.alive, hash: location.hash, y: scrollY })');++ assert.deepStrictEqual([v.alive, v.hash], [3, ''], 'Back from the part stays on the page');++ assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);++ await p.evaluate('window.alive = 1; 1');++// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and// then cancels the browser's own navigation so the test stays on this page.
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,15 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.22.2 — 2026-10-28
+
+- **In-page links scroll instead of reloading** (`boost.js`). A link to a part of the page (`href="#the-quests"`) made
+ Chrome fire `popstate` with no state. boost answered every state-less `popstate` with `location.reload()`, so the page
+ loaded again and landed at the top (openvibe.quest's "See the quests", on every boosted site).
+ - boost now knows which page it is showing. A `popstate` on that same page (a part link, or Back and Forward between
+ parts) is left to the browser: the part scrolls into view, and Back returns to where the person was.
+ - `test/boost-chrome.test.js` covers both.
+
## 4.22.1 — 2026-10-28
## 4.22.1 — 2026-10-28
- **Search suggestions reach every site.** `search.js` asked `search.openvibe.network` from the page, and the
- **Search suggestions reach every site.** `search.js` asked `search.openvibe.network` from the page, and the
@@ -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.22.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.22.2 (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.22.1",
+ "version": "4.22.2",
"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)",
@@ -34,6 +34,8 @@
@@ -34,6 +34,8 @@
const scrolls = Object.create(null); // history key → y
const scrolls = Object.create(null); // history key → y
const s = { moves: 0, fallbacks: 0, prefetches: 0, last: null };
const s = { moves: 0, fallbacks: 0, prefetches: 0, last: null };
let inflight = null, stopped = false, key = 0;
let inflight = null, stopped = false, key = 0;
+ // The page whose <main> is shown (path and query) and where it was scrolled before a link to one of its parts.
+ let shown = root.location.pathname + root.location.search, partFromY = null;
const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail }));
const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail }));
const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; };
const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; };
const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n');
const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n');
@@ -151,6 +153,8 @@
@@ -151,6 +153,8 @@
for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name);
for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name);
for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value);
for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value);
mainEl.replaceChildren(...imported.childNodes);
mainEl.replaceChildren(...imported.childNodes);
+ try { const shownUrl = new URL(page.url, root.location.href); shown = shownUrl.pathname + shownUrl.search; } catch { shown = root.location.pathname + root.location.search; }
+ partFromY = null;
const fresh = mainEl;
const fresh = mainEl;
updateHead(doc);
updateHead(doc);
runInlineScripts(fresh);
runInlineScripts(fresh);
@@ -173,6 +177,7 @@
@@ -173,6 +177,7 @@
function onClick(ev) {
function onClick(ev) {
const a = ev.target && ev.target.closest && ev.target.closest('a[href]');
const a = ev.target && ev.target.closest && ev.target.closest('a[href]');
+ if (a && !ev.defaultPrevented && a.hash) { try { const t = new URL(a.href, root.location.href); if (t.origin === root.location.origin && t.pathname + t.search === shown) partFromY = root.scrollY; } catch { /* */ } }
const u = boostable(a, ev);
const u = boostable(a, ev);
if (!u) return;
if (!u) return;
ev.preventDefault();
ev.preventDefault();
@@ -181,6 +186,15 @@
@@ -181,6 +186,15 @@
function onIntent(ev) { const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); const u = boostable(a); if (u) prefetch(u); }
function onIntent(ev) { const a = ev.target && ev.target.closest && ev.target.closest('a[href]'); const u = boostable(a); if (u) prefetch(u); }
function onPop(ev) {
function onPop(ev) {
const st = ev.state;
const st = ev.state;
+ // A link to a part of this page (#id), and Back or Forward between its parts, fire popstate too (with no state
+ // in Chrome): that is the browser moving within the page it shows, not a page change. Every in-page link used
+ // to reload the page and land at the top (openvibe.quest's "See the quests", 2026-10-28).
+ if (root.location.pathname + root.location.search === shown && (!st || st.ovBoost == null || st.ovBoost === key)) {
+ const part = root.location.hash && document.getElementById(decodeURIComponent(root.location.hash.slice(1)));
+ if (part) part.scrollIntoView();
+ else if (!root.location.hash && partFromY != null) root.scrollTo(0, partFromY);
+ return;
+ }
if (!st || st.ovBoost == null) { root.location.reload(); return; }
if (!st || st.ovBoost == null) { root.location.reload(); return; }
const y = scrolls[st.ovBoost];
const y = scrolls[st.ovBoost];
scrolls[key] = root.scrollY;
scrolls[key] = root.scrollY;
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.22.1",
+ "version": "4.22.2",
"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": {
@@ -23,7 +23,7 @@ ${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boos
@@ -23,7 +23,7 @@ ${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boos
<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
const PAGES = {
const PAGES = {
- '/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p>` }),
+ '/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),
'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
@@ -81,6 +81,22 @@ const server = http.createServer((req, res) => {
@@ -81,6 +81,22 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual([v.alive, v.page, v.path], [1, 'Home', '/']);
assert.deepStrictEqual([v.alive, v.page, v.path], [1, 'Home', '/']);
assert.ok(Math.abs(v.y - startY) < 5, `back restores the scroll (${v.y} vs ${startY})`);
assert.ok(Math.abs(v.y - startY) < 5, `back restores the scroll (${v.y} vs ${startY})`);
+ // A link to a part of this page scrolls to it, and Back returns to where the person was: no page load. Chrome fires
+ // popstate (with no state) for both, and boost used to answer it with a reload, landing at the top
+ // (openvibe.quest's "See the quests", 2026-10-28).
+ await p.evaluate('window.alive = 3; window.scrollTo(0, 40); 1');
+ await p.evaluate('document.getElementById("to-part").click(); 1');
+ await wait(600);
+ v = await p.evaluate('({ alive: window.alive, hash: location.hash, top: Math.round(document.getElementById("part").getBoundingClientRect().top), y: scrollY, h: document.documentElement.scrollHeight, ih: innerHeight })');
+ assert.deepStrictEqual([v.alive, v.hash], [3, '#part'], 'an in-page link does not load the page again');
+ assert.ok(v.y > 1000 && v.top >= -5 && v.top < v.ih, `and the page scrolled to the part (y ${v.y}, part at ${v.top} of ${v.ih})`);
+ await p.evaluate('history.back(); 1');
+ await wait(600);
+ v = await p.evaluate('({ alive: window.alive, hash: location.hash, y: scrollY })');
+ assert.deepStrictEqual([v.alive, v.hash], [3, ''], 'Back from the part stays on the page');
+ assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);
+ await p.evaluate('window.alive = 1; 1');
+
// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
// then cancels the browser's own navigation so the test stays on this page.
// then cancels the browser's own navigation so the test stays on this page.