Skip to content

[go_router] Adds an ability to add a custom codec for serializing/des… #5288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 3, 2023
Merged
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 @@
## 12.1.0

- Adds an ability to add a custom codec for serializing/deserializing extra.

## 12.0.3

- Fixes crashes when dynamically updates routing tables with named routes.
Expand Down
20 changes: 20 additions & 0 deletions packages/go_router/doc/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,25 @@ Returning a value:
onTap: () => context.pop(true)
```

## Using extra
You can provide additional data along with navigation.

```dart
context.go('/123, extra: 'abc');
```

and retrieve the data from GoRouterState

```dart
final String extraString = GoRouterState.of(context).extra! as String;
```

The extra data will go through serialization when it is stored in the browser.
If you plan to use complex data as extra, consider also providing a codec
to GoRouter so that it won't get dropped during serialization.

For an example on how to use complex data in extra with a codec, see
[extra_codec.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart).


[Named routes]: https://pub.dev/documentation/go_router/latest/topics/Named%20routes-topic.html
5 changes: 5 additions & 0 deletions packages/go_router/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ An example to demonstrate how to use a `StatefulShellRoute` to create stateful n

An example to demonstrate how to handle exception in go_router.

## [Extra Codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart)
`flutter run lib/extra_codec.dart`

An example to demonstrate how to use a complex object as extra.

## [Books app](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/books)
`flutter run lib/books/main.dart`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
139 changes: 139 additions & 0 deletions packages/go_router/example/lib/extra_codec.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

/// This sample app demonstrates how to provide a codec for complex extra data.
void main() => runApp(const MyApp());

/// The router configuration.
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) =>
const HomeScreen(),
),
],
extraCodec: const MyExtraCodec(),
);

/// The main app.
class MyApp extends StatelessWidget {
/// Constructs a [MyApp]
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
);
}
}

/// The home screen.
class HomeScreen extends StatelessWidget {
/// Constructs a [HomeScreen].
const HomeScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Screen')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
"If running in web, use the browser's backward and forward button to test extra codec after setting extra several times."),
Text(
'The extra for this page is: ${GoRouterState.of(context).extra}'),
ElevatedButton(
onPressed: () => context.go('/', extra: ComplexData1('data')),
child: const Text('Set extra to ComplexData1'),
),
ElevatedButton(
onPressed: () => context.go('/', extra: ComplexData2('data')),
child: const Text('Set extra to ComplexData2'),
),
],
),
),
);
}
}

/// A complex class.
class ComplexData1 {
/// Create a complex object.
ComplexData1(this.data);

/// The data.
final String data;

@override
String toString() => 'ComplexData1(data: $data)';
}

/// A complex class.
class ComplexData2 {
/// Create a complex object.
ComplexData2(this.data);

/// The data.
final String data;

@override
String toString() => 'ComplexData2(data: $data)';
}

/// A codec that can serialize both [ComplexData1] and [ComplexData2].
class MyExtraCodec extends Codec<Object?, Object?> {
/// Create a codec.
const MyExtraCodec();
@override
Converter<Object?, Object?> get decoder => const _MyExtraDecoder();

@override
Converter<Object?, Object?> get encoder => const _MyExtraEncoder();
}

class _MyExtraDecoder extends Converter<Object?, Object?> {
const _MyExtraDecoder();
@override
Object? convert(Object? input) {
if (input == null) {
return null;
}
final List<Object?> inputAsList = input as List<Object?>;
if (inputAsList[0] == 'ComplexData1') {
return ComplexData1(inputAsList[1]! as String);
}
if (inputAsList[0] == 'ComplexData2') {
return ComplexData2(inputAsList[1]! as String);
}
throw FormatException('Unable tp parse input: $input');
}
}

class _MyExtraEncoder extends Converter<Object?, Object?> {
const _MyExtraEncoder();
@override
Object? convert(Object? input) {
if (input == null) {
return null;
}
switch (input.runtimeType) {
case ComplexData1:
return <Object?>['ComplexData1', (input as ComplexData1).data];
case ComplexData2:
return <Object?>['ComplexData2', (input as ComplexData2).data];
default:
throw FormatException('Cannot encode type ${input.runtimeType}');
}
}
}
23 changes: 23 additions & 0 deletions packages/go_router/example/test/extra_codec_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_examples/extra_codec.dart' as example;

void main() {
testWidgets('example works', (WidgetTester tester) async {
await tester.pumpWidget(const example.MyApp());
expect(find.text('The extra for this page is: null'), findsOneWidget);

await tester.tap(find.text('Set extra to ComplexData1'));
await tester.pumpAndSettle();
expect(find.text('The extra for this page is: ComplexData1(data: data)'),
findsOneWidget);

await tester.tap(find.text('Set extra to ComplexData2'));
await tester.pumpAndSettle();
expect(find.text('The extra for this page is: ComplexData2(data: data)'),
findsOneWidget);
});
}
15 changes: 15 additions & 0 deletions packages/go_router/lib/src/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
Expand All @@ -25,6 +26,7 @@ class RouteConfiguration {
RouteConfiguration(
this._routingConfig, {
required this.navigatorKey,
this.extraCodec,
}) {
_onRoutingTableChanged();
_routingConfig.addListener(_onRoutingTableChanged);
Expand Down Expand Up @@ -232,6 +234,19 @@ class RouteConfiguration {
/// The global key for top level navigator.
final GlobalKey<NavigatorState> navigatorKey;

/// The codec used to encode and decode extra into a serializable format.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe link the "extra" to GoRoute.extra or wherever users can learn more about what "extra" is.

///
/// When navigating using [GoRouter.go] or [GoRouter.push], one can provide
/// an `extra` parameter along with it. If the extra contains complex data,
/// consider provide a codec for serializing and deserializing the extra data.
///
/// See also:
/// * [Navigation](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
/// topic.
/// * [extra_codec](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart)
/// example.
final Codec<Object?, Object?>? extraCodec;

final Map<String, String> _nameToPath = <String, String>{};

/// Looks up the url location by a [GoRoute]'s name.
Expand Down
4 changes: 2 additions & 2 deletions packages/go_router/lib/src/logging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ final Logger logger = Logger('GoRouter');
bool _enabled = false;

/// Logs the message if logging is enabled.
void log(String message) {
void log(String message, {Level level = Level.INFO}) {
if (_enabled) {
logger.info(message);
logger.log(level, message);
}
}

Expand Down
65 changes: 49 additions & 16 deletions packages/go_router/lib/src/match.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';

import 'configuration.dart';
import 'logging.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
import 'route.dart';
Expand Down Expand Up @@ -358,29 +360,35 @@ class RouteMatchList {
/// Handles encoding and decoding of [RouteMatchList] objects to a format
/// suitable for using with [StandardMessageCodec].
///
/// The primary use of this class is for state restoration.
/// The primary use of this class is for state restoration and browser history.
@internal
class RouteMatchListCodec extends Codec<RouteMatchList, Map<Object?, Object?>> {
/// Creates a new [RouteMatchListCodec] object.
RouteMatchListCodec(RouteConfiguration configuration)
: decoder = _RouteMatchListDecoder(configuration);
: decoder = _RouteMatchListDecoder(configuration),
encoder = _RouteMatchListEncoder(configuration);

static const String _locationKey = 'location';
static const String _extraKey = 'state';
static const String _imperativeMatchesKey = 'imperativeMatches';
static const String _pageKey = 'pageKey';
static const String _codecKey = 'codec';
static const String _jsonCodecName = 'json';
static const String _customCodecName = 'custom';
static const String _encodedKey = 'encoded';

@override
final Converter<RouteMatchList, Map<Object?, Object?>> encoder =
const _RouteMatchListEncoder();
final Converter<RouteMatchList, Map<Object?, Object?>> encoder;

@override
final Converter<Map<Object?, Object?>, RouteMatchList> decoder;
}

class _RouteMatchListEncoder
extends Converter<RouteMatchList, Map<Object?, Object?>> {
const _RouteMatchListEncoder();
const _RouteMatchListEncoder(this.configuration);

final RouteConfiguration configuration;
@override
Map<Object?, Object?> convert(RouteMatchList input) {
final List<Map<Object?, Object?>> imperativeMatches = input.matches
Expand All @@ -394,15 +402,36 @@ class _RouteMatchListEncoder
imperativeMatches: imperativeMatches);
}

static Map<Object?, Object?> _toPrimitives(String location, Object? extra,
Map<Object?, Object?> _toPrimitives(String location, Object? extra,
{List<Map<Object?, Object?>>? imperativeMatches, String? pageKey}) {
String? encodedExtra;
try {
encodedExtra = json.encoder.convert(extra);
} on JsonUnsupportedObjectError {/* give up if not serializable */}
Map<String, Object?> encodedExtra;
if (configuration.extraCodec != null) {
encodedExtra = <String, Object?>{
RouteMatchListCodec._codecKey: RouteMatchListCodec._customCodecName,
RouteMatchListCodec._encodedKey:
configuration.extraCodec?.encode(extra),
};
} else {
String jsonEncodedExtra;
try {
jsonEncodedExtra = json.encoder.convert(extra);
} on JsonUnsupportedObjectError {
jsonEncodedExtra = json.encoder.convert(null);
log(
'An extra with complex data type ${extra.runtimeType} is provided '
'without a codec. Consider provide a codec to GoRouter to '
'prevent extra being dropped during serialization.',
level: Level.WARNING);
}
encodedExtra = <String, Object?>{
RouteMatchListCodec._codecKey: RouteMatchListCodec._jsonCodecName,
RouteMatchListCodec._encodedKey: jsonEncodedExtra,
};
}

return <Object?, Object?>{
RouteMatchListCodec._locationKey: location,
if (encodedExtra != null) RouteMatchListCodec._extraKey: encodedExtra,
RouteMatchListCodec._extraKey: encodedExtra,
if (imperativeMatches != null)
RouteMatchListCodec._imperativeMatchesKey: imperativeMatches,
if (pageKey != null) RouteMatchListCodec._pageKey: pageKey,
Expand All @@ -420,13 +449,17 @@ class _RouteMatchListDecoder
RouteMatchList convert(Map<Object?, Object?> input) {
final String rootLocation =
input[RouteMatchListCodec._locationKey]! as String;
final String? encodedExtra =
input[RouteMatchListCodec._extraKey] as String?;
final Map<Object?, Object?> encodedExtra =
input[RouteMatchListCodec._extraKey]! as Map<Object?, Object?>;
final Object? extra;
if (encodedExtra != null) {
extra = json.decoder.convert(encodedExtra);

if (encodedExtra[RouteMatchListCodec._codecKey] ==
RouteMatchListCodec._jsonCodecName) {
extra = json.decoder
.convert(encodedExtra[RouteMatchListCodec._encodedKey]! as String);
} else {
extra = null;
extra = configuration.extraCodec
?.decode(encodedExtra[RouteMatchListCodec._encodedKey]);
}
RouteMatchList matchList =
configuration.findMatch(rootLocation, extra: extra);
Expand Down
Loading