Mantle App Layout Header Slots Migration Guide
@ngrok/mantle's AppLayout.Header has three slots. AppLayout.HeaderStart holds the Sidebar.Trigger, AppLayout.HeaderContent holds the breadcrumb trail, and AppLayout.HeaderActions holds the page's actions. The slots carry the row's flex (shrink-0, min-w-0 flex-1, and ml-auto shrink-0), so a call site sets none of it. A header composed without them still renders, so nothing breaks on upgrade. This guide moves a shell onto the slots and onto the canonical composition: each route renders AppLayout.Header and AppLayout.Body itself, and a context that pages push actions into goes away.
TL;DR: In every route module that renders a page, render AppLayout.Header (AppLayout.HeaderStart with Sidebar.Trigger, AppLayout.HeaderContent with RouteBreadcrumbs, AppLayout.HeaderActions with the route's actions) and AppLayout.Body around the page. The shell keeps only <Outlet /> inside AppLayout.Content. Delete the header-actions context and its effect hook. AppLayout.Body now renders Main itself, so delete the Main composed onto it, and pass asChild with a <div> where the page already owns a main.
Codemod
Codemod: manual: the header moves from the shell into every route module and the actions context goes away, so the rewrite spans files and no rule can place a route's actions.
You are an agent migrating a codebase. Do this:
- In the shell, delete
AppLayout.HeaderandAppLayout.Body.AppLayout.Contentholds only the<Outlet />. - In every route module that renders a page, render
AppLayout.HeaderandAppLayout.Bodyaround the page. The header holdsAppLayout.HeaderStartwithSidebar.Trigger,AppLayout.HeaderContentwithRouteBreadcrumbs, and, when the page has actions,AppLayout.HeaderActions. A layout route with tab children renders both once, and the tab routes render only their content through its<Outlet />. - Find a header-actions context: a provider in the shell, a hook such as
usePageHeaderActions(<Actions />)that a page calls from an effect, and a component that renders the context's value. For every page that calls the hook, move the element it passes into that route'sAppLayout.HeaderActions. The element keeps reading the page's own state. Delete the provider, the hook, and the render component. - Delete a wrapper such as
<div className="min-w-0 flex-1">around a trail and<div className="ml-auto flex gap-2">around actions, and delete aflex-1,min-w-0, orml-autoset on them for this purpose. The slots carry the flex. - Where the actions are icon buttons, collapse them into one menu below the sidebar's mobile breakpoint.
PageActionsin Header Actions from Routes → The file is a router-free component that does this. Changemd:contentsandmd:hiddento the app'smobileBreakpoint(lg:for ngrok's dashboards). - If a route suspends, keep the boundary inside
AppLayout.Body, around the page content. Delete a boundary that wraps the shell's<Outlet />with a whole-card fallback. - After editing, run the project's formatter, linter, type checker, and tests.
Why the route, not a context or a handle? A context push arrives in an effect, so the server HTML and the first frame have no actions, and the outgoing page's cleanup races the incoming page's push. A route handle is a static export, so an action there cannot read the page's state. A header rendered by the route is in the first paint, the server renders it, and an action reads the state beside it.
AppLayout.Body renders Main
The same release makes AppLayout.Body the main landmark by default. It renders <main id="main" tabIndex={-1}> with data-slot="app-layout-body main", and it no longer accepts id or tabIndex.
- In a shell that owns the document, replace
<AppLayout.Body asChild><Main>…</Main></AppLayout.Body>with<AppLayout.Body>…</AppLayout.Body>. Delete theMainimport when nothing else uses it. - In a shell embedded in a page that already has a
main(a docs demo, a test, a widget inside another layout), replace<AppLayout.Body>with<AppLayout.Body asChild><div>…</div></AppLayout.Body>. Move anidor atabIndexfrom the body onto the<div>. - Replace a selector that matches
[data-slot="app-layout-body"]exactly with[data-slot~="app-layout-body"].
Before → after
The shell
A page that pushes its actions from an effect
The action reads the same state as the page, with no URL round trip and no effect.
A page with no actions
Edge cases & gotchas
- A layout route with tab children. The layout renders the header and the body once, and its tab routes render only their content through the layout's
<Outlet />. Two headers on one URL is a bug. - A route that renders no
AppLayout.Headerships without the trigger and the trail. Compare the route list with the modules that render one. A leaf route or its layout renders it, never neither. - An action that is not a plain button (a
Dialog.Trigger, a routerLink, a toggle with its own state). Put it inAppLayout.HeaderActions. The slot takes any JSX. - Three actions at most. The header is one row, and the trail beside it needs the width more than a fourth button does. Move the rest into the page.
- A shell that must keep its header. Derive the actions from a route handle with Header Actions from Routes. That is the documented deviation, and an action there reads only the URL and loader data. Never mix the two in one app.
- CSS on the old wrappers. A selector such as
[data-slot="app-layout-header"] > divnow matches the slots. Target[data-slot="app-layout-header-content"]or[data-slot="app-layout-header-actions"]instead. - Tests that select the header's children by position. The slots add one element level. Select by role and name instead.
- Tests that render
AppLayout.Bodyinside a page that already has amain.getByRole("main")now finds two. PassasChildwith a<div>in the embedded one.
How to verify
- Type check.
- Compare the route list with the modules that render
<AppLayout.Header. Every URL renders exactly one. - Load a page with a long breadcrumb trail at a narrow width. The trail scrolls inside the header, and the actions stay at the row's end.
- View the server HTML of a page with actions. The header and the actions are in the first response.
- Press Tab on a fresh page load and take the skip link. Focus lands on the scrolling body, and PageDown scrolls it.
- Navigate between two pages with different actions. The header shows the new page's actions with no flash of the old ones.
- Suspend a load inside a page. The header stays on screen while the body shows the fallback.
- Narrow the viewport below the sidebar's mobile breakpoint. Icon buttons rendered through
PageActionsbecome one menu with the same actions. - Run the project's formatter, linter, type checker, and tests.