Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/go_router/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
error: matchList.error,
pageKey: ValueKey<String>('${matchList.uri}(error)'),
topRoute: matchList.lastOrNull?.route,
metadata: matchList.topRouteMetadata,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/go_router/lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ class RouteConfiguration {
pageKey: const ValueKey<String>('topLevel'),
topRoute: matchList.lastOrNull?.route,
error: matchList.error,
metadata: matchList.topRouteMetadata,
);
}

Expand Down
100 changes: 99 additions & 1 deletion packages/go_router/lib/src/match.dart
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ class RouteMatch extends RouteMatchBase {
path: route.path,
extra: matches.extra,
topRoute: matches.lastOrNull?.route,
metadata: matches.routeMetadataFor(this),
);
}
}
Expand Down Expand Up @@ -400,6 +401,7 @@ class ShellRouteMatch extends RouteMatchBase {
RouteConfiguration configuration,
RouteMatchList matches,
) {
final Map<String, dynamic> metadata = matches.routeMetadataFor(this);
// The route related data is stored in the leaf route match.
final RouteMatch leafMatch = _lastLeaf;
if (leafMatch is ImperativeRouteMatch) {
Expand All @@ -414,6 +416,7 @@ class ShellRouteMatch extends RouteMatchBase {
pageKey: pageKey,
extra: matches.extra,
topRoute: matches.lastOrNull?.route,
metadata: metadata,
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, dynamic> routeMetadataFor(RouteMatchBase target) {
return _metadataForRouteMatch(
matches: matches,
target: target,
inheritedMetadata: const <String, dynamic>{},
) ??
const <String, dynamic>{};
}

/// 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<String, dynamic> get topRouteMetadata {
final RouteMatchBase? target = _lastRouteMatchOrNull(matches);
if (target == null) {
return const <String, dynamic>{};
}
if (target is ImperativeRouteMatch) {
return target.matches.topRouteMetadata;
}
return routeMetadataFor(target);
}

static RouteMatchBase? _lastRouteMatchOrNull(List<RouteMatchBase> 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<String, dynamic>? _metadataForRouteMatch({
required List<RouteMatchBase> matches,
required RouteMatchBase target,
required Map<String, dynamic> inheritedMetadata,
}) {
var currentInheritedMetadata = inheritedMetadata;
for (final match in matches) {
final Map<String, dynamic> currentMetadata = _mergeMetadata(
currentInheritedMetadata,
match.route.metadata,
);
if (identical(match, target)) {
return currentMetadata;
}
if (match is ShellRouteMatch) {
final Map<String, dynamic>? childMetadata = _metadataForRouteMatch(
matches: match.matches,
target: target,
inheritedMetadata: currentMetadata,
);
if (childMetadata != null) {
return childMetadata;
}
}
currentInheritedMetadata = currentMetadata;
}
return null;
}

static Map<String, dynamic> _mergeMetadata(
Map<String, dynamic> parentMetadata,
Map<String, dynamic>? currentMetadata,
) {
if (currentMetadata == null || currentMetadata.isEmpty) {
return parentMetadata;
}
if (parentMetadata.isEmpty) {
return Map<String, dynamic>.of(currentMetadata);
}
return <String, dynamic>{...parentMetadata, ...currentMetadata};
}

/// Traverse route matches in this match list in preorder until visitor
/// returns false.
///
Expand Down
1 change: 1 addition & 0 deletions packages/go_router/lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ class _OnEnterHandler {
pageKey: const ValueKey<String>('topLevel'),
topRoute: matchList.lastOrNull?.route,
error: matchList.error,
metadata: matchList.topRouteMetadata,
);
}

Expand Down
10 changes: 10 additions & 0 deletions packages/go_router/lib/src/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ typedef ExitCallback =
abstract class RouteBase with Diagnosticable {
const RouteBase._({
this.redirect,
this.metadata,
required this.routes,
required this.parentNavigatorKey,
});
Expand Down Expand Up @@ -230,6 +231,11 @@ abstract class RouteBase with Diagnosticable {
/// Navigator instead of the nearest ShellRoute ancestor.
final GlobalKey<NavigatorState>? parentNavigatorKey;

/// Metadata associated with the current route.
///
/// Metadata is inherited from parent routes and overridden by child routes.
final Map<String, dynamic>? metadata;

/// Builds a lists containing the provided routes along with all their
/// descendant [routes].
static Iterable<RouteBase> routesRecursively(Iterable<RouteBase> routes) {
Expand Down Expand Up @@ -279,6 +285,7 @@ class GoRoute extends RouteBase {
this.pageBuilder,
super.parentNavigatorKey,
super.redirect,
super.metadata,
this.onExit,
this.caseSensitive = true,
super.routes = const <RouteBase>[],
Expand Down Expand Up @@ -498,6 +505,7 @@ abstract class ShellRouteBase extends RouteBase {
super.redirect,
required super.routes,
required super.parentNavigatorKey,
super.metadata,
this.notifyRootObserver = true,
}) : super._();

Expand Down Expand Up @@ -715,6 +723,7 @@ class ShellRoute extends ShellRouteBase {
this.pageBuilder,
super.notifyRootObserver,
this.observers,
super.metadata,
required super.routes,
super.parentNavigatorKey,
GlobalKey<NavigatorState>? navigatorKey,
Expand Down Expand Up @@ -900,6 +909,7 @@ class StatefulShellRoute extends ShellRouteBase {
super.notifyRootObserver,
required this.navigatorContainerBuilder,
super.parentNavigatorKey,
super.metadata,
this.restorationScopeId,
GlobalKey<StatefulNavigationShellState>? key,
}) : assert(branches.isNotEmpty),
Expand Down
17 changes: 16 additions & 1 deletion packages/go_router/lib/src/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +28,7 @@ class GoRouterState {
this.error,
required this.pageKey,
this.topRoute,
this.metadata = const <String, dynamic>{},
});
final RouteConfiguration _configuration;

Expand Down Expand Up @@ -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<String, dynamic> metadata;

/// Gets the [GoRouterState] from context.
///
/// The returned [GoRouterState] will depends on which [GoRoute] or
Expand Down Expand Up @@ -182,7 +190,8 @@ class GoRouterState {
other.pathParameters == pathParameters &&
other.extra == extra &&
other.error == error &&
other.pageKey == pageKey;
other.pageKey == pageKey &&
const MapEquality<String, dynamic>().equals(other.metadata, metadata);
}

@override
Expand All @@ -196,6 +205,12 @@ class GoRouterState {
extra,
error,
pageKey,
Object.hashAllUnordered(
metadata.entries.map<int>(
(MapEntry<String, dynamic> entry) =>
Object.hash(entry.key, entry.value),
),
),
);
}

Expand Down
71 changes: 71 additions & 0 deletions packages/go_router/test/go_router_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <RouteBase>[
GoRoute(
path: '/',
metadata: const <String, dynamic>{
'fromParent': 'yes',
'shared': 'parent',
},
builder: (_, __) => const SizedBox.shrink(),
routes: <RouteBase>[
GoRoute(
path: 'inherit',
builder: (BuildContext context, GoRouterState state) {
inheritedState = state;
return const Text('inherit');
},
),
GoRoute(
path: 'override',
metadata: const <String, dynamic>{
'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 <String, dynamic>{
'fromParent': 'yes',
'shared': 'parent',
});

router.go('/override');
await tester.pumpAndSettle();
expect(overriddenState, isNotNull);
expect(overriddenState!.metadata, const <String, dynamic>{
'fromParent': 'yes',
'shared': 'child',
'childOnly': true,
});

router.go('/empty');
await tester.pumpAndSettle();
expect(emptyState, isNotNull);
expect(emptyState!.metadata, isEmpty);
expect(emptyState!.metadata, isNotNull);
});
});
}