audit-shells
Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/ `<body>` rendered inside a shell, shells that never render `children`, unused shells, and `ErrorBoundary` exports shadowed by
SKILL.mdskills/audit-shells/SKILL.md
GitHubPracht Audit Shells
Shells in pracht are named layout components composed around routes. The
framework owns the document — shells must not render <html>, <head>, or
<body>. They sit between the framework's HTML scaffold and the route
component.
Step 1: Enumerate
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
pracht inspect routes --json
Prerequisite: pracht inspect needs a vite config with the pracht plugin
wired up; run it from the app root.
For every shell used by the app, read its source file. Also list which routes
use which shell (the JSON output includes resolved shell and shellFile per
route). The JSON's top-level mode field tells you which router the app uses
— see Step 3 for how that changes shell discovery.
Step 2: Per-shell checks
For each shell file:
2a. Document-level tag misuse
Grep for <html, <head>, <body>, <meta, <title>, <link rel. Any of
these inside a shell is a bug — the framework injects them based on head()
exports. Recommend moving meta/title to the shell's head() export and
removing the JSX tags.
2b. Shell export shape
- Must be a function component named
Shell. - Must accept
{ children }: ShellProps. - Must render
{children}somewhere — flag shells that never renderchildren(hard to spot, blank page everywhere).
2c. Loading() for SPA routes
If any route assigned to this shell has render: "spa", the shell SHOULD
export a Loading() function that renders a placeholder during the
client-only data fetch. Without it, users see blank content during navigation.
2d. head() export
- Optional, but recommended if the shell sets shared meta tags.
- Verify return shape matches
{ title?, lang?, meta?, link?, script? }. - Flag shells whose
head()returnsundefinedunconditionally — delete the export.
2e. ErrorBoundary export
ErrorBoundary is a valid export on both shells and routes (see
ShellModule and RouteModule in packages/framework/src/types.ts). The
runtime resolves routeMod.ErrorBoundary ?? shellModule.ErrorBoundary — a
shell-level boundary is the fallback for every route under that shell when
the route doesn't declare its own.
- A shell exporting
ErrorBoundaryis fine — often the right place for a shared error fallback. - If both the shell and a route declare one, the route wins. Flag as
infoat most, and only if the shell boundary is thereby unreachable for every route under it (dead code).
2f. headers() export
Shells may export headers() to contribute response headers (merged with
route headers()). Optional, but if present:
- Verify the return shape is a plain
HeadersInit. - Flag shells whose
headers()returnsundefinedunconditionally — delete the export.
2g. loader export
Shells may export loader(args) for layout-level data, read with
useShellData() in the shell and its routes. If present:
- Run the audit-loaders checks on it: its return value is serialized into
every page under the shell (
shellDatain the hydration state and the route-state JSON). useShellData()returnsundefinedwhile the shell renders without its data (SPA loading state, an error boundary after the shell loader failed). Flag a shell that dereferences it unguarded.- Flag a shell loader used as an auth gate (redirecting anonymous users) with no gating middleware on its routes: navigations inside the shell ask the server to skip it. Gate in middleware.
- Flag several routes under one shell whose loaders each fetch the same layout-level value (the current user): that belongs in the shell loader.
Step 3: Coverage and waste
Shell registration is mode-aware — check the mode field from the Step 1
JSON first:
- Manifest apps (
mode: "manifest"): shells are registered indefineApp({ shells }). Diff that registry against the per-route resolvedshell/shellFilevalues from the inspect JSON — the JSON has no shell registry of its own, so "unused" means "registered indefineAppbut referenced by no route or group". - Pages apps (
mode: "pages"): there is nodefineAppshell registry. Each_appfile is auto-registered —src/pages/_app.tsxas"pages",src/pages/blog/_app.tsxas"pages:blog"— and owns the routes in its directory subtree (see https://pracht.resynapse.dev/docs/shells). Shells replace, they do not nest: the nearest_appabove a route is the only one that renders it, so a directory shell must carry its ownhead()/headers()rather than inherit the parent's. "Unused shells" analysis does not apply; instead checkpracht inspect routes --jsonand confirm each route'sshellis the nearest_appyou expect, and that no directory you meant to scope is silently falling back to"pages". An_appinside an underscore-reserved tree (src/pages/_components/_app.tsx) is a plain helper, and two_appfiles in one directory are a build,doctor, andverifyerror.
Then report:
- Unused shells (manifest apps only): registered but referenced by no route or group. Recommend removal.
- Single-use shells: shells used by exactly one route — sometimes a
signal the layout should be inlined. Flag as
info. - Routes without shells: routes resolved to no shell. Usually intentional for raw HTML responses, but worth listing.
Step 4: Report
| Shell | File | Used by | Issue | Severity |
|---|
Severities: error (document tags, missing children), warn (no Loading
on SPA routes, empty headers()), info (unused, single-use, shell
ErrorBoundary shadowed by route-level boundaries on every route).
Rules
- Source of truth is
pracht inspect routes --json— it shows resolved shell-per-route after group inheritance. - Read the shell source — do not infer from names.
Loading()is a shell export (SPA-only fallback).ErrorBoundaryis valid on both shells and routes: the shell's boundary is the fallback, the route's wins when both exist. Do not flag shell boundaries as bugs.- Recommend deletions for unused shells; do not delete automatically.
- When in doubt about render mode interaction, cross-reference with
tune-render-mode.
$ARGUMENTS