Stop authored links reloading the document on public pages - #276
Merged
Conversation
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
Deploying simple-module-python with |
| 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 |
antosubash
marked this pull request as ready for review
August 21, 2026 13:01
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A simple_module app is client-rendered: the root template ships
<div id="app"></div>empty andapp.tsxfills it withcreateRoot().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:
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
clicklistener on the document catches all of them, whatever produced the anchor.Shape
@simple-module-py/uiexportsstartSpaLinkInterception(). 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 alreadydefaultPreventedand 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.
mailto:,tel:)/media/report.pdf)#,#section, link to the current pathhref="#"is also an unfilled pagebuilder nav rowdownload,target≠_self,rel="external"data-native-link[data-puck-preview]/[data-puck-component]iframe={{ enabled: false }}, putting the edited page's real anchors in the admin documentBackstop: a visit returning without an
x-inertiaheader falls back to a hard navigation, so a URL that turns out not to be a page still resolves.Upgrade note
smpy newwires the call into the app template, so new apps get it. Apps scaffolded before this own their ownapp.tsxand 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
defaultPreventedno-double-visit guarantee, modified clicks, teardown.tsc --noEmitclean onpackages/ui, file-size gate OK.<Link>click, and the site-layout editor plus a non-Inertia JSON endpoint behaving exactly as before.The test helper reads its verdict on
windowand then cancels the event, so the clicks this code correctly declines don't reach jsdom's link activation — otherwise the run fills withNot implemented: navigationand a real error would be lost in it.https://claude.ai/code/session_01YMPtPuP8YqsVNh3p1hLCuS