diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 9aad45c2da0c..28f583810dbe 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 17.3.0 + +- 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/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/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..e0f25295d574 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,84 @@ class RouteMatchList with Diagnosticable { return result; } + 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..36aca15a5751 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,17 @@ abstract class RouteBase with Diagnosticable { /// Navigator instead of the nearest ShellRoute ancestor. final GlobalKey? parentNavigatorKey; + /// Application-defined metadata associated with this route. + /// + /// 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 /// descendant [routes]. static Iterable routesRecursively(Iterable routes) { @@ -279,6 +291,7 @@ class GoRoute extends RouteBase { this.pageBuilder, super.parentNavigatorKey, super.redirect, + super.metadata, this.onExit, this.caseSensitive = true, super.routes = const [], @@ -498,6 +511,7 @@ abstract class ShellRouteBase extends RouteBase { super.redirect, required super.routes, required super.parentNavigatorKey, + super.metadata, this.notifyRootObserver = true, }) : super._(); @@ -715,6 +729,7 @@ class ShellRoute extends ShellRouteBase { this.pageBuilder, super.notifyRootObserver, this.observers, + super.metadata, required super.routes, super.parentNavigatorKey, GlobalKey? navigatorKey, @@ -900,6 +915,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..995b24634a4c 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,15 @@ class GoRouterState { /// associated GoRouterState to be uniquely identified using [GoRoute.name] final GoRoute? topRoute; + /// Merged application-defined metadata for the current route match. + /// + /// 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. /// /// The returned [GoRouterState] will depends on which [GoRoute] or @@ -182,7 +193,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 +208,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/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 2624084dea1b..cbdaed0ffc27 100644 --- a/packages/go_router/test/go_router_state_test.dart +++ b/packages/go_router/test/go_router_state_test.dart @@ -351,5 +351,121 @@ 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); + }); + + 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, + }); + }); }); }