Screens, layouts, and layout chains
A screen is one route's content. A layout is the chrome around it —
header, sidebar, footer. This page covers how layouts nest, what the
server renders for each level, and how the client swaps only the part
of the page that changes on navigation.
One layout
site := app.NewLayout("site"). WithHeader(header). WithFooter(footer)application.SetDefaultLayout(site)application.Register("/", &HomeScreen{}, nil) // uses the defaultapplication.Register("/tools", &ToolsScreen{}, tool) // explicit layout REPLACES the default
A screen with its own layout renders in that layout alone. Only screens
without one fall back to SetDefaultLayout, and only screens in a
ScreenGroup nest under it.
Nesting with ScreenGroup
ScreenGroup is the nesting API: a URL prefix plus a layout that wraps
every screen under it. Groups nest inside each other, and the whole
group nests under the app default layout.
docs := app.NewScreenGroup("/docs", app.NewLayout("docs").WithSidebar(docsNav))docs.Screen(app.NewScreen("intro", &IntroScreen{}), nil)guides := docs.SubGroup("guides", app.NewLayout("guides").WithSidebar(guideNav))guides.Screen(app.NewScreen("deploy", &DeployScreen{}), nil)application.Router.ScreenGroup(docs)
/docs/guides/deploy renders inside three layers: the app default
(outermost, owns the single <main id="main-content">), the docs
layer, and the guides layer. Rules that shape the chain:
- A group's screens inherit the group layout;
group.Screen(s, other)
replaces it for that one screen. The group boundary is kept, so the
route is still a sibling of the others for navigation purposes — but
its layer compares as different, and navigating to it re-renders the
shell (see keys below). SubGroup(prefix, nil)inherits the parent's layout. The inherited
layer renders once — the level still exists as an addressable marker,
it just adds no duplicate chrome.Standalone()on a group suppresses the app default layout for
everything under it. Use it when a feature ships its own full shell
(the admin back-office does this to avoid a sidebar inside a sidebar).- Drawer/sheet/dialog screens and layout-less pages have no chain.
What the server renders
Every layer gets three attributes:
| Attribute | Where | What it is |
|---|---|---|
data-fui-layout="<name>" | layer wrapper div | the layout's name; pairs with the .layout-<name> class for CSS |
data-fui-layout-key="<key>" | layer wrapper div | the layer's identity: l:<name> for a plain layout, g:<prefix>:<name> for a group layer |
data-fui-layout-slot="<key>" | the layer's content cell | the swap target: <main> at the root, a .layout-content div below it |
The route manifest carries each route's chain as the layouts array of
those keys, outermost first. A group layer's key embeds its layout
name, so a per-screen layout override inside a group compares as a
different layer than its siblings and gets its shell re-rendered on
navigation instead of silently keeping whichever was on screen.
What the client swaps
On navigation the runtime compares the target's chain against the
data-fui-layout-key spine in the DOM, position by position, and swaps
at the deepest layer the two share:
- Sibling inside a group → only the innermost content cell changes.
The sidebar keeps its DOM nodes, scroll, and open disclosures. - Different branch under a shared root → the shared chrome stays; the
diverging layers re-render. - Nothing shared → full page fetch, whole shell replaced. Still no
hard reload.
The partial request names the origin route in X-Gofastr-From; the
server renders only the layers the two routes do not share and echoes
the boundary in X-Gofastr-Swap. Shared chrome is never re-sent. A
boundary the DOM doesn't have (a deploy changed the chains mid-session)
falls back to a full-page load.
Prefetch
A route can declare that the client may fetch its content before the
user clicks:
application.Register("/pricing", &PricingScreen{}, nil, app.Preload(app.PreloadHover))PreloadHover fetches on link hover or keyboard focus, PreloadVisible
when a link to the route scrolls into view, PreloadEager at idle
after page load. The prefetched response lives in a small 30-second
cache the router checks before fetching; ui.InvalidateScreens
selectors evict it like the screen cache. Prefetch requests carry
X-Gofastr-Prefetch: 1, skip session side effects, and are never used
for routes that would open as overlays.
Scroll and history
The runtime restores scroll position per history entry: Back and
Forward land where the user left, and a reload keeps its position. A
history move whose only URL change is in-page state (a pane or widget
deep-link parameter) closes or reopens that state without refetching
the screen. Search and pagination parameters are screen identity and
refetch as before.
Modules that write in-page state into the URL must go through
__gofastr._pushURL(url) — a raw history.pushState leaves the router
unaware of the change and breaks both behaviors above.
Common mistakes
- Giving a screen its own layout and expecting the default around
it. An explicit layout onRegisterreplaces the default; only
ScreenGroupscreens nest under it. To add a section sidebar inside
the site shell, put the screen in a group. - Reusing one layout name for two different layouts. Layer keys
embed the name; two distinct*Layoutvalues named"docs"at the
same depth compare as the same layer, and navigation between them
keeps the wrong shell. Names should be unique per shape. - Expecting
Standalone()per screen. It is a group property: the
whole group opts out of the default layout, not one route. - Prefetching mutating or per-user-expensive routes. Prefetch is a
real render on the server (policy,Load, DB reads) for a page the
user may never open. Declare it on cheap, frequently-next routes;
leave heavy dashboards to the click. - Writing
history.pushStatedirectly in custom client code. Use
__gofastr._pushURL(url). A raw push leavescurrentPathstale, so
the next Back either refetches an in-page state change or skips a
real navigation, and scroll restoration loses the entry.