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)

OP OpenVibers committed 16 days ago · cb1da4e3d0 · 6 files, +43 −4

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex efb7123..93713ea 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex f819d9e..ad6c905 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.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
([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 21e9e83..e879546 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/boost.js b/boost.js
iindex f989759..e5f99a2 100644
---- a/boost.js
++++ b/boost.js
@@@ -34,6 +34,8 @@
const scrolls = Object.create(null); // history key → y
const 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;
ddiff --git a/package.json b/package.json
iindex 42fb4a5..7899829 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/boost-chrome.test.js b/test/boost-chrome.test.js
iindex 3cf52b7..ca16f37 100644
---- a/test/boost-chrome.test.js
++++ b/test/boost-chrome.test.js
@@@ -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.