Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,26 @@ All notable changes to this project are documented in this file. The format is b
production-mode containers pass `UsersSettings` boot validation.

### Fixed
- Public pages no longer reload the whole document when a visitor clicks a link
in authored content. A simple_module app is client-rendered — the root
template ships `<div id="app"></div>` empty — so a navigation that creates a
new document paints a blank white body until the bundle has booted. Admin
screens were never affected because the shell navigates with Inertia's
`<Link>`, but pagebuilder widgets and their markdown/rich-text fields render
author-entered URLs as plain `<a href>`, and there is no component to swap for
a `<Link>` when the anchor comes out of a markdown parser. A downstream site
measured ~330ms of blank viewport per click on a throttled connection.
`@simple-module-py/ui` now exports `startSpaLinkInterception()`, a delegated
click handler that routes same-origin page links through Inertia whatever
produced the anchor; the `smpy new` app template calls it. It deliberately
leaves alone anything Inertia cannot render — other origins, non-http schemes,
paths that look like a file, in-page anchors, `download`/`target`/
`rel="external"`/`data-native-link`, and anchors inside a Puck editor surface
— and falls back to a hard navigation if a visit returns without an
`x-inertia` header, so a media download can never be replaced by an error
modal. **Existing apps must add the one-line call to their own
`host/client_app/app.tsx`**, which is scaffold output and so is not upgraded
for them.
- `smpy gen-pages` now emits module stylesheet `@import` lines as **absolute
paths** instead of `#module/<pkg>` alias specifiers. The alias only resolved
if the host's `vite.config.ts` defined a matching `resolve.alias` — but that
Expand Down
49 changes: 49 additions & 0 deletions docs/frontend/inertia.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -149,6 +149,55 @@ createInertiaApp({

`resolvePage` lives in `host/client_app/pages.ts` (hand-written). It builds a page-key → loader map from `moduleGlobs` (the `import.meta.glob` calls in the generated `modules.generated.ts`) plus the host's own `./pages/**/*.tsx` glob, mapping a page key (e.g. `"Orders/Browse"`) to a dynamic import of the matching `.tsx` file.


## Link navigation

The app is client-rendered: the root template ships `<div id="app"></div>` empty
and `app.tsx` fills it. So a navigation that creates a **new document** shows a
blank white page until the bundle has booted — which is what a plain
`<a href="/somewhere">` does.

Admin screens use Inertia's `<Link>` and are fine. The problem is authored
content: pagebuilder widgets, and the markdown and rich-text fields inside them,
turn author-entered URLs into plain anchors. An anchor that comes out of a
markdown parser has no component to swap for a `<Link>`.

`app.tsx` therefore calls `startSpaLinkInterception()` once, before the first
render:

```tsx
import { startSpaLinkInterception } from "@simple-module-py/ui/lib/spa-links";

setup({ el, App, props }) {
startSpaLinkInterception();
createRoot(el).render(<App {...props} />);
}
```

It delegates one `click` listener on the document and routes same-origin page
links through `router.visit()`, whatever produced the anchor. It runs in the
bubble phase, so a `<Link>` — which cancels the event itself — is already
`defaultPrevented` and is left alone rather than visited twice.

The bias is towards **not** interfering. These keep the browser's own behaviour:

| Left alone | Why |
| --- | --- |
| Another origin, or a non-http scheme (`mailto:`, `tel:`) | Not ours to route |
| A path whose last segment looks like a file (`/media/report.pdf`) | Inertia would request it expecting a page and raise its error modal over the download |
| `#`, `#section`, or a link to the current path | The browser scrolls correctly; `href="#"` is also what an unfilled pagebuilder nav row holds |
| `download`, `target` other than `_self`, `rel="external"` | The author asked for it |
| `data-native-link` | Explicit opt-out for anything the rules miss |
| Anything inside `[data-puck-preview]` / `[data-puck-component]` | A Puck editor rendering without an iframe puts the edited page's real anchors in the admin document |
| Modified clicks (⌘/ctrl/shift/alt) and non-left buttons | Open-in-new-tab must keep working |

As a backstop, a visit that comes back **without** an `x-inertia` header is
handed to the browser as a hard navigation, so a URL that turns out not to be a
page still resolves instead of raising the error modal.

`smpy new` wires this into the app template. An app scaffolded before this
landed owns its own `app.tsx` and must add the call itself.

## CSRF

There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie:
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { createInertiaApp, router } from '@inertiajs/react';
import { configureI18n, updateI18n } from '@simple-module-py/i18n';
import { startSpaLinkInterception } from '@simple-module-py/ui/lib/spa-links';
import { createRoot } from 'react-dom/client';
import { resolvePage } from './pages';

Expand DownExpand Up@@ -28,6 +29,11 @@ createInertiaApp({
activeLocale = block.locale;
}
});
// Authored content (pagebuilder widgets, markdown and rich-text fields)
// renders author-entered URLs as plain <a href>, which the browser would
// follow with a full document load. This app is client-rendered, so that
// means a blank page until the bundle boots. Route them through Inertia.
startSpaLinkInterception();
createRoot(el).render(<App {...props} />);
},
progress: {
Expand Down
6 changes: 6 additions & 0 deletions host/client_app/app.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { createInertiaApp, router } from '@inertiajs/react';
import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary';
import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title';
import { startSpaLinkInterception } from '@simple-module-py/ui/lib/spa-links';
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n';
Expand DownExpand Up@@ -29,9 +30,14 @@ createInertiaApp({
useEffect(() => {
const stopReset = router.on('navigate', () => boundaryRef.current?.reset());
const stopI18n = subscribeI18nToNavigation();
// Authored content renders author-entered URLs as plain <a href>, which
// the browser follows with a full document load — a blank page until
// this client-rendered app boots again. Route them through Inertia.
const stopLinks = startSpaLinkInterception();
return () => {
stopReset();
stopI18n();
stopLinks();
};
}, []);

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,5 +10,6 @@ export { AppLayout } from './layouts/AppLayout';
export { AuthenticatedLayout } from './layouts/AuthenticatedLayout';
export { PublicLayout } from './layouts/PublicLayout';
export { SidebarLayout } from './layouts/SidebarLayout';
export { shouldInterceptNavigation, startSpaLinkInterception } from './lib/spa-links';
export { TONE, type Tone } from './lib/tone';
export type { MenuItem, SharedProps } from './types';
184 changes: 184 additions & 0 deletions packages/ui/src/lib/spa-links.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
import { router } from '@inertiajs/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { shouldInterceptNavigation, startSpaLinkInterception } from './spa-links';

/**
* The rule has to be conservative in both directions. Taking over a link
* Inertia cannot render replaces a working download with an error modal;
* leaving an ordinary page link alone puts back the full document reload —
* and with it the blank frame a client-rendered app paints while it boots.
*
* The cases pinned here are the ones a naive "same origin?" test gets wrong.
*/

const HERE = new URL('http://localhost:3000/p/who-we-are');

function anchor(html: string): HTMLAnchorElement {
document.body.innerHTML = html;
const el = document.body.querySelector('a');
if (!el) throw new Error(`no anchor in ${html}`);
return el;
}

beforeEach(() => {
document.body.innerHTML = '';
});

describe('shouldInterceptNavigation', () => {
it('takes over an ordinary same-origin page link', () => {
expect(shouldInterceptNavigation(anchor('<a href="/p/outputs">Outputs</a>'), HERE)).toBe(true);
expect(shouldInterceptNavigation(anchor('<a href="/">Home</a>'), HERE)).toBe(true);
expect(
shouldInterceptNavigation(anchor('<a href="http://localhost:3000/p/faqs">FAQs</a>'), HERE),
).toBe(true);
});

it('leaves other origins to the browser', () => {
expect(
shouldInterceptNavigation(anchor('<a href="https://example.org/x">Away</a>'), HERE),
).toBe(false);
});

it('leaves non-http schemes alone', () => {
expect(shouldInterceptNavigation(anchor('<a href="mailto:a@b.org">Mail</a>'), HERE)).toBe(
false,
);
expect(shouldInterceptNavigation(anchor('<a href="tel:+4312345">Call</a>'), HERE)).toBe(false);
});

it('leaves anything that looks like a file to the browser', () => {
// Editor-uploaded documents are served from the same origin; routing one
// through Inertia swaps the download for its error modal.
for (const href of ['/media/report.pdf', '/uploads/logo.png', '/static/dist/main.js']) {
expect(shouldInterceptNavigation(anchor(`<a href="${href}">f</a>`), HERE)).toBe(false);
}
});

it('respects an explicit opt-out', () => {
expect(shouldInterceptNavigation(anchor('<a href="/p/x" download>Save</a>'), HERE)).toBe(false);
expect(shouldInterceptNavigation(anchor('<a href="/p/x" target="_blank">New</a>'), HERE)).toBe(
false,
);
expect(shouldInterceptNavigation(anchor('<a href="/p/x" rel="external">Out</a>'), HERE)).toBe(
false,
);
expect(shouldInterceptNavigation(anchor('<a href="/p/x" data-native-link>P</a>'), HERE)).toBe(
false,
);
});

it('keeps target="_self" — it means this frame, which is what we do', () => {
expect(shouldInterceptNavigation(anchor('<a href="/p/x" target="_self">Here</a>'), HERE)).toBe(
true,
);
});

it('leaves in-page anchors to the browser', () => {
// Including `href="#"`, which is what a pagebuilder nav row holds before an
// editor fills it in — taking it over would re-fetch the current page.
for (const href of ['#', '#section', '/p/who-we-are', '/p/who-we-are#team']) {
expect(shouldInterceptNavigation(anchor(`<a href="${href}">a</a>`), HERE)).toBe(false);
}
});

it('does not touch anchors inside a Puck editor surface', () => {
// An editor rendering with `iframe={{ enabled: false }}` puts the edited
// page's real nav anchors into the admin document.
for (const wrapper of ['data-puck-preview', 'data-puck-component']) {
const el = anchor(`<div ${wrapper}><a href="/p/outputs">Outputs</a></div>`);
expect(shouldInterceptNavigation(el, HERE)).toBe(false);
}
});

it('ignores an anchor with no href', () => {
document.body.innerHTML = '<a>no href</a>';
const el = document.body.querySelector('a') as HTMLAnchorElement;
expect(shouldInterceptNavigation(el, HERE)).toBe(false);
});
});

describe('startSpaLinkInterception', () => {
let stop: () => void;
let visit: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
window.history.pushState({}, '', '/p/who-we-are');
visit = vi.spyOn(router, 'visit').mockImplementation(() => undefined);
stop = startSpaLinkInterception();
});

afterEach(() => {
stop();
visit.mockRestore();
});

/**
* Dispatch a click and report whether the handler under test took it over.
*
* The verdict is read on `window` — the last stop in the bubble path, after
* the document listener — and the event is then cancelled unconditionally.
* Without that, every click this code correctly declines would reach jsdom's
* link activation, which floods the run with "Not implemented: navigation"
* and would drown a real error.
*/
function click(el: Element, init: MouseEventInit = {}): { prevented: boolean } {
let prevented = false;
const guard = (event: Event): void => {
prevented = event.defaultPrevented;
event.preventDefault();
};
window.addEventListener('click', guard);
el.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init }),
);
window.removeEventListener('click', guard);
return { prevented };
}

it('visits an internal link instead of letting the browser navigate', () => {
expect(click(anchor('<a href="/p/outputs">Outputs</a>')).prevented).toBe(true);
expect(visit).toHaveBeenCalledWith('http://localhost:3000/p/outputs');
});

it('follows a click on an element nested inside the link', () => {
// Authored links wrap icons and spans; the click target is not the anchor.
document.body.innerHTML = '<a href="/p/outputs"><span id="inner">Go</span></a>';
click(document.getElementById('inner') as Element);
expect(visit).toHaveBeenCalledWith('http://localhost:3000/p/outputs');
});

it('leaves a click another handler already took', () => {
// This is what keeps an Inertia <Link> from being visited twice: its own
// onClick preventDefaults before the event reaches the document.
const el = anchor('<a href="/p/outputs">Outputs</a>');
el.addEventListener('click', (e) => e.preventDefault());
click(el);
expect(visit).not.toHaveBeenCalled();
});

it('leaves modified and non-left clicks to the browser', () => {
const el = anchor('<a href="/p/outputs">Outputs</a>');
for (const init of [
{ metaKey: true },
{ ctrlKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
expect(click(el, init).prevented).toBe(false);
}
expect(visit).not.toHaveBeenCalled();
});

it('leaves a link it should not take over', () => {
expect(click(anchor('<a href="/media/report.pdf">Report</a>')).prevented).toBe(false);
expect(visit).not.toHaveBeenCalled();
});

it('stops intercepting once torn down', () => {
stop();
expect(click(anchor('<a href="/p/outputs">Outputs</a>')).prevented).toBe(false);
expect(visit).not.toHaveBeenCalled();
stop = () => undefined; // afterEach must not tear down twice
});
});
Loading