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.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.
Expand Down
98 changes: 98 additions & 0 deletions packages/go_router/example/lib/route_metadata.dart
Original file line number Diff line number Diff line change
@@ -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: <RouteBase>[
GoRoute(
path: '/',
redirect: (BuildContext context, GoRouterState state) => '/books',
),
GoRoute(
path: '/books',
metadata: const <String, dynamic>{
'section': 'Library',
'requiresAuth': true,
'analyticsName': 'books',
},
builder: (BuildContext context, GoRouterState state) {
return MetadataScreen(state: state);
},
routes: <RouteBase>[
GoRoute(
path: 'preview',
metadata: const <String, dynamic>{
'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: <Widget>[
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'),
),
],
),
);
}
}
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
95 changes: 94 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,84 @@ class RouteMatchList with Diagnosticable {
return result;
}

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
16 changes: 16 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,17 @@ abstract class RouteBase with Diagnosticable {
/// Navigator instead of the nearest ShellRoute ancestor.
final GlobalKey<NavigatorState>? 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<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 +291,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 +511,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 +729,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 +915,7 @@ class StatefulShellRoute extends ShellRouteBase {
super.notifyRootObserver,
required this.navigatorContainerBuilder,
super.parentNavigatorKey,
super.metadata,
this.restorationScopeId,
GlobalKey<StatefulNavigationShellState>? key,
}) : assert(branches.isNotEmpty),
Expand Down
20 changes: 19 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,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<String, dynamic> metadata;

/// Gets the [GoRouterState] from context.
///
/// The returned [GoRouterState] will depends on which [GoRoute] or
Expand Down Expand Up @@ -182,7 +193,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 +208,12 @@ class GoRouterState {
extra,
error,
pageKey,
Object.hashAllUnordered(
metadata.entries.map<int>(
(MapEntry<String, dynamic> entry) =>
Object.hash(entry.key, entry.value),
),
),
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/go_router/pending_changelogs/route_metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changelog: |
- Adds route `metadata` support, including inheritance and override behavior with exposure on `GoRouterState`.
version: minor
Loading