Skip to content

Stop Inertia payloads rendering as pages, and 500ing on client-side visits - #272

Merged
antosubash merged 3 commits into
mainfrom
fix/inertia-json-encoder-and-cache
Aug 21, 2026
Merged

Stop Inertia payloads rendering as pages, and 500ing on client-side visits#272
antosubash merged 3 commits into
mainfrom
fix/inertia-json-encoder-and-cache

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Two defects in how the Inertia JSON representation is produced and served. Both were found on a deployed site running this framework, and both are reproduced end to end here.

The payload can be served as the page

Every Inertia route answers one URL two ways, chosen on the X-Inertia request header, and neither response said so. A public-content module marking its page Cache-Control: public therefore made both representations share a single cache entry and validator:

  1. Visit a page through the SPA — client-side visit, JSON payload, stored as public, max-age=60.
  2. Open the same URL directly — the browser serves the stored JSON as the document.

The visitor gets {"component":"...","props":{...}} where the page should be. Confirmed in a real browser against a live site, and the same shared ETag will 304 a document request straight back into the cached payload.

This belongs in the framework rather than in each module: InertiaLayoutDataMiddleware is what merges the signed-in user's auth block, permission list and menus into every payload. A route author choosing public for their own page content has no way to know the response carries someone's identity — so this was a disclosure risk as well as a broken page.

InertiaCacheMiddleware now, for any response to an X-Inertia request:

  • forces Cache-Control: private, no-store
  • drops the ETag, so nothing can revalidate its way back to a stored copy
  • adds Vary: X-Inertia — also added to HTML documents, so a cache keeps the two apart

Documents keep whatever caching their route chose; static assets and JSON APIs are untouched, so they keep their 304s.

A rich prop 500s only on a client-side visit

InertiaConfig.json_encoder exists to run props through FastAPI's jsonable_encoder, and upstream applies it on the full page load only — the JSON branch builds a Starlette JSONResponse, reaching plain json.dumps:

# full page load — honours json_encoderjson_string=json.dumps(page_data, cls=self._config.json_encoder)
# client-side visit — no encoderreturnJSONResponse(content=awaitself._get_page_data(), ...)

So any prop the stdlib can't encode renders on a reload and raises on every navigation to the same route — a miserable bug to read from a stack trace, because the page "works" right up until it's clicked. Settings → Modules hit this against a module declaring media_root: Path:

TypeError: Object of type PosixPath is not JSON serializable

Fixed at both layers: the dependency is wrapped so the two branches encode identically (covering Decimal, date, UUID, enums and dataclasses too), and settings.serialize encodes field values at the boundary where a settings object stops being Python and becomes a prop.

Why CI never caught it: no module in this repo declares a non-JSON-native settings field, so /settings/ always serialised cleanly. test_module_settings_render.py adds a demo module with media_root: Path and keeps it there.

Verification

  • uv run pytest — 2076 passed, 4 skipped
  • ruff format --check, ruff check, ty check, 300-line cap — all clean
  • Each new test confirmed failing against the unfixed source: the settings tests raise the exact production TypeError, and the pipeline test 500s on the client-side visit while the full page load passes — the reported asymmetry

test_middleware_order.py and the CLAUDE.md pipeline line are updated for the new middleware.

https://claude.ai/code/session_013z9hUQKEehrp99jWUDPV39

…visit
Two defects in how the Inertia JSON representation is produced and served,
both found on a deployed site running the framework.
**The payload can be served as the page.** Every Inertia route answers one
URL two ways, chosen on the `X-Inertia` request header, and neither response
said so. A public-content module marking its page `Cache-Control: public`
therefore made both representations share one cache entry: visit a page
through the SPA, then open the same URL directly, and the browser serves the
stored JSON as the document — the visitor gets `{"component":...}` where the
page should be. Reproduced end to end in a browser against a live site.
That is the framework's problem to fix rather than each module's, because
`InertiaLayoutDataMiddleware` is what merges the signed-in user's `auth`
block, permissions and menus into every payload. A route author choosing
`public` for their own page content has no way to know the response carries
someone's identity — which also made this a disclosure bug, not just a
broken page. `InertiaCacheMiddleware` now forces `private, no-store` on any
response to an `X-Inertia` request, drops its ETag so nothing can revalidate
its way back to a stored copy, and adds `Vary: X-Inertia` to both
representations. Documents keep whatever caching their route chose.
**A rich prop 500s only on a client-side visit.** `InertiaConfig.json_encoder`
exists to run props through `jsonable_encoder`, and upstream applies it only
on the full page load; the JSON branch builds a Starlette `JSONResponse` and
reaches plain `json.dumps`. So a `Path`, `Decimal` or `date` in props renders
on a reload and raises on every navigation to the same route. Settings →
Modules hit this against a module declaring `media_root: Path`:
TypeError: Object of type PosixPath is not JSON serializable
The dependency is now wrapped so both branches encode identically, and
`settings.serialize` encodes field values at the boundary where a settings
object stops being Python and becomes a prop. No module in this repo declares
a non-JSON-native settings field, which is why CI never saw it — the new
tests add one and keep it.
Claude-Session: https://claude.ai/code/session_013z9hUQKEehrp99jWUDPV39
@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:42bccdb
Status: ✅ Deploy successful!
Preview URL:https://feb3de32.simple-module-python.pages.dev
Branch Preview URL:https://fix-inertia-json-encoder-and.simple-module-python.pages.dev

View logs

Code review caught that `is_inertia_request` and the thing it exists to
shadow disagreed. It required the `X-Inertia` header to equal `b"true"`, but
`fastapi-inertia`'s own `Inertia._is_inertia_request` — the property
`render()` consults to choose JSON over HTML — is presence-only:
return "X-Inertia" in self._request.headers
So a request carrying any other value (`X-Inertia: 1`, `X-Inertia: false`)
was rendered as JSON by the library while this middleware did not recognise
it as Inertia: no `private, no-store`, no ETag strip, no `Vary`. The payload —
auth block, permissions and menus included — went back marked however the
route had marked it, which for a public-content route means publicly
cacheable. That is precisely the leak this middleware was added to close,
reachable by changing one header value.
Now mirrors upstream's predicate exactly, so the two cannot drift apart while
the library keeps that definition. `_HEADER_INERTIA_TRUE` goes with it.
The regression test deliberately does not call `is_inertia_request`: its
handler reproduces upstream's presence check directly, so the test asserts
agreement between two independent implementations rather than a function
agreeing with itself.
Claude-Session: https://claude.ai/code/session_013z9hUQKEehrp99jWUDPV39
@antosubash
antosubash merged commit 55e8000 into mainAug 21, 2026
13 checks passed
antosubash added a commit that referenced this pull request Aug 21, 2026
…to end
Round 1 pass 3 (post-rebase confirmation) verified by source-reading that
resolving the middleware conflict as InertiaCache -> Maintenance ->
CommitBeforeResponse is correct: Maintenance's short-circuited 503 is sent
through InertiaCache's send-wrapper (its `self.app` is Maintenance), so an
Inertia request during maintenance still gets `private, no-store` / dropped
ETag / `Vary: X-Inertia` — the exact guarantee GH #272 added InertiaCache
for.
test_middleware_order.py already pins the *order*; it can't prove a
short-circuiting middleware's response actually reaches the wrapper outside
it. Add that as a direct test: build the two middlewares in real pipeline
order and assert the 503's headers, for both an X-Inertia request (gets the
cache guard) and a plain API request (does not, matching the existing
JSON-caller behavior).
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
antosubash added a commit that referenced this pull request Aug 21, 2026
…r pages (#274)
* fix(auth): preserve the deep link across login
AuthMiddleware stashed the URL an anonymous visitor asked for in
session["next"], but the local provider never read it back:
get_login_url() accepted a next_url argument and ignored it, and
Login.tsx looked for a ?next= query param that nothing ever set. Every
login landed on the configured default instead of the requested page.
The Keycloak provider already consumed the session key correctly, so
the two providers had silently drifted apart.
Middleware now hands the provider a sanitised relative target and the
local login view surfaces it as login_redirect_url — read, not popped,
so reloading the login page does not downgrade the deep link. The POST
handlers clear it once login actually succeeds.
Login.tsx no longer reads ?next= from the query string. That was an
open redirect: a crafted /users/login?next=https://evil.example would
bounce the user off-site immediately after signing in.
safe_next() moves from site_lock into simple_module_core.redirect_safety
alongside the session-key constant, so the middleware, both providers
and the site gate share one implementation rather than four copies of
the same URL-safety rules.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* feat(hosting): common error states, maintenance mode, offline banner
The Inertia error page only had copy for 403/404/500, so a 401, 429 or
503 rendered as a bare "Error / An unexpected error occurred" — telling
the user nothing they could act on. Adds 401, 419, 422, 429 and 503,
and collapses the three parallel Record<number, …> maps into one row
per status, since maps that must be edited in lockstep drift.
401 and 419 now offer "sign in" as the primary action, since that is
the actual remedy. The URL comes from the auth provider via app.state
rather than an import — framework code must not reach into modules
(SM009) — and an app with no provider installed simply gets no button.
A test parses the status table out of Error.tsx and asserts every
status the handler renders has copy for it. The two lists are in
different languages and nothing else keeps them honest.
Maintenance mode is a DB-backed HostSettings flag so flipping it does
not need a redeploy, which is when you least want one. Admins pass
through — someone must be able to reach settings and switch it off —
and the auth provider's own routes stay open so an admin who was
signed out when it flipped can still sign in. It fails open on missing
config: a config gap taking the site down is the exact failure this
feature would otherwise cause.
Also fixes render_error_page reading app.state.sm outside its own try,
which meant the documented JSON fallback never ran for that failure and
the error page raised while reporting an error.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* feat(admin): move the admin screens under /admin
The admin surfaces were scattered across the URL space — /settings/,
/audit_log/, /users/admin, /dashboard/doctor — with nothing marking
them as a section. MenuSection.ADMIN_SIDEBAR and AdminLayout already
existed but only one screen (Doctor) ever used them, so the whole admin
area was scaffolding with nothing in it.
Every admin screen now lives under /admin/, registers into
ADMIN_SIDEBAR, and renders in AdminLayout. Grouped Access / Appearance
/ System, which is what group= is for now that the admin sidebar is
itself the separation — it previously tried to carve an admin area out
of the main sidebar with ad-hoc "Administration" and "System" labels.
Pure-admin modules just point view_prefix at /admin/...; that does not
work for users and dashboard, which serve public and admin pages from
one package. A module gets exactly one view router, so ModuleMeta gains
an optional admin_view_prefix and ModuleBase an optional
register_admin_routes hook, letting users keep /users/login while its
CRUD moves to /admin/users. Both are additive and default to no-op, so
no existing module changes.
/admin itself is a host route: no module owns the admin section, it is
assembled from whatever is installed, and a module could only serve it
by claiming /admin as its own view_prefix. The page lists no tools of
its own — it reads the adminSidebar shared prop, already filtered by
the viewer's roles and permissions, so a card cannot advertise a screen
its owner cannot open.
Old URLs 301 to the new ones. Only view URLs moved; /api/* is a
separate contract with external callers and is untouched.
Two things found while moving:
- settings' own /modules -> / redirect hardcoded the old path, so it
would have sent users to a URL that no longer exists. Now derived
from VIEW_PREFIX.
- The bare-prefix route clone only ever covered routes declared
directly on a router. include_router stores a placeholder and
flattens later, so for the majority of modules it silently matched
nothing. Documented rather than left implying coverage it lacks;
the affected menu URLs use the canonical trailing-slash path, which
is what every other module already did.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix(hosting): show maintenance copy on a planned outage
The 503 rendered "Service unavailable" — the generic incident wording —
even when an operator had deliberately taken the site down, and the
maintenance_title/description strings added alongside the feature were
unreachable: nothing ever selected them.
MaintenanceMiddleware now marks the request, so the page can tell a
planned outage from the same status arriving unbidden. That matters
most when the operator sets no message, which is the case where the
page has nothing else to say.
Adds a test that every host.error.* key is referenced by Error.tsx.
Copy that no code path can reach is invisible in review and in the
running app — the page just quietly falls back to the generic message,
which is exactly how these two keys shipped dead.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* docs: record the admin section and the admin-routes hook
Documents where admin screens go and why all three parts move together
— URL, menu section, layout — since changing one leaves a page whose
sidebar no longer lists it, and nothing about that failure is obvious
from the diff.
Living reference docs now point at the moved URLs. Dated plans, specs,
release notes and perf runs are left alone: they record what was true
when they were written.
Two of the corrected links were already stale before this move —
/settings/permissions and /settings/feature-flags never existed under
the settings module.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix: address code review findings (round 1, pass 1)
Ten findings from the Stage A review, most of them call sites the URL
move missed because they were neither <Link href> nor router.get:
- permissions RoleEdit/UserEdit PUT, feature_flags toggle/clear POST and
audit_log filter navigation still targeted the pre-move prefixes. The
legacy redirects are GET-only, so saving role permissions or toggling a
flag would have 404/405'd and silently failed to persist.
- /admin/doctor had no authorization dependency at all: any signed-in
account could read migration status, module list and system info, and
the Doctor menu item carried no roles= either, so it was visible to
them too. Now guarded, gated in the menu, and covered by a regression
test — this PR's own module-authoring guidance says to do both.
- MaintenanceMiddleware consulted only the provider's legacy
get_public_paths(), not the PublicRouteRegistry that AuthMiddleware
also checks. Branding's logo/favicon are registered there precisely so
the sign-in page can render the brand, so the maintenance page was
losing its own branding — and any module's deliberately-public route
(webhooks, STAC/OGC reads) was blocked. Fails closed when no registry
is present.
- The legacy redirect dropped the query string, so a bookmarked filtered
or tabbed URL reset itself on the way through.
- permissions' post-save redirect and a RoleEdit cancel link used bare
or hardcoded paths instead of the canonical trailing slash and the
USERS_ADMIN_PATH constant.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix(admin): admit anyone with an admin sidebar entry to /admin
Follow-up to review finding #11. The reviewer flagged the hardcoded
is_admin() gate as a design call; the narrower problem underneath it is a
real inconsistency.
The overview renders from the adminSidebar shared prop, which the menu
registry filters by roles AND permissions. Admission checked the admin
role alone. So a custom role holding, say, settings.view could open
/admin/settings/, see the AdminLayout badge pointing at /admin, click it,
and get a 403 — from a page whose whole job was to list the one tool
they can use.
Admission now mirrors what the page renders, read from the shared props
the layout middleware already built for the request rather than
recomputing the filter (recomputing is how the two drift apart). Admins
are still admitted unconditionally so an install with no admin modules
reaches the page's own empty state instead of a 403, which would have
made that copy unreachable.
Whether "admin" should become a real permission is left alone — that is
the architecture question the reviewer raised, and it is not this branch.
Adds host/tests/, which needed a fixture that mounts the host router:
the framework app fixture builds create_app() only, so every host-level
route was previously untested and /admin simply 404'd.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* docs: fix stale admin-section menu metadata left over from the URL move
audit_log/feature_flags/settings menu tables still said Section=SIDEBAR
after their module.py registrations moved to MenuSection.ADMIN_SIDEBAR;
feature_flags/users also had stale Group values. settings.md's prose and
view-routes table still referenced the pre-move /settings paths instead
of /admin/settings.
Found by an out-of-scope code-review pass while confirming 5a804e2/d772ed2
were clean; these inaccuracies predate both commits (already present in
88f880d) so they're committed separately rather than folded into the
admin-admission fix.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* test(maintenance): cover POST to a GET-only public route during maintenance
test_method_not_covered_by_the_rule_still_gates claimed to verify that a
method not covered by a public-route rule still gates, but it requested
an unrelated path with no rule at all rather than a disallowed method on
the same path the rule covers — the actually security-relevant case (a
GET-only public rule must not let POST bypass maintenance) was untested,
and the file's _get helper only issued GET requests so nothing could
catch a regression there.
Renamed the existing test to test_uncovered_path_still_gates (accurate
to what it checks) and added test_wrong_method_on_a_covered_path_still_gates,
which POSTs to a GET-only registered path and asserts 503. The underlying
PublicRoute.matches/PublicRouteRegistry.matches were already correctly
method-aware (confirmed by reading public_routes.py) — this closes a
test-coverage gap found during round-1 pass-2 review, not a runtime bug.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* test(e2e): point the browser suite at the moved admin URLs
The e2e suite still drove /users/admin, /audit_log, /settings/modules,
/feature_flags/ and /branding/. Two specs failed in CI: the command
palette waited on **/audit_log** forever, and the user-search spec
loaded /users/admin?q=_ and found nothing to assert against.
This was missed because e2e is excluded from `make test-py`
(-m 'not e2e and not perf'), so every local run stayed green while the
E2E smoke job was red — the local gate and CI disagreed about what
"tests pass" means.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix(ui): keep every admin screen reachable from the command palette
Surfaced by running the e2e suite locally after rebasing onto main.
SidebarLayout passed only its own menuKey's items to CommandPalette, so
once the admin screens moved to adminSidebar, ⌘K from the app shell
could no longer reach Users, Settings, Audit Log or any other admin
page — and from AdminLayout it could no longer reach the app ones. The
palette's own docstring promises "⌘K over everything the sidebar can
reach"; that quietly stopped being true.
It now indexes both sidebars, deduped by url. Both are already filtered
by roles and permissions server-side, so this widens reach without
offering anything the viewer cannot open.
Also points main's new test_module_settings_render at /admin/settings/;
it was written against /settings/ while this branch was moving it.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* test(maintenance): lock in the InertiaCache/Maintenance ordering end to end
Round 1 pass 3 (post-rebase confirmation) verified by source-reading that
resolving the middleware conflict as InertiaCache -> Maintenance ->
CommitBeforeResponse is correct: Maintenance's short-circuited 503 is sent
through InertiaCache's send-wrapper (its `self.app` is Maintenance), so an
Inertia request during maintenance still gets `private, no-store` / dropped
ETag / `Vary: X-Inertia` — the exact guarantee GH #272 added InertiaCache
for.
test_middleware_order.py already pins the *order*; it can't prove a
short-circuiting middleware's response actually reaches the wrapper outside
it. Add that as a direct test: build the two middlewares in real pipeline
order and assert the 503's headers, for both an X-Inertia request (gets the
cache guard) and a plain API request (does not, matching the existing
JSON-caller behavior).
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* test(perf): measure the moved admin routes, not their redirects
The perf and loadtest suites still drove /users/admin and /audit_log/.
Nothing failed — routes_legacy.py 301s them and page.goto follows — so
the suites stayed green while quietly measuring an extra redirect hop
on every navigation sample, and the loadtest reported timings under a
route name that no longer exists.
A perf guard that passes while measuring the wrong thing is worse than
one that fails, since nothing prompts anyone to look.
Neither suite runs in the default gate (perf is excluded by the pytest
marker filter, loadtest is a separate Locust script), which is why the
URL move missed them.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix: address code review findings (round 1, pass 3)
Automated high-effort review against refs/remotes/origin/main (--fix)
found 10 real issues in the admin-section move and the deep-link/error
work that shipped alongside it. All but one fixed here; the remaining
one is a pre-existing, unrelated bug left as a judgment call (see below).
- No menu item anywhere pointed at /admin: admins landing on /dashboard/
had no click path into the admin section once every admin screen moved
out of the main sidebar. Add AdminSectionLink, rendered at the bottom
of the plain-sidebar shell only when the viewer has at least one admin
entry — mirrors host/routes.py's own admission rule, so it never offers
a link that would 403. New "ui.nav.admin" key in both locales.
- Dashboard's "Users" tile opened the viewer's own profile instead of
user management: menuTarget()'s prefix fallback assumed the Users menu
entry still lived under /users, but it moved to /admin/users
(admin_view_prefix). Ship the module's admin mount point in the
system_info payload (stats.py) and search it first (Home.tsx),
matching how the module itself is structured.
- Two in-app links (RolesCard.tsx, RolesTab.tsx) still pointed at the
pre-move /permissions/... prefix, working today only via the
routes_legacy.py 301 shim that is scheduled for removal.
- audit_log and feature_flags Browse.tsx targeted the bare
/admin/audit-log and /admin/feature-flags prefixes with router.visit();
neither is eligible for _clone_bare_prefix_route's bare-alias (both are
contributed via include_router), so every filter/sort change cost an
extra 307 round trip. Use the canonical trailing-slash form, matching
each module's own MENU_URL.
- Google OAuth login never consumed the SESSION_NEXT_KEY deep link
AuthMiddleware stashes before bouncing an anonymous visitor to login —
the password and Keycloak paths already did. Wire it in the same way:
pop, re-sanitise with safe_next_or_none (the value lands in a Location
header), fall back to login_redirect_url. Added a source-level test
pinning all three completion paths to the same contract, since two of
the three need a live identity provider to exercise end to end.
- redirects.py's safe_referer_or_root carried its own, weaker inline
same-site check alongside the new redirect_safety.safe_next, and
accepted at least one shape (backslash-prefixed relative paths) that
safe_next correctly rejects. Delegate to safe_next instead of
restating its rules, so the two cannot drift apart again.
- _INERTIA_ERROR_STATUSES widened to cover 401/429/503 moved exactly the
statuses whose headers carry meaning (WWW-Authenticate, Retry-After)
onto the page-rendering branch of the error handler, which dropped
them — narrowing a contract the handler's own comment still claimed to
honor. render_error_page now takes the exception's headers through to
both the rendered page and its JSON fallback; maintenance.py's 503
goes through the same path instead of mutating the response after the
fact. Frontend's SIGN_IN_STATUSES literal removed in favor of trusting
the server's login_url (null unless it should show), so the two lists
cannot drift.
- host/locales/es.json and packages/ui/locales/es.json were missing every
key this branch's error/offline/admin-nav work added to their English
counterparts (20+ keys) — Spanish visitors would have seen raw keys or
a silent English fallback. Filled in and re-sorted to match en.json's
key order; both packages now have full key parity.
- tests/test_audit_log.py and tests/test_principal_resolver_integration.py
(both outside pytest's `testpaths`, so never collected by `make test-py`)
still asserted against the pre-move /audit_log/ and /users/admin view
URLs. Retargeted to /admin/audit-log/ and /admin/users/.
Left unfixed, out of scope for this pass: retargeting
tests/test_principal_resolver_integration.py surfaced a second, wholly
unrelated bug in modules/users/users/provider.py (untouched by this
branch, predates it entirely) — UsersAuthProvider.resolve_user() takes
the bearer-token branch whenever an Authorization header is present and
never falls back to the session cookie if that resolution fails, so
test_session_wins_over_bad_bearer fails (a valid session + a garbage
Bearer header currently 401s instead of authenticating via session).
Whether an explicit-but-invalid bearer token should fall back to the
session or hard-fail is a security/product judgment call, not a rebase
or admin-section correctness bug, so it's flagged rather than changed
here.
Verified: uv run pytest -q -> 2137 passed, 2 skipped; npm test -> 105
passed; ruff / ty / biome ci / tsc (host + packages/ui) clean; make
doctor -> 0 errors (1 pre-existing, unrelated SM003 warning); file-size
and hardcoded-string checks clean.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix(dashboard): keep the Dashboard tile on the dashboard
Found by browser QA. The module tiles resolve a target by searching menu
entries under each of the module's prefixes. Preferring the admin prefix
outright sent the Dashboard tile to Doctor: dashboard owns both
/dashboard and /admin/doctor, and its own screen is the one the tile is
for. The rule that fixed the Users tile broke this one.
Exact menu matches on either mount point now beat under-prefix guessing
on both, which resolves each case on evidence rather than on an ordering
that can only ever suit one of them: dashboard hits /dashboard/ exactly,
while users has no exact /users entry and so lands on /admin/users/
rather than falling through to the profile page.
Also names the users-table row action. It was icon-only with no
accessible name — pre-existing (identical to main apart from the href),
but this branch already edits that line and a screen-reader user tabbing
the table otherwise hears an indistinguishable "Edit" per row with no
way to tell which account it opens.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* refactor(dashboard): reuse the shared path helpers in tile resolution
The tile-target fix hand-rolled trailing-slash normalisation and exact
matching that already exist as trimmed/samePath/isUnder in
packages/ui/src/lib/current-path.ts, which AppTopbar uses for the same
job. Two implementations of "do these paths refer to the same screen"
is how the sidebar highlight and the tiles end up disagreeing.
Behaviourally equivalent — same normalisation, same param order,
trimmed('') === '' preserved.
Found by the round-2 confirming review, which also traced menuTarget
against every installed module's prefixes and menu entries: dashboard
resolves to /dashboard/, users to /admin/users/, and permissions,
site_lock and auth stay inert because they ship no menu entry.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
* fix(hosting): let the browser tab name the page
Found by QA, which reported every tab reading as the bare app name.
Inertia's head manager only replaces elements carrying the `inertia`
attribute. The root template shipped a plain <title>, so the manager
left it alone and *appended* its own <title inertia=""> beside it. Two
title elements, and the browser uses the first in document order — so
the static brand name always won and no <Head title> anywhere in the app
had any effect. Marking the template's title head-managed makes the
manager replace it, while the server still renders the branded name for
the pre-hydration tab.
App-wide, not specific to the admin section: /dashboard/ predates this
branch, sets a static <Head title="Dashboard">, and was equally bare.
Covered by e2e assertions on both the text and the element count —
the count is the actual defect, since the text only goes wrong because
of the ordering a second title produces. Verified by reverting the
attribute: all five fail, and pass again with it.
The branding test asserted the exact <title> markup; relaxed to the text
so an attribute it is not about cannot fail it.
Also adds unit coverage for OfflineBanner, whose "back online"
confirmation QA reported missing. It was not missing: the sequence works
in a real browser, and the reading came from monkeypatching
navigator.onLine and sampling ~150ms later. Five fake-timer tests now
pin the offline, restored, cleared and re-armed states deterministically
rather than leaving it to a browser clock.
Claude-Session: https://claude.ai/code/session_01HCFXBubRRWDDBeyqYRZ1kw
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