SPA sidebar navigation compounds URL path across successive clicks (stale root_path-relative links) #70

Closed
opened 2026-06-30 09:26:56 +00:00 by anuna-02 · 1 comment
anuna-02 commented 2026-06-30 09:26:56 +00:00 (Migrated from codeberg.org)

Summary

In a static zetl build deployed under a SPA shell (_static/spa.js), successive in-app navigations via the persistent sidebar compound the URL path instead of replacing it. After two clicks you get URLs like:

/orgs/insight-exchange/research/datafication-of-metoo/research/tech-abuse-and-coercive-control/index.html

which 404 (or fall back), breaking navigation.

Affects zetl 0.9.1 (static build + spa.js).

Root cause

spa.js (swap()) replaces only the [data-zetl-volatile] element and intentionally keeps the shell — including the sidebar — mounted:

oldRoot.replaceWith(newRoot);     // only the volatile/main region
...
if (push) history.pushState({ zetl: true, url: url }, '', url);

But the sidebar links are depth-relative, built from the page's root_path (e.g. themes/default/base.html sidebar tree: href="{{ root_path }}{{ node.slug }}/{{ index_file }}"). root_path is computed for the page that was originally loaded from the static host. SPA navigation changes document URL (the base for resolving relative links) via pushState without re-rendering the sidebar, so the sidebar keeps hrefs relative to the original page's depth.

When the new document URL is deeper than the original, those stale relative hrefs resolve against the wrong (deeper) base, appending instead of replacing. Each further sidebar click compounds again.

Exact trace (matches the reported URL)

  1. Load homepage /root_path is empty, so sidebar link to insight-exchange is orgs/insight-exchange/index.html, to datafication is research/datafication-of-metoo/index.html, etc.
  2. Click insight-exchange: resolves against //orgs/insight-exchange/index.html. pushState sets URL there. Sidebar is not re-rendered — still homepage-relative.
  3. Click datafication (sidebar href research/datafication-of-metoo/index.html): now resolves against base /orgs/insight-exchange//orgs/insight-exchange/research/datafication-of-metoo/index.html. Compounded.
  4. Click tech-abuse (sidebar href research/tech-abuse-and-coercive-control/index.html): resolves against the depth-3 base → /orgs/insight-exchange/research/datafication-of-metoo/research/tech-abuse-and-coercive-control/index.html. Exactly the reported URL.

The getAttribute('href')a.href path in the click handler uses the browser's resolution against the current document.baseURI, which is why the stale relative hrefs drift.

Reproduction

Deploy a zetl build to a static host (e.g. Cloudflare Pages). From the homepage, click a sidebar entry in a subfolder, then click a second sidebar entry. The address bar shows a compounded path and the page fails to load.

Suggested fix

The persistent shell can't use depth-relative links that go stale on pushState. Options:

  • Emit sidebar / persistent-shell links as root-relative ({{ site_base }}/orgs/...) rather than root_path-relative, so resolution is independent of the current document depth (respecting any sub-path deploy base).
  • Or, in spa.js after swap(), rebase the persistent shell's href/src for the new URL (or re-render the sidebar from the fetched document).
  • Or maintain a <base href> and keep it correct across navigations (note <base> affects all relative URLs).

Add a test simulating two successive SPA navigations from the homepage into a subfolder and asserting the resulting URL is not compounded.

## Summary In a static `zetl build` deployed under a SPA shell (`_static/spa.js`), successive in-app navigations via the **persistent sidebar** compound the URL path instead of replacing it. After two clicks you get URLs like: ``` /orgs/insight-exchange/research/datafication-of-metoo/research/tech-abuse-and-coercive-control/index.html ``` which 404 (or fall back), breaking navigation. Affects **zetl 0.9.1** (static build + `spa.js`). ## Root cause `spa.js` (`swap()`) replaces only the `[data-zetl-volatile]` element and intentionally keeps the shell — **including the sidebar** — mounted: ```js oldRoot.replaceWith(newRoot); // only the volatile/main region ... if (push) history.pushState({ zetl: true, url: url }, '', url); ``` But the sidebar links are **depth-relative**, built from the page's `root_path` (e.g. `themes/default/base.html` sidebar tree: `href="{{ root_path }}{{ node.slug }}/{{ index_file }}"`). `root_path` is computed for the page that was *originally* loaded from the static host. SPA navigation changes `document` URL (the base for resolving relative links) via `pushState` **without re-rendering the sidebar**, so the sidebar keeps hrefs relative to the original page's depth. When the new document URL is deeper than the original, those stale relative hrefs resolve against the wrong (deeper) base, appending instead of replacing. Each further sidebar click compounds again. ### Exact trace (matches the reported URL) 1. Load homepage `/` → `root_path` is empty, so sidebar link to insight-exchange is `orgs/insight-exchange/index.html`, to datafication is `research/datafication-of-metoo/index.html`, etc. 2. Click **insight-exchange**: resolves against `/` → `/orgs/insight-exchange/index.html`. `pushState` sets URL there. Sidebar is **not** re-rendered — still homepage-relative. 3. Click **datafication** (sidebar href `research/datafication-of-metoo/index.html`): now resolves against base `/orgs/insight-exchange/` → `/orgs/insight-exchange/research/datafication-of-metoo/index.html`. Compounded. 4. Click **tech-abuse** (sidebar href `research/tech-abuse-and-coercive-control/index.html`): resolves against the depth-3 base → `/orgs/insight-exchange/research/datafication-of-metoo/research/tech-abuse-and-coercive-control/index.html`. Exactly the reported URL. The `getAttribute('href')` → `a.href` path in the click handler uses the browser's resolution against the current `document.baseURI`, which is why the stale relative hrefs drift. ## Reproduction Deploy a `zetl build` to a static host (e.g. Cloudflare Pages). From the homepage, click a sidebar entry in a subfolder, then click a second sidebar entry. The address bar shows a compounded path and the page fails to load. ## Suggested fix The persistent shell can't use depth-relative links that go stale on `pushState`. Options: - Emit sidebar / persistent-shell links as **root-relative** (`{{ site_base }}/orgs/...`) rather than `root_path`-relative, so resolution is independent of the current document depth (respecting any sub-path deploy base). - Or, in `spa.js` after `swap()`, **rebase the persistent shell's** `href`/`src` for the new URL (or re-render the sidebar from the fetched document). - Or maintain a `<base href>` and keep it correct across navigations (note `<base>` affects all relative URLs). Add a test simulating two successive SPA navigations from the homepage into a subfolder and asserting the resulting URL is not compounded.
anuna-02 commented 2026-06-30 11:49:23 +00:00 (Migrated from codeberg.org)

Fixed in zetl 0.9.2. spa.js now pins persistent-shell links to absolute URLs at load, so successive sidebar navigations no longer compound the path.

Fixed in zetl 0.9.2. spa.js now pins persistent-shell links to absolute URLs at load, so successive sidebar navigations no longer compound the path.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna-research/zetl#70
No description provided.