From 9446cba2e80b8b9f3d8ba8857a6b0084c424c316 Mon Sep 17 00:00:00 2001 From: Chibeiyu Date: Mon, 25 May 2026 16:11:31 +0800 Subject: [PATCH 1/3] [go_router] Add route metadata support to GoRouterState. --- packages/go_router/lib/src/builder.dart | 1 + packages/go_router/lib/src/configuration.dart | 1 + packages/go_router/lib/src/match.dart | 100 +++++++++++++++++- packages/go_router/lib/src/parser.dart | 1 + packages/go_router/lib/src/route.dart | 10 ++ packages/go_router/lib/src/state.dart | 17 ++- .../go_router/test/go_router_state_test.dart | 71 +++++++++++++ 7 files changed, 199 insertions(+), 2 deletions(-) diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 4eb036ea190b..012e51a7cda9 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -415,6 +415,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { error: matchList.error, pageKey: ValueKey('${matchList.uri}(error)'), topRoute: matchList.lastOrNull?.route, + metadata: matchList.topRouteMetadata, ); } diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 15c159af5506..60ce9f8551b2 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -238,6 +238,7 @@ class RouteConfiguration { pageKey: const ValueKey('topLevel'), topRoute: matchList.lastOrNull?.route, error: matchList.error, + metadata: matchList.topRouteMetadata, ); } diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 87f588a3eeef..f0d3ab20500b 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -354,6 +354,7 @@ class RouteMatch extends RouteMatchBase { path: route.path, extra: matches.extra, topRoute: matches.lastOrNull?.route, + metadata: matches.routeMetadataFor(this), ); } } @@ -400,6 +401,7 @@ class ShellRouteMatch extends RouteMatchBase { RouteConfiguration configuration, RouteMatchList matches, ) { + final Map metadata = matches.routeMetadataFor(this); // The route related data is stored in the leaf route match. final RouteMatch leafMatch = _lastLeaf; if (leafMatch is ImperativeRouteMatch) { @@ -414,6 +416,7 @@ class ShellRouteMatch extends RouteMatchBase { pageKey: pageKey, extra: matches.extra, topRoute: matches.lastOrNull?.route, + metadata: metadata, ); } @@ -494,7 +497,19 @@ class ImperativeRouteMatch extends RouteMatch { RouteConfiguration configuration, RouteMatchList matches, ) { - return super.buildState(configuration, this.matches); + return GoRouterState( + configuration, + uri: this.matches.uri, + matchedLocation: matchedLocation, + fullPath: this.matches.fullPath, + pathParameters: this.matches.pathParameters, + pageKey: pageKey, + name: route.name, + path: route.path, + extra: this.matches.extra, + topRoute: this.matches.lastOrNull?.route, + metadata: this.matches.topRouteMetadata, + ); } @override @@ -813,6 +828,89 @@ class RouteMatchList with Diagnosticable { return result; } + /// Returns merged metadata for the provided [target] route match. + /// + /// Metadata is inherited from parent routes and overridden by child routes. + /// Returns an empty map when no metadata can be found. + @meta.internal + Map routeMetadataFor(RouteMatchBase target) { + return _metadataForRouteMatch( + matches: matches, + target: target, + inheritedMetadata: const {}, + ) ?? + const {}; + } + + /// Returns merged metadata for the currently matched top route. + /// + /// For imperative matches, this resolves metadata from the imperative match's + /// own match list. + @meta.internal + Map get topRouteMetadata { + final RouteMatchBase? target = _lastRouteMatchOrNull(matches); + if (target == null) { + return const {}; + } + if (target is ImperativeRouteMatch) { + return target.matches.topRouteMetadata; + } + return routeMetadataFor(target); + } + + static RouteMatchBase? _lastRouteMatchOrNull(List matches) { + if (matches.isEmpty) { + return null; + } + RouteMatchBase current = matches.last; + while (current is ShellRouteMatch && current.matches.isNotEmpty) { + current = current.matches.last; + } + return current; + } + + static Map? _metadataForRouteMatch({ + required List matches, + required RouteMatchBase target, + required Map inheritedMetadata, + }) { + var currentInheritedMetadata = inheritedMetadata; + for (final match in matches) { + final Map currentMetadata = _mergeMetadata( + currentInheritedMetadata, + match.route.metadata, + ); + if (identical(match, target)) { + return currentMetadata; + } + if (match is ShellRouteMatch) { + final Map? childMetadata = _metadataForRouteMatch( + matches: match.matches, + target: target, + inheritedMetadata: currentMetadata, + ); + if (childMetadata != null) { + return childMetadata; + } + } + currentInheritedMetadata = currentMetadata; + } + return null; + } + + static Map _mergeMetadata( + Map parentMetadata, + Map? currentMetadata, + ) { + if (currentMetadata == null || currentMetadata.isEmpty) { + return parentMetadata; + } + if (parentMetadata.isEmpty) { + return Map.of(currentMetadata); + } + return {...parentMetadata, ...currentMetadata}; + } + /// Traverse route matches in this match list in preorder until visitor /// returns false. /// diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 5b91b0e81ff9..f5fe991203e1 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -595,6 +595,7 @@ class _OnEnterHandler { pageKey: const ValueKey('topLevel'), topRoute: matchList.lastOrNull?.route, error: matchList.error, + metadata: matchList.topRouteMetadata, ); } diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index cf1a7e240827..626daba8cc7c 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -160,6 +160,7 @@ typedef ExitCallback = abstract class RouteBase with Diagnosticable { const RouteBase._({ this.redirect, + this.metadata, required this.routes, required this.parentNavigatorKey, }); @@ -230,6 +231,11 @@ abstract class RouteBase with Diagnosticable { /// Navigator instead of the nearest ShellRoute ancestor. final GlobalKey? parentNavigatorKey; + /// Metadata associated with the current route. + /// + /// Metadata is inherited from parent routes and overridden by child routes. + final Map? metadata; + /// Builds a lists containing the provided routes along with all their /// descendant [routes]. static Iterable routesRecursively(Iterable routes) { @@ -279,6 +285,7 @@ class GoRoute extends RouteBase { this.pageBuilder, super.parentNavigatorKey, super.redirect, + super.metadata, this.onExit, this.caseSensitive = true, super.routes = const [], @@ -498,6 +505,7 @@ abstract class ShellRouteBase extends RouteBase { super.redirect, required super.routes, required super.parentNavigatorKey, + super.metadata, this.notifyRootObserver = true, }) : super._(); @@ -715,6 +723,7 @@ class ShellRoute extends ShellRouteBase { this.pageBuilder, super.notifyRootObserver, this.observers, + super.metadata, required super.routes, super.parentNavigatorKey, GlobalKey? navigatorKey, @@ -900,6 +909,7 @@ class StatefulShellRoute extends ShellRouteBase { super.notifyRootObserver, required this.navigatorContainerBuilder, super.parentNavigatorKey, + super.metadata, this.restorationScopeId, GlobalKey? key, }) : assert(branches.isNotEmpty), diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart index 80554631915e..280faa34e226 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -27,6 +28,7 @@ class GoRouterState { this.error, required this.pageKey, this.topRoute, + this.metadata = const {}, }); final RouteConfiguration _configuration; @@ -83,6 +85,12 @@ class GoRouterState { /// associated GoRouterState to be uniquely identified using [GoRoute.name] final GoRoute? topRoute; + /// Metadata associated with the current route. + /// + /// Metadata is inherited from parent routes and overridden by child routes. + /// This map is never null. + final Map metadata; + /// Gets the [GoRouterState] from context. /// /// The returned [GoRouterState] will depends on which [GoRoute] or @@ -182,7 +190,8 @@ class GoRouterState { other.pathParameters == pathParameters && other.extra == extra && other.error == error && - other.pageKey == pageKey; + other.pageKey == pageKey && + const MapEquality().equals(other.metadata, metadata); } @override @@ -196,6 +205,12 @@ class GoRouterState { extra, error, pageKey, + Object.hashAllUnordered( + metadata.entries.map( + (MapEntry entry) => + Object.hash(entry.key, entry.value), + ), + ), ); } diff --git a/packages/go_router/test/go_router_state_test.dart b/packages/go_router/test/go_router_state_test.dart index 2624084dea1b..401e0271796d 100644 --- a/packages/go_router/test/go_router_state_test.dart +++ b/packages/go_router/test/go_router_state_test.dart @@ -351,5 +351,76 @@ void main() { await tester.pumpAndSettle(); expect(find.text('B'), findsOneWidget); }); + + testWidgets('metadata inherits, overrides, and defaults to empty map', ( + WidgetTester tester, + ) async { + GoRouterState? inheritedState; + GoRouterState? overriddenState; + GoRouterState? emptyState; + + final routes = [ + GoRoute( + path: '/', + metadata: const { + 'fromParent': 'yes', + 'shared': 'parent', + }, + builder: (_, __) => const SizedBox.shrink(), + routes: [ + GoRoute( + path: 'inherit', + builder: (BuildContext context, GoRouterState state) { + inheritedState = state; + return const Text('inherit'); + }, + ), + GoRoute( + path: 'override', + metadata: const { + 'shared': 'child', + 'childOnly': true, + }, + builder: (BuildContext context, GoRouterState state) { + overriddenState = state; + return const Text('override'); + }, + ), + ], + ), + GoRoute( + path: '/empty', + builder: (BuildContext context, GoRouterState state) { + emptyState = state; + return const Text('empty'); + }, + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + + router.go('/inherit'); + await tester.pumpAndSettle(); + expect(inheritedState, isNotNull); + expect(inheritedState!.metadata, const { + 'fromParent': 'yes', + 'shared': 'parent', + }); + + router.go('/override'); + await tester.pumpAndSettle(); + expect(overriddenState, isNotNull); + expect(overriddenState!.metadata, const { + 'fromParent': 'yes', + 'shared': 'child', + 'childOnly': true, + }); + + router.go('/empty'); + await tester.pumpAndSettle(); + expect(emptyState, isNotNull); + expect(emptyState!.metadata, isEmpty); + expect(emptyState!.metadata, isNotNull); + }); }); } From 1cd4eaf2863d35ece7f100ceb44f9cbd6c5363e6 Mon Sep 17 00:00:00 2001 From: Chibeiyu Date: Wed, 27 May 2026 12:06:51 +0800 Subject: [PATCH 2/3] [go_router] Update changelog for route metadata support --- packages/go_router/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 9aad45c2da0c..b4d140c951f8 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 17.2.4 + +- Adds route `metadata` support, including inheritance/override behavior and exposure on `GoRouterState`. + ## 17.2.3 - Fixes an assertion failure when navigating to URLs with hash fragments missing a leading slash. From b620eb7da144a95e9f1437fb264d69999a520e24 Mon Sep 17 00:00:00 2001 From: Chibeiyu Date: Fri, 29 May 2026 08:43:41 +0800 Subject: [PATCH 3/3] [go_router] Address route metadata review feedback --- packages/go_router/CHANGELOG.md | 2 +- .../go_router/example/lib/route_metadata.dart | 98 +++++++++++++++++++ packages/go_router/lib/src/match.dart | 13 +-- packages/go_router/lib/src/route.dart | 10 +- packages/go_router/lib/src/state.dart | 9 +- .../pending_changelogs/route_metadata.yaml | 3 + .../go_router/test/go_router_state_test.dart | 45 +++++++++ 7 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 packages/go_router/example/lib/route_metadata.dart create mode 100644 packages/go_router/pending_changelogs/route_metadata.yaml diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index b4d140c951f8..28f583810dbe 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,4 +1,4 @@ -## 17.2.4 +## 17.3.0 - Adds route `metadata` support, including inheritance/override behavior and exposure on `GoRouterState`. diff --git a/packages/go_router/example/lib/route_metadata.dart b/packages/go_router/example/lib/route_metadata.dart new file mode 100644 index 000000000000..249fef333b3d --- /dev/null +++ b/packages/go_router/example/lib/route_metadata.dart @@ -0,0 +1,98 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +void main() => runApp(const RouteMetadataApp()); + +/// An example that displays metadata from the matched route. +class RouteMetadataApp extends StatelessWidget { + /// Creates a [RouteMetadataApp]. + const RouteMetadataApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router(routerConfig: _router); + } +} + +final GoRouter _router = GoRouter( + initialLocation: '/books', + routes: [ + GoRoute( + path: '/', + redirect: (BuildContext context, GoRouterState state) => '/books', + ), + GoRoute( + path: '/books', + metadata: const { + 'section': 'Library', + 'requiresAuth': true, + 'analyticsName': 'books', + }, + builder: (BuildContext context, GoRouterState state) { + return MetadataScreen(state: state); + }, + routes: [ + GoRoute( + path: 'preview', + metadata: const { + 'requiresAuth': false, + 'analyticsName': 'book-preview', + }, + builder: (BuildContext context, GoRouterState state) { + return MetadataScreen(state: state); + }, + ), + ], + ), + ], +); + +/// Displays the current route metadata. +class MetadataScreen extends StatelessWidget { + /// Creates a [MetadataScreen]. + const MetadataScreen({required this.state, super.key}); + + /// The current route state. + final GoRouterState state; + + @override + Widget build(BuildContext context) { + final title = state.metadata['analyticsName'] as String; + final requiresAuth = state.metadata['requiresAuth'] as bool; + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ListTile( + title: const Text('section'), + subtitle: Text(state.metadata['section'] as String), + ), + ListTile( + title: const Text('requiresAuth'), + subtitle: Text(requiresAuth.toString()), + ), + ListTile( + title: const Text('analyticsName'), + subtitle: Text(title), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.go('/books'), + child: const Text('Books'), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () => context.go('/books/preview'), + child: const Text('Preview'), + ), + ], + ), + ); + } +} diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index f0d3ab20500b..e0f25295d574 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -354,7 +354,7 @@ class RouteMatch extends RouteMatchBase { path: route.path, extra: matches.extra, topRoute: matches.lastOrNull?.route, - metadata: matches.routeMetadataFor(this), + metadata: matches._routeMetadataFor(this), ); } } @@ -401,7 +401,7 @@ class ShellRouteMatch extends RouteMatchBase { RouteConfiguration configuration, RouteMatchList matches, ) { - final Map metadata = matches.routeMetadataFor(this); + final Map metadata = matches._routeMetadataFor(this); // The route related data is stored in the leaf route match. final RouteMatch leafMatch = _lastLeaf; if (leafMatch is ImperativeRouteMatch) { @@ -828,12 +828,7 @@ class RouteMatchList with Diagnosticable { return result; } - /// Returns merged metadata for the provided [target] route match. - /// - /// Metadata is inherited from parent routes and overridden by child routes. - /// Returns an empty map when no metadata can be found. - @meta.internal - Map routeMetadataFor(RouteMatchBase target) { + Map _routeMetadataFor(RouteMatchBase target) { return _metadataForRouteMatch( matches: matches, target: target, @@ -855,7 +850,7 @@ class RouteMatchList with Diagnosticable { if (target is ImperativeRouteMatch) { return target.matches.topRouteMetadata; } - return routeMetadataFor(target); + return _routeMetadataFor(target); } static RouteMatchBase? _lastRouteMatchOrNull(List matches) { diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 626daba8cc7c..36aca15a5751 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -231,9 +231,15 @@ abstract class RouteBase with Diagnosticable { /// Navigator instead of the nearest ShellRoute ancestor. final GlobalKey? parentNavigatorKey; - /// Metadata associated with the current route. + /// Application-defined metadata associated with this route. /// - /// Metadata is inherited from parent routes and overridden by child routes. + /// This can be used to attach static information that is useful while + /// building the matched page, such as analytics labels, page titles, + /// permissions, or feature flags. The merged metadata for the current match + /// is available from [GoRouterState.metadata]. + /// + /// Metadata is inherited from parent routes and child route values override + /// parent values with the same key. final Map? metadata; /// Builds a lists containing the provided routes along with all their diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart index 280faa34e226..995b24634a4c 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -85,10 +85,13 @@ class GoRouterState { /// associated GoRouterState to be uniquely identified using [GoRoute.name] final GoRoute? topRoute; - /// Metadata associated with the current route. + /// Merged application-defined metadata for the current route match. /// - /// Metadata is inherited from parent routes and overridden by child routes. - /// This map is never null. + /// Metadata is inherited from parent routes, and child routes override + /// parent values with the same key. For example, if a parent route has + /// `{'section': 'library', 'requiresAuth': true}` and the matched child route + /// has `{'requiresAuth': false, 'title': 'Preview'}`, this value is + /// `{'section': 'library', 'requiresAuth': false, 'title': 'Preview'}`. final Map metadata; /// Gets the [GoRouterState] from context. diff --git a/packages/go_router/pending_changelogs/route_metadata.yaml b/packages/go_router/pending_changelogs/route_metadata.yaml new file mode 100644 index 000000000000..4d647006bf48 --- /dev/null +++ b/packages/go_router/pending_changelogs/route_metadata.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds route `metadata` support, including inheritance and override behavior with exposure on `GoRouterState`. +version: minor diff --git a/packages/go_router/test/go_router_state_test.dart b/packages/go_router/test/go_router_state_test.dart index 401e0271796d..cbdaed0ffc27 100644 --- a/packages/go_router/test/go_router_state_test.dart +++ b/packages/go_router/test/go_router_state_test.dart @@ -422,5 +422,50 @@ void main() { expect(emptyState!.metadata, isEmpty); expect(emptyState!.metadata, isNotNull); }); + + testWidgets('metadata is available after imperative push', ( + WidgetTester tester, + ) async { + GoRouterState? pushedState; + + final routes = [ + GoRoute( + path: '/', + metadata: const { + 'fromParent': true, + 'presentation': 'base', + }, + builder: (BuildContext context, GoRouterState state) { + return const Text('home'); + }, + routes: [ + GoRoute( + path: 'push', + metadata: const { + 'presentation': 'pushed', + 'fromChild': true, + }, + builder: (BuildContext context, GoRouterState state) { + pushedState = state; + return const Text('push'); + }, + ), + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + expect(find.text('home'), findsOneWidget); + + router.push('/push'); + await tester.pumpAndSettle(); + + expect(pushedState, isNotNull); + expect(pushedState!.metadata, const { + 'fromParent': true, + 'presentation': 'pushed', + 'fromChild': true, + }); + }); }); }