Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ All notable changes to this project are documented in this file. The format is b
- **Behaviour change:** `RedactionFilter` on ordinary logging handlers masks
secrets in tracebacks and stack information, including exception text not
yet formatted when the filter runs (#789).
- **Behaviour change (#788):** Inertia visits report each tab's cached catalog
with `X-SM-I18n`; locale/audience mismatches resend messages, and raw requests
no longer advance legacy session markers. Clients reload a missing locale once.
- **Behaviour change (#790):** The standalone adapter owns Inertia header
detection and cache protection (`private, no-store`, no ETag, `Vary: X-Inertia`).
Hosting's duplicate cache middleware is removed; document caching is preserved.
- **Behaviour change (#791):** Layout props and module providers resolve once
when a page or error page needs them; JSON APIs/downloads skip that work.
Permission resolution remains eager. Direct layout readers can await
`resolve_inertia_shared(request)` (from `simple_module_hosting.inertia_deps`) before using `request.state.inertia_shared`.
- **Behaviour change:** Inertia GETs with a missing asset-version header now
receive 409 when the server version is non-empty; version conflicts include
both `X-Inertia-Location` and the current `X-Inertia-Version` (#774).
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ the host), then async `on_startup` / `on_shutdown` (reverse order). Non-hook ove
**Middleware** (Starlette `add_middleware` is LIFO). Execution order per
request: `(ForwardedHost if trusted proxy + allowlist) → (ProxyHeaders if
SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → CORS → BodyLimit → GZip → SecurityHeaders → Session → InertiaMiddleware → <module middleware> → Tenant
(opt-in) → Locale → InertiaLayoutData → InertiaCache → RateLimit → Setup →
(opt-in) → Locale → InertiaLayoutData → RateLimit → Setup →
Maintenance → CommitBeforeResponse → app`. Rules that follow from it:

- Modules at the same dependency tier: the one that sorts later wraps outermost.
Expand Down Expand Up @@ -262,7 +262,9 @@ and prop factories (`optional`, `always`, `defer`, `merge`, `once`, `scroll`)
come from `simple_module_inertia`. Shared props `auth`, `menus`, `i18n` are set
by `InertiaLayoutDataMiddleware`; `auth.user` is null unless
`app.state.principal_serializer` is set, and `i18n.messages` is null on every
`X-Inertia` request unless the locale or audience changed. Inertia
`X-Inertia` request when the tab reports a matching locale and audience
via `X-SM-I18n`; clients without that header use the legacy session markers.
Layout props are resolved lazily by page and error rendering; permissions stay eager. Inertia
`router.post/patch/put/delete` must target view routes, never `/api/*`
(`SM018`). `docs/frontend/`.

Expand Down
2 changes: 1 addition & 1 deletion docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ way.

### Shared props

`InertiaLayoutDataMiddleware` populates `request.state.inertia_shared` with:
`InertiaLayoutDataMiddleware` prepares these lazily; page and error rendering resolve them once (`await resolve_inertia_shared(request)`, from `simple_module_hosting.inertia_deps`) into `request.state.inertia_shared`:

- `auth.user`, `auth.isAuthenticated`, `auth.permissions` (expanded from roles).
- `menus` — grouped by `MenuSection` (sidebar, adminSidebar, navbar, userDropdown), role-filtered.
Expand Down
15 changes: 15 additions & 0 deletions docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,18 @@ r = await client.get("/", cookies={"locale": "es"})
```

The `LocaleMiddleware` resolves and the shared props carry the right bundle to the Inertia page.

## Catalog caching across tabs

Each tab sends `X-SM-I18n: <locale>:<audience>` on Inertia visits, describing the
catalog it actually holds. The `i18n` block includes `audience` (`public` or
`full`); messages are omitted only when the tab reports the current pair.
Full document loads always receive messages. Header-bearing requests never
write the legacy session markers. Clients without the header retain session
comparison, with marker writes limited to page-shaped requests; raw API requests
and downloads skip layout resolution entirely.

The navigation subscriber replaces the catalog on any non-null messages,
including login/logout audience changes. If the server reports a different
locale with null messages, it reloads only `i18n` once, advertising the catalog
still held by that tab. Successful catalog delivery resets that retry guard.
25 changes: 15 additions & 10 deletions docs/framework/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ The actual `add_middleware` call order (in `install_middleware`) is the
app.add_middleware(CommitBeforeResponseMiddleware)
app.add_middleware(MaintenanceMiddleware)
app.add_middleware(RateLimitMiddleware, ...)
app.add_middleware(InertiaCacheMiddleware)
app.add_middleware(InertiaLayoutDataMiddleware, ...)
app.add_middleware(LocaleMiddleware, ...)

Expand Down Expand Up @@ -58,7 +57,7 @@ SecurityHeaders
↓
Session
↓
InertiaMiddleware (303 / fragment 409 / empty-200 back / Vary)
InertiaMiddleware (303 / fragment 409 / empty-200 back / Vary / no-store)
↓
<module middleware> ← in whatever order each module installed them
↓
Expand All @@ -68,8 +67,6 @@ Locale
↓
InertiaLayoutData
↓
InertiaCache
↓
RateLimit (429 for anonymous public-route traffic)
↓
Maintenance
Expand All @@ -83,7 +80,7 @@ The response flows back up in the reverse of this order.

The last three are ordered relative to each other for reasons worth stating, because each is the kind of thing that looks arbitrary until it breaks:

- **`Maintenance` sits *inside* `InertiaCache`.** Its 503 is an Inertia payload produced by short-circuiting, carrying this user's auth block and menus like any other. Short-circuiting outside the cache guard would ship exactly the per-user payload that guard exists to keep out of caches. Because its `self.app` is the middleware below it, the 503 still travels back out through `InertiaCache`'s send-wrapper and picks up the same headers.
- **`Maintenance` sits inside the adapter's `InertiaMiddleware`.** Its short-circuit responses receive the same cache protection and protocol normalization as page endpoints.
- **`Maintenance` runs *after* `InertiaLayoutData`, `Locale` and auth.** It needs the shared props to render with a layout instead of bare, the locale to answer in the right language, and the resolved user to know whether the caller is an admin who should pass through.
- **`CommitBeforeResponse` is innermost.** It hooks the `send` channel, so being added first makes its wrapper the first to see the response — which is what lets the commit land before any byte is written.

Expand Down Expand Up @@ -197,15 +194,23 @@ The resolved locale lands on `request.state.locale` and is used by `InertiaLayou

### `InertiaLayoutDataMiddleware`

Runs last (closest to the app). Populates `request.state.inertia_shared` with:
Permissions resolve eagerly for route guards. Other layout work is deferred in
`request.state.inertia_shared_factory`: menus, catalog selection, principal
serialization and module providers run only when a page needs them. Both
`get_inertia` and `render_error_page` await `resolve_inertia_shared(request)`;
its memoised task populates `request.state.inertia_shared` for existing readers.
JSON APIs and downloads do not invoke it. Custom readers should await the same
resolver before accessing the state dict.

When a page needs layout data, populates `request.state.inertia_shared` with:

- `auth.user`, `auth.isAuthenticated`, `auth.permissions`
- `menus` — grouped by `MenuSection`, filtered by the current user's permissions
- `i18n` — `{ locale, bundle }` for the resolved locale
- `i18n` — `{ locale, audience, supportedLocales, messages }` for the resolved locale

Inertia responses pick these up automatically via `InertiaDep` from `simple_module_hosting.inertia_deps`.

### `InertiaCacheMiddleware`
### Cache protection in `InertiaMiddleware`

Keeps the Inertia payload out of caches that answer page requests.

Expand All @@ -218,7 +223,7 @@ That only bites once a route marks itself cacheable, which a public-content modu

A module that wants its public page content cached should give the **document** its own `Cache-Control` and an ETag. That path is left alone — this middleware only governs the payload.

The Inertia-request predicate mirrors `fastapi-inertia`'s own, which is presence-only (`"X-Inertia" in headers`), not an equality check against `true`. The two must agree: if the library renders JSON for `X-Inertia: 1` while this middleware doesn't recognise it as Inertia, the payload goes back marked however the route marked it — reopening the leak by changing one header value.
The exported adapter helper `is_inertia_request(scope)` uses header presence (`"X-Inertia" in headers`), not an equality check against `true`. The two must agree: if the library renders JSON for `X-Inertia: 1` while this middleware doesn't recognise it as Inertia, the payload goes back marked however the route marked it — reopening the leak by changing one header value.

### `MaintenanceMiddleware`

Expand All @@ -245,7 +250,7 @@ Pure ASGI rather than `BaseHTTPMiddleware`, because the hook point is the `send`
## Inertia protocol responses

`InertiaMiddleware` comes from `simple_module_inertia` and runs just inside
`Session`, outside every module middleware and `InertiaCacheMiddleware`, so it
`Session`, outside every module middleware, so it
also normalises redirects a module middleware issues (an expired session's
login redirect after a `DELETE`).
For requests carrying `X-Inertia`, it converts 302 and 307 redirects after
Expand Down
7 changes: 4 additions & 3 deletions docs/frontend/inertia.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Inertia.js turns classic server-side routing into a single-page app without a se
Two moving parts:

1. **`fastapi-inertia`** — renders templates and serializes responses in Inertia's expected shape.
2. **`InertiaLayoutDataMiddleware`** (framework) — attaches shared props (`auth`, `menus`, `i18n`) to every response.
2. **`InertiaLayoutDataMiddleware`** (framework) — lazily supplies shared props (`auth`, `menus`, `i18n`) to rendered pages.

## Rendering a page

Expand All @@ -33,7 +33,7 @@ async def browse(inertia: InertiaDep, service: OrderServiceDep):

Breakdown:

- `InertiaDep` (from `simple_module_hosting.inertia_deps`) is a request-scoped dependency that resolves the `Inertia` instance from `app.state.inertia_dependency` (configured at boot) and shares in `request.state.inertia_shared`.
- `InertiaDep` (from `simple_module_hosting.inertia_deps`) is a request-scoped dependency that resolves the `Inertia` instance from `app.state.inertia_dependency` (configured at boot) and shares the layout props (`auth`, `menus`, `i18n`, …), which it resolves lazily via `resolve_inertia_shared(request)`; do not read `request.state.inertia_shared` without awaiting that first.
- The first argument to `render` is the **page key** — `"Orders/Browse"` maps to `modules/orders/orders/pages/Browse.tsx`. See [Pages & discovery](/frontend/pages).
- `props` is a plain dict serialized to JSON. Use `model_dump(mode="json")` on SQLModel DTOs to get JSON-safe types (`Decimal → str`, `datetime → iso string`).

Expand Down Expand Up @@ -129,8 +129,9 @@ Every Inertia response includes:
},
i18n: {
locale: string,
audience: 'public' | 'full',
supportedLocales: string[],
messages: Record<string, string> | null, // flat key → value; null on unchanged-locale XHR partials
messages: Record<string, string> | null, // flat key → value; null when the tab reports a matching locale/audience
},
}
```
Expand Down
34 changes: 17 additions & 17 deletions docs/frontend/shared-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,20 @@ interface SharedProps {
// Every Inertia response also carries an `i18n` block (read via usePage().props.i18n):
interface I18nSharedProps { // SharedProps['i18n']
locale: string;
audience: 'public' | 'full';
supportedLocales: string[];
messages: Record<string, string> | null; // null on Inertia XHR partials when the locale is unchanged
messages: Record<string, string> | null; // null when the tab reports the same locale and audience
}
```

## How they're populated

`InertiaLayoutDataMiddleware` runs **last** in the middleware pipeline (closest to the app). For every outgoing Inertia response, it reads:
`InertiaLayoutDataMiddleware` resolves permissions eagerly and installs a memoised
async layout factory. Page dependencies and error rendering resolve that factory;
JSON API requests and downloads skip it. The resolved props remain available as
`request.state.inertia_shared`; custom readers first await
`resolve_inertia_shared(request)` from `simple_module_hosting.inertia_deps`.
For each rendered page, it reads:

- `request.state.user` (set by the auth middleware) and the registered `app.state.principal_serializer` to build `auth.user`. The user's roles drive `auth.permissions`.
- `menu_registry.get_for_user(...)`, filtered by the user's auth state and roles, to build `menus`.
Expand Down Expand Up @@ -161,24 +167,18 @@ t("orders.browse.title");
t("orders.items", { count: orders.length }); // pluralization
```

Each tab advertises its cached locale/audience via `X-SM-I18n`. A matching
Inertia visit receives `messages: null`; otherwise the server resends the catalog.
The client reloads `i18n` once if it receives a changed locale without messages.

The `i18n.messages` block is the **resolved locale's** translations merged across all modules. Switching the locale (via `<LocaleSwitcher />`, which POSTs to `/i18n/set-locale`) triggers a full-page navigation so the messages are re-fetched. The active locale and supported locales are on `usePage().props.i18n` (`locale`, `supportedLocales`).

## Extending shared props

If your module needs to inject a new shared prop (e.g. feature flags), do **not** mutate `request.state.inertia_shared` from random handlers — wrap in a dedicated middleware:

```python
class FeatureFlagsSharedPropsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
flags = request.app.state.sm.feature_flags.evaluate_all(
principal=getattr(request.state, "user", None)
)
if not hasattr(request.state, "inertia_shared"):
request.state.inertia_shared = {}
request.state.inertia_shared["flags"] = flags
return await call_next(request)
```

Register in `register_middleware`. Because it runs *before* `InertiaLayoutDataMiddleware` (framework middleware runs first on the way in), the flag dict is already attached when the framework middleware merges its own props.
Register a sync or async provider with
`simple_module_hosting.register_inertia_shared_provider(app, provider)` from a
module lifecycle hook. It runs once when shared props are resolved, and its
returned dict is merged after the built-in props. Reserved keys cannot be
overwritten; provider failures are logged and skipped.

On the client, a module-specific shared prop is read with `usePageProps<{ flags: Flags }>()`; props every app needs belong in `SharedProps` (`packages/ui/src/types.ts`).
5 changes: 2 additions & 3 deletions framework/hosting/simple_module_hosting/_error_negotiation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Choose JSON or HTML for framework error responses."""

from simple_module_inertia import is_inertia_request
from starlette.requests import Request

from simple_module_hosting._inertia_shared import _INERTIA_HEADER


def _explicit_accept_q(accept: str, media_type: str) -> float | None:
"""Quality the Accept header gives ``media_type`` by explicit listing, or
Expand Down Expand Up @@ -51,7 +50,7 @@ def _wants_json(request: Request) -> bool:
view path keeps its pre-existing default and an ``/api/*`` path keeps
getting JSON either way.
"""
if request.headers.get(_INERTIA_HEADER):
if is_inertia_request(request.scope):
return False
accept = request.headers.get("accept", "")
html_q = _explicit_accept_q(accept, "text/html")
Expand Down
10 changes: 9 additions & 1 deletion framework/hosting/simple_module_hosting/_error_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from starlette.requests import Request
from starlette.responses import Response

from simple_module_hosting._inertia_shared import resolve_inertia_shared

logger = logging.getLogger(__name__)

# Statuses whose remedy is "sign in", so the page offers that as its primary
Expand Down Expand Up @@ -103,7 +105,13 @@ async def render_error_page(
# repeated here. Without it the error page renders raw translation keys
# (ui.error_page.not_found_title) and loses auth/menus, so a signed-in
# user's 404 has no layout.
shared = getattr(request.state, "inertia_shared", None)
try:
shared = await resolve_inertia_shared(request)
except Exception:
# Building the layout is what failed (a DB outage in the menus, say):
# still render the page, just without the shared layout props.
logger.warning("Error page: layout props unavailable", exc_info=True)
shared = {}
if shared:
inertia.share(**shared)
# The correlation id is the only handle a user has on their own failed
Expand Down
Loading
Loading