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. 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); + }); }); }