Skip to content

Stop authored links reloading the document on public pages - #276

Merged
antosubash merged 2 commits into
mainfrom
fix/spa-link-navigation
Aug 21, 2026
Merged

Stop authored links reloading the document on public pages#276
antosubash merged 2 commits into
mainfrom
fix/spa-link-navigation

Conversation

@antosubash

Copy link
Copy Markdown
Owner

A simple_module app is client-rendered: the root template ships <div id="app"></div> empty and app.tsx fills it with createRoot().render(). So a navigation that creates a new document paints a blank white body and only fills in once the bundle has run.

Admin screens never hit this — the shell navigates with Inertia's <Link>. Authored content does. Pagebuilder widgets, and the markdown and rich-text fields inside them, render author-entered URLs as plain <a href>, so a public site reloads the whole document every time a visitor clicks its own nav.

Measured on a downstream site (Global Canopy Atlas) with a screencast:

beforeafter
navigationnew documentsame document
blank frames per click10
blank viewport (Fast-3G, 4× CPU)~330ms0ms

Why not fix the widgets

23 widget files render hrefs — but that is not the blocker. The markdown and rich-text fields turn [label](href) into anchors inside a parser, where there is no component to swap for a <Link>. Any per-widget fix would still leave every body-content link reloading the page, and would need repeating for each new widget.

One delegated click listener on the document catches all of them, whatever produced the anchor.

Shape

@simple-module-py/ui exports startSpaLinkInterception(). It is opt-in rather than an import side effect — a UI package should not install a global click listener merely by being imported — and returns a teardown, which the tests use.

It runs in the bubble phase, so an Inertia <Link> (which cancels the event itself) arrives already defaultPrevented and is left alone rather than visited a second time.

What it deliberately does not touch

The bias is towards leaving links alone: taking over a URL Inertia cannot render turns a working download into an error modal, while missing one only costs the reload we are trying to avoid.

Left aloneWhy
Another origin, non-http scheme (mailto:, tel:)Not ours to route
Last path segment looks like a file (/media/report.pdf)Inertia would raise its error modal over the download
#, #section, link to the current pathBrowser scrolls correctly; href="#" is also an unfilled pagebuilder nav row
download, target_self, rel="external"The author asked for it
data-native-linkExplicit opt-out for anything the rules miss
Inside [data-puck-preview] / [data-puck-component]The site-layout editor renders with iframe={{ enabled: false }}, putting the edited page's real anchors in the admin document
Modified (⌘/ctrl/shift/alt) and non-left clicksOpen-in-new-tab must keep working

Backstop: a visit returning without an x-inertia header falls back to a hard navigation, so a URL that turns out not to be a page still resolves.

Upgrade note

smpy new wires the call into the app template, so new apps get it. Apps scaffolded before this own their own app.tsx and must add the one-line call — the CHANGELOG entry says so explicitly, given this file's coverage note about a downstream app previously being misled about what had shipped.

Verification

  • 15 new unit tests covering the decision table and the listener — nested click targets, the defaultPrevented no-double-visit guarantee, modified clicks, teardown.
  • Full JS suite: 17 files / 103 tests pass. Biome clean, tsc --noEmit clean on packages/ui, file-size gate OK.
  • The mechanism itself was verified in a real browser downstream before being generalised here: 0 blank frames, document survives the click, one Inertia visit per admin <Link> click, and the site-layout editor plus a non-Inertia JSON endpoint behaving exactly as before.

The test helper reads its verdict on window and then cancels the event, so the clicks this code correctly declines don't reach jsdom's link activation — otherwise the run fills with Not implemented: navigation and a real error would be lost in it.

https://claude.ai/code/session_01YMPtPuP8YqsVNh3p1hLCuS

A simple_module app is client-rendered: the root template ships
`<div id="app"></div>` empty and app.tsx fills it with createRoot().render().
So a navigation that creates a *new document* paints a blank white body and
only fills in once the bundle has run.
Admin screens never hit this — the shell navigates with Inertia's <Link>.
Authored content does. Pagebuilder widgets, and the markdown and rich-text
fields inside them, render author-entered URLs as plain `<a href>`, so a public
site reloads the whole document every time a visitor clicks its own nav. A
downstream site measured one fully blank frame per click, and ~330ms of blank
viewport on Fast-3G with a 4x CPU slowdown.
Fixing this per widget does not work: 23 widget files render hrefs, and the
markdown/rich-text ones turn `[label](href)` into anchors inside a parser,
where there is no component to swap for a <Link>. One delegated listener on the
document catches them all, whatever produced the anchor.
It is opt-in rather than an import side effect — a UI package should not install
a global click listener just by being imported — and returns a teardown, which
is what the tests use. The smpy new template calls it; apps scaffolded before
this own their app.tsx and have to add the line, so the CHANGELOG says so.
The rules are deliberately biased towards leaving links alone, because taking
over a URL Inertia cannot render turns a working download into an error modal
while missing one only costs the reload. Left to the browser: other origins,
non-http schemes, paths that look like a file, in-page anchors, download/target/
rel=external/data-native-link, modified and non-left clicks, and anchors inside
a Puck editor surface — pagebuilder's site-layout editor renders with the iframe
disabled, so the edited page's real nav anchors sit in the admin document.
Running in the bubble phase means an Inertia <Link> is already defaultPrevented
and is left alone rather than visited twice. A visit that returns without an
x-inertia header falls back to a hard navigation.
Claude-Session: https://claude.ai/code/session_01YMPtPuP8YqsVNh3p1hLCuS
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 21, 2026

Copy link
Copy Markdown

Deploying simple-module-python with Cloudflare Pages Cloudflare Pages

Latest commit:9d5c696
Status: ✅ Deploy successful!
Preview URL:https://83f23195.simple-module-python.pages.dev
Branch Preview URL:https://fix-spa-link-navigation.simple-module-python.pages.dev

View logs

@antosubash
antosubash marked this pull request as ready for review August 21, 2026 13:01
@antosubash
antosubash merged commit c198c2a into mainAug 21, 2026
13 checks passed
@antosubash
antosubash deleted the fix/spa-link-navigation branch August 21, 2026 14:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@antosubash