Advanced Routing
Callback routes, web URL synchronization, and browser history — the parts of
Routing you need when navigation drives an
already-rendered widget (a PageView, TabBarView, or IndexedStack) or when
your app runs on the web. Read the concept page first; this guide assumes you
have routes defined and the navigator key registered.
Callback Routes
Callback routes connect route navigation to an already-rendered multi-page
widget. They are the right tool when navigation should switch pages inside a
PageView, select a tab in a TabBarView, or update an IndexedStack index.
The callback receives:
| Argument | Description |
|---|---|
index |
The zero-based route position within the group. |
name |
The group-qualified route name, or null if the route has no name. |
params |
Merged path, query, and caller-provided parameters. |
Registering a Callback
Register the callback in the controller that owns the multi-page widget. The
group name must match the name attribute on <routeGroup>.
On web startup, XRouter may use this callback to apply the browser's initial
URL after the first frame has built. It is safe for the callback to call APIs
such as PageController.jumpToPage(...), TabController.animateTo(...), or
setState(...).
import 'package:flutter/material.dart';
import 'package:xwidget/xwidget.dart';
class AppController extends Controller {
final pageController = PageController();
@override
void init() {
XRouter.registerRouteCallback('main', (index, name, params) {
pageController.jumpToPage(index);
});
}
@override
void bindDependencies() {
dependencies.setValue('pageController', pageController);
}
@override
void dispose() {
XRouter.unregisterRouteCallback('main');
pageController.dispose();
super.dispose();
}
}
For an IndexedStack, store the index in controller state:
class AppController extends Controller {
int currentIndex = 0;
@override
void init() {
XRouter.registerRouteCallback('main', (index, name, params) {
setState(() => currentIndex = index);
});
}
@override
void bindDependencies() {
dependencies.setValue('currentIndex', currentIndex);
}
@override
void dispose() {
XRouter.unregisterRouteCallback('main');
super.dispose();
}
}
For a TabBarView, use the callback to select the tab:
class AppController extends Controller with SingleTickerProviderStateMixin {
late final tabController = TabController(length: 4, vsync: this);
@override
void init() {
XRouter.registerRouteCallback('main', (index, name, params) {
tabController.animateTo(index);
});
}
@override
void bindDependencies() {
dependencies.setValue('tabController', tabController);
}
@override
void dispose() {
XRouter.unregisterRouteCallback('main');
tabController.dispose();
super.dispose();
}
}
Always unregister the callback in dispose(). A stale callback can point at a
disposed controller.
Match Route Order to View Order
The order of <route> elements determines the index passed to the callback:
<routeGroup name="main" path="/">
<route path="/overview" fragment="overview" name="overview" /> <!-- index 0 -->
<route path="/renders" fragment="renders" name="renders" /> <!-- index 1 -->
<route path="/downloads" fragment="downloads" name="downloads" /> <!-- index 2 -->
<route path="/errors" fragment="errors" name="errors" /> <!-- index 3 -->
</routeGroup>
Keep this order aligned with the page order in your PageView, TabBarView,
or IndexedStack. If they differ, navigation resolves successfully but displays
the wrong page.
For nested route groups, the nested <routeGroup> consumes one index in the
parent group, and its own child routes start at index 0 inside the nested
group. Align both levels with their corresponding widgets.
Navigating to Callback Routes
Callers do not need to know whether a route is Navigator-backed or callback-backed:
<ListTile onTap="${routeTo('/renders')}">
<Text for="title" data="Renders" />
</ListTile>
<TextButton onPressed="${routeTo('/downloads')}">
<Text data="View Downloads" />
</TextButton>
XRouter resolves the route, sees the registered callback for the group, and calls that callback instead of pushing a new Flutter route.
Web URL Sync
On web, callback route groups can synchronize route changes with the browser
URL and History API. This is enabled automatically when you call
XRouter.registerRouteCallback(...).
When URL sync is active, XRouter also honors the browser's current URL on
startup. After the matching callback route group is registered and the first
Flutter frame has built, XRouter resolves the current browser path and query
string and routes the app to that destination without adding a duplicate
history entry. This allows direct links such as /analytics/renders?range=7d
to open on the correct callback-driven page.
Deep links into nested route groups are activated from the outside in. For a
route such as /analytics/renders, XRouter first switches the parent group to
the child view that hosts the nested analytics group, then waits for the nested
group's callback to register and selects the final child route.
If the initial URL resolves through a redirect, XRouter replaces the browser URL with the final route target instead of leaving the redirected-from URL in the history stack.
Flutter's built-in URL strategy also owns browser history, so it must be
disabled before runApp() if your app uses callback routes:
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:xwidget/xwidget.dart';
import 'xwidget/generated/registry.g.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
setUrlStrategy(null);
await XWidget.initialize(register: registerXWidgetComponents);
runApp(const MyApp());
}
If Flutter's URL strategy is still active when a route callback is registered,
XRouter throws a StateError that explains the conflict.
Apps that only use Navigator routes do not need this web setup. URL sync is activated by callback registration, and the non-web implementation is a no-op.
Browser History
The history attribute controls whether route navigation updates the browser
URL when web URL sync is active. It defaults to true and can be set on either
a group or a route:
<routeGroup name="main" path="/" history="true">
<route path="/overview" fragment="overview" name="overview" />
<route path="/drawer" fragment="drawer" name="drawer" history="false" />
</routeGroup>
A route with history="false" still navigates and still records analytics, but
it does not update the browser URL and does not create a browser history entry.
Use this for transient views that should not be revisited through the browser
back button.
On non-web platforms, and in web apps where URL sync has not been enabled,
history has no effect.
Presenter routes do not push browser history entries even when history is
true, because they are overlay presentations rather than managed XRouter
destinations.
When the browser back or forward button changes the URL, XRouter resolves the
new path and query string and calls goTo(...). For nested callback routes,
the same pending-route cascade is used, so navigation resumes when the required
callbacks are registered.
Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
Navigator not found |
The root app does not use navigatorKey="${navigatorKey()}". |
Assign XRouter.navigatorKey to MaterialApp.navigatorKey or CupertinoApp.navigatorKey. |
| A route logs a warning and does nothing | No registered route path, name, or alias matches the target. | Check the final grouped path and whether child route names are declared. |
| A redirect loops or throws | Redirects exceeded maxRedirects, or redirect routes point back to each other. |
Fix the redirect chain or raise maxRedirects only if the chain is intentionally longer. |
| A route says it needs a fragment | The route resolves after redirects but the final route does not define fragment. |
Add fragment to the final route or redirect to a route that has one. |
analytics:renders does not resolve |
The child route is missing name="renders". |
Add a child name or navigate by path. |
| A callback route opens a new page instead of switching tabs/pages | No callback is registered for the group at navigation time. | Register XRouter.registerRouteCallback(groupName, callback) in the owning controller. |
| A custom presenter route does nothing | The presenter name is not registered, or the root app does not have XRouter.navigatorKey. |
Register it with XRouter.registerPresenter(...) and assign the navigator key. |
| A deep link opens the default page instead of the URL's page | The browser URL does not match any route, the callback route group is not registered, or web URL sync is not active. | Confirm the route path, call setUrlStrategy(null) before runApp(), and register the callback for the matching <routeGroup>. |
| A nested deep link stops at the parent page | The nested route group's callback has not registered, or the parent page did not build the nested view. | Ensure the parent callback selects the child view that hosts the nested group, and register the nested callback from that view's controller. |
A PageController reports no attached positions during startup routing |
Startup navigation is being triggered before the first frame. | Use XRouter's built-in URL sync path; it defers initial browser URL application until after the first frame. |
Browser URL sync throws a StateError |
Flutter's URL strategy is active. | Call setUrlStrategy(null) before runApp(). |
Navigation lands on the wrong page in a PageView or TabBarView |
Route order and widget page order differ. | Reorder the <route> elements or the widget pages so their indexes match. |
pushAndRemoveUntil throws |
XRouter does not provide a removeUntil predicate. |
Use pushAndRemoveAll or call XRouter.navigateToFragment(...) directly with a predicate. |