ZeroZ Stack maps real URLs to Java views, and each route declares the data it needs alongside the path it answers to. The loading happens before the view is built, so a view never exists in a half-loaded state and never fetches from inside a component that is already on screen.
Routes are found at compile time by the annotation processor and turned into a route table. Nothing is discovered by reflection, because the browser runtime has none — which also means a route that does not compile is not a route.
@Route("/tasks/:id")
public class TaskDetailView implements RouteView<Task> {
private final TaskService tasks = TaskService_Stub.create();
@Override
public Task load(RouteParams params) {
return tasks.byId(params.getLong("id")); // reads as blocking; suspends the coroutine
}
@Override
public Component render(Task task, RouteParams params) {
return new Div(new Span(task.getTitle()), new Span(task.getDetail()));
}
}load runs and completes first; render receives what it returned. A view that needs no data
implements RouteView<Void> and leaves load alone.
Start the router once, pointing at the element it owns:
Zeroz4jClient.connect(wsUrl, () -> Router.start("app-root"));Real paths through the history API — /tasks/42, not #/tasks/42. A segment beginning with : is a
parameter.
@Route("/") // the landing view
@Route("/tasks") // literal
@Route("/tasks/:id") // one parameter
@Route("/teams/:team/tasks/:id")Matching prefers the more specific pattern, so /tasks/new wins over /tasks/:id whatever order the
compiler emitted them in. Two routes claiming the same path is a startup error rather than a race
decided by build order.
Read parameters from RouteParams:
params.get("id") // "42"
params.getLong("id") // 42 — throws if absent or not a number
params.query("page") // from ?page=2
params.queryLong("page", 1) // falls back instead of throwingThe difference is deliberate. A path parameter that will not parse means a broken link, so it throws. A query parameter is usually a user-adjustable option, so it falls back.
Because the paths are real, the browser asks the server for them. Opening /projects/42 from a
bookmark, reloading it, or sharing the link is an HTTP request for a path with no file behind it —
so the server has to answer with the application shell and let the router resolve it once the page
loads. StaticContentResource does this: an unmatched path that does not look like a file falls back
to index.html.
A missing asset still returns 404. Serving the shell for /js/classes.js would hand the browser a
page where it expected a script, and the failure would surface as an unreadable syntax error rather
than a missing file. The last path segment containing a dot is what tells them apart.
Chrome shared across routes — a navigation bar, a sidebar — is a RouteLayout, and children name it:
@Route("/")
public class AppShell implements RouteLayout<User> {
@Override
public User load(RouteParams params) { return users.current(); }
@Override
public Component render(User user, RouteParams params, Component child) {
return new Div(
new Div(new Span(user.getName())), // the chrome
child); // where the matched route goes
}
}@Route(value = "/tasks/:id", layout = AppShell.class)
public class TaskDetailView implements RouteView<Task> { ... }Layouts nest: a layout may declare a layout of its own, and the chain is built outward from the
matched route. Loading the current user once in the shell is what stops every view underneath
fetching it separately.
- The path is matched, most specific pattern first.
- The layout chain is resolved outward from the matched route.
- Every
@RequiresRolein that chain is checked. - Each level's
loadruns, outermost first, so a nested route can rely on what its layout fetched. - Only then are components built, innermost first, each layout wrapping its child.
- The container's contents are replaced in one go.
Nothing reaches the screen until every loader has returned.
Client code runs on a single cooperative scheduler and cannot create threads, so a layout's loader and its child's cannot overlap — two round trips are two round trips. The win here is the ordering guarantee, not concurrency: data is fetched before rendering rather than from inside a mounted component, and shared data is fetched once in a layout instead of repeatedly in its children. If you need one round trip, make it one service call.
Router.navigate("/tasks/42"); // adds a history entry
Router.replace("/login"); // replaces it — for a redirect Back should not re-enterIn markup, add data-route to an anchor and the router takes it over:
<a data-route href="/tasks/42">Open task</a>Only anchors carrying that attribute are intercepted — taking over every link would swallow links to other sites and to downloads. Modified clicks (new tab, new window) are always left to the browser.
A WAR is usually deployed under a context path — /coachapp, /clientportal — and then the browser
shows /coachapp/tasks/42 for the route /tasks/42. Route paths never change. @Route declares
/tasks/:id, Router.navigate("/tasks/42") takes that, and RouteParams.getPath() reports it; the
router translates to and from browser locations through AppBase, which reads the application's root
from document.baseURI.
That works because the server serves the shell with a <base href> for its own context path —
StaticContent does this for both bindings, so no application configures it and nothing has to be
rebuilt to move a deployment. It is also what makes a deep link's relative asset references resolve:
js/classes.js in a shell served for /coachapp/tasks/42 means /coachapp/js/classes.js, not
/coachapp/tasks/js/classes.js.
Two things an application still writes for itself, and both have a helper:
Zeroz4jClient.connect(Zeroz4jClient.defaultWebSocketUrl(), () -> Router.start("app-root"));
anchor.setAttribute("href", AppBase.location("/tasks/42")); // /coachapp/tasks/42An href has to carry the context path, because middle-click and "open in new tab" go to the server
rather than through the router. The router accepts either form on the way back in, so a click on such
an anchor still resolves to the route /tasks/42.
Write relative references in index.html (js/classes.js, manifest.webmanifest), not absolute
ones. An absolute /js/classes.js ignores the base element and escapes the context path.
@Route(value = "/admin", layout = AppShell.class)
@RequiresRole("admin")
public class AdminView implements RouteView<Void> { ... }Checked against RmiSecurityContext, which is populated from the server at connect. Every layout in
the chain is checked too, so a guarded shell protects everything inside it.
This check decides nothing on its own. A client-side check decides what to show; the server
checks every RMI call again against @Secured and @RolesAllowed, and that is the check that
counts. Skipping the annotation only means the user reaches a view whose calls then fail.
Router.notFoundRoute("/not-found");
Router.forbiddenRoute("/login");
Router.onError((path, reason) -> toast.show("Could not open " + path));A navigation whose loader throws leaves the page as it was and reports through the error handler — replacing a working view with a blank one because a fetch failed loses whatever the user was doing. Without a handler the failure is logged to the console rather than vanishing.
The processor refuses, at compile time:
- a
@Routeclass implementing neitherRouteViewnorRouteLayout - a
@Routeclass implementing both, where whether it renders a child would be ambiguous - a
@Routeclass without a public no-argument constructor — the router builds it without reflection - a path not starting with
/
- No parallel loaders, as above.
- No nested outlets beyond one child per layout. A layout renders exactly one child; sibling outlets are not modelled.
- No wildcard or optional segments. Patterns are literal segments and
:paramswith a fixed count;/files/*pathis not supported. - No route-level transitions or scroll restoration. The container's contents are replaced outright.
- The whole view is rebuilt on every navigation, including a layout that did not change. Layout loaders therefore re-run when navigating between two children of the same layout.
- No lazy loading. Everything is in one bundle; a route does not defer any code.