diff --git a/packages/kaisel/CHANGELOG.md b/packages/kaisel/CHANGELOG.md index 178e5fb..144efb3 100644 --- a/packages/kaisel/CHANGELOG.md +++ b/packages/kaisel/CHANGELOG.md @@ -12,6 +12,10 @@ main stack, shell branches, modules, and flows — so screen-view analytics no longer re-logs a tab you return to ([#66](https://github.com/Mastersam07/kaisel/issues/66)). +- Deep-link decoding may now be asynchronous, following `kaisel_core` + ([#64](https://github.com/Mastersam07/kaisel/issues/64)). The route + information parser awaits the codec, so an async `decode` resolves before + the stack is applied. ## 1.0.0+1 diff --git a/packages/kaisel/lib/src/kaisel_route_information_parser.dart b/packages/kaisel/lib/src/kaisel_route_information_parser.dart index 5e00ab6..ab0713e 100644 --- a/packages/kaisel/lib/src/kaisel_route_information_parser.dart +++ b/packages/kaisel/lib/src/kaisel_route_information_parser.dart @@ -66,7 +66,7 @@ class KaiselRouteInformationParser Future> parseRouteInformation( RouteInformation routeInformation, ) async { - final decoded = _codec.decode(routeInformation.uri); + final decoded = await _codec.decode(routeInformation.uri); if (decoded == null) return KaiselConfig(mainStack: _fallback); return decoded; } diff --git a/packages/kaisel/lib/src/kaisel_router_delegate.dart b/packages/kaisel/lib/src/kaisel_router_delegate.dart index f783e76..da113f5 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -876,7 +876,13 @@ class KaiselRouterDelegate } bool roundTrips; try { - roundTrips = codec.decode(uri) != null; + final decoded = codec.decode(uri); + if (decoded case final Future?> pending) { + pending.ignore(); + roundTrips = true; + } else { + roundTrips = decoded != null; + } } catch (_) { roundTrips = false; } @@ -900,8 +906,13 @@ class KaiselRouterDelegate final codec = _codec; if (codec == null) return null; try { - final config = codec.decode(Uri.parse(url)); - if (config == null) return null; + final decoded = codec.decode(Uri.parse(url)); + if (decoded case final Future?> pending) { + pending.ignore(); + return null; + } + if (decoded is! KaiselConfig) return null; + final config = decoded; final lines = [ 'main: ${config.mainStack.map((r) => '$r').join(' → ')}', ]; @@ -932,7 +943,9 @@ class KaiselRouterDelegate case 'deepLink': final codec = _codec; if (codec == null) return _cmd(false, 'No codec wired.'); - final config = codec.decode(Uri.parse('${command['url'] ?? ''}')); + final config = await codec.decode( + Uri.parse('${command['url'] ?? ''}'), + ); if (config == null) return _cmd(false, 'URL did not decode.'); await setNewRoutePath(config); return _cmd(true, 'Applied ${command['url']}.'); diff --git a/packages/kaisel/test/kaisel_async_codec_test.dart b/packages/kaisel/test/kaisel_async_codec_test.dart new file mode 100644 index 0000000..cafe23b --- /dev/null +++ b/packages/kaisel/test/kaisel_async_codec_test.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kaisel/kaisel.dart'; + +sealed class _R extends KaiselRoute { + const _R(); +} + +final class _Home extends _R { + const _Home(); +} + +final class _Vault extends _R { + const _Vault(); +} + +final class _Locked extends _R { + const _Locked(); +} + +class _EntitlementCodec extends KaiselStackCodec<_R> { + const _EntitlementCodec(this.hasEntitlement); + + final Future Function() hasEntitlement; + + @override + Uri encode(List<_R> stack) => switch (stack.last) { + _Home() => Uri(path: '/'), + _Vault() => Uri(path: '/vault'), + _Locked() => Uri(path: '/locked'), + }; + + @override + Future?> decode(Uri uri) async => switch (uri.pathSegments) { + [] || [''] => const [_Home()], + ['vault'] => + await hasEntitlement() + ? const [_Home(), _Vault()] + : const [_Home(), _Locked()], + _ => null, + }; +} + +class _FailingCodec extends KaiselStackCodec<_R> { + const _FailingCodec(); + + @override + Uri encode(List<_R> stack) => Uri(path: '/'); + + @override + Future?> decode(Uri uri) async => throw StateError('storage down'); +} + +class _SyncCodec extends KaiselStackCodec<_R> { + const _SyncCodec(); + + @override + Uri encode(List<_R> stack) => Uri(path: '/'); + + @override + List<_R>? decode(Uri uri) => + uri.path == '/vault' ? const [_Home(), _Vault()] : const [_Home()]; +} + +RouteInformationParser> _parserOf( + KaiselRouterConfig<_R> config, +) => config.routeInformationParser as RouteInformationParser>; + +Widget _appWith(KaiselRouterConfig<_R> config) => + MaterialApp.router(routerConfig: config); + +KaiselRouterConfig<_R> _configWith(KaiselStackCodec<_R> codec) => + KaiselRouterConfig<_R>( + initial: const _Home(), + codec: StackToConfigCodec(codec), + builder: (context, route) => switch (route) { + _Home() => const Scaffold(body: Text('home')), + _Vault() => const Scaffold(body: Text('vault')), + _Locked() => const Scaffold(body: Text('locked')), + }, + ); + +void main() { + testWidgets('an async codec resolves a deep link once its state is read', ( + tester, + ) async { + final gate = Completer(); + final config = _configWith(_EntitlementCodec(() => gate.future)); + await tester.pumpWidget(_appWith(config)); + + final pending = _parserOf( + config, + ).parseRouteInformation(RouteInformation(uri: Uri.parse('/vault'))); + gate.complete(true); + + expect((await pending).mainStack, const [_Home(), _Vault()]); + }); + + testWidgets('the async result decides the destination', (tester) async { + final config = _configWith(_EntitlementCodec(() async => false)); + await tester.pumpWidget(_appWith(config)); + + final decoded = await _parserOf( + config, + ).parseRouteInformation(RouteInformation(uri: Uri.parse('/vault'))); + + expect(decoded.mainStack, const [_Home(), _Locked()]); + }); + + testWidgets('an unrecognised URL still falls back', (tester) async { + final config = _configWith(_EntitlementCodec(() async => true)); + await tester.pumpWidget(_appWith(config)); + + final decoded = await _parserOf( + config, + ).parseRouteInformation(RouteInformation(uri: Uri.parse('/nope'))); + + expect(decoded.mainStack, const [_Home()]); + }); + + testWidgets('a rejecting async decode does not leak from DevTools paths', ( + tester, + ) async { + final router = KaiselRouter<_R>(initial: const _Home()); + final delegate = KaiselRouterDelegate<_R>( + router: router, + codec: const StackToConfigCodec(_FailingCodec()), + builder: (context, route) => const Scaffold(body: Text('home')), + ); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + + expect(delegate.debugDecode('/anything'), isNull); + delegate.debugSnapshot(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('synchronous codecs are unaffected', (tester) async { + final config = _configWith(const _SyncCodec()); + await tester.pumpWidget(_appWith(config)); + + final decoded = await _parserOf( + config, + ).parseRouteInformation(RouteInformation(uri: Uri.parse('/vault'))); + + expect(decoded.mainStack, const [_Home(), _Vault()]); + }); +} diff --git a/packages/kaisel/test/kaisel_module_codec_test.dart b/packages/kaisel/test/kaisel_module_codec_test.dart index 7719f3a..1e9e42a 100644 --- a/packages/kaisel/test/kaisel_module_codec_test.dart +++ b/packages/kaisel/test/kaisel_module_codec_test.dart @@ -113,14 +113,14 @@ class _MainCodec implements KaiselConfigCodec<_AppRoute> { void main() { group('ModuleStackCodec type erasure', () { - test('encodeAny downcasts to the typed encode', () { + test('encodeAny downcasts to the typed encode', () async { const codec = _CheckoutCodec(); // Pass as List, the erased entry point. final result = codec.encodeAny(const [_Cart(), _Shipping()]); expect(result, ['shipping']); }); - test('decodeAny upcasts the typed decode result', () { + test('decodeAny upcasts the typed decode result', () async { const codec = _CheckoutCodec(); final result = codec.decodeAny(const ['shipping']); expect(result, isA>()); @@ -129,12 +129,12 @@ void main() { expect(result[1], isA<_Shipping>()); }); - test('decodeAny returns null when the typed decode rejects', () { + test('decodeAny returns null when the typed decode rejects', () async { const codec = _CheckoutCodec(); expect(codec.decodeAny(const ['nonsense']), isNull); }); - test('typed encode/decode round-trip', () { + test('typed encode/decode round-trip', () async { const codec = _CheckoutCodec(); for (final stack in >[ const [_Cart()], @@ -165,18 +165,18 @@ void main() { ], ); - test('non-module URLs delegate to the base codec', () { - final home = codec.decode(Uri.parse('/')); + test('non-module URLs delegate to the base codec', () async { + final home = await codec.decode(Uri.parse('/')); expect(home, isNotNull); expect(home!.mainStack, const [_Home()]); expect(home.nestedState, isNull); - final settings = codec.decode(Uri.parse('/settings')); + final settings = await codec.decode(Uri.parse('/settings')); expect(settings!.mainStack, const [_Settings()]); }); - test('module root URL decodes to mount + module initial', () { - final result = codec.decode(Uri.parse('/checkout')); + test('module root URL decodes to mount + module initial', () async { + final result = await codec.decode(Uri.parse('/checkout')); expect(result, isNotNull); expect(result!.mainStack, const [_CheckoutMount()]); expect(result.nestedState, isA()); @@ -184,46 +184,51 @@ void main() { expect(nested.stack, const [_Cart()]); }); - test('deep module URL decodes to full restored stack', () { - final result = codec.decode(Uri.parse('/checkout/confirm')); + test('deep module URL decodes to full restored stack', () async { + final result = await codec.decode(Uri.parse('/checkout/confirm')); expect(result!.mainStack, const [_CheckoutMount()]); final nested = result.nestedState! as KaiselModuleConfig; expect(nested.stack, const [_Cart(), _Shipping(), _Confirm()]); }); - test('different module prefixes route to different module codecs', () { - final checkout = codec.decode(Uri.parse('/checkout/shipping')); - expect(checkout!.mainStack, const [_CheckoutMount()]); + test( + 'different module prefixes route to different module codecs', + () async { + final checkout = await codec.decode(Uri.parse('/checkout/shipping')); + expect(checkout!.mainStack, const [_CheckoutMount()]); - final account = codec.decode(Uri.parse('/account/payment-methods')); - expect(account!.mainStack, const [_AccountMount()]); - final nested = account.nestedState! as KaiselModuleConfig; - expect(nested.stack, const [_Profile(), _PaymentMethods()]); - }); + final account = await codec.decode( + Uri.parse('/account/payment-methods'), + ); + expect(account!.mainStack, const [_AccountMount()]); + final nested = account.nestedState! as KaiselModuleConfig; + expect(nested.stack, const [_Profile(), _PaymentMethods()]); + }, + ); test( 'URL within a known prefix but unrecognised by the module codec returns null ' 'and does NOT fall through to baseCodec', - () { + () async { // /checkout/nope matches the /checkout prefix; the module // codec rejects ['nope']. The composer returns null rather // than letting baseCodec try (which would also reject, but // the principle matters: the URL is in the module's // namespace). - expect(codec.decode(Uri.parse('/checkout/nope')), isNull); + expect(await codec.decode(Uri.parse('/checkout/nope')), isNull); }, ); - test('URL with no matching prefix falls through to baseCodec', () { + test('URL with no matching prefix falls through to baseCodec', () async { // /unknown isn't under any module prefix; baseCodec is asked. // Its decode returns null for unknown paths. - expect(codec.decode(Uri.parse('/unknown')), isNull); + expect(await codec.decode(Uri.parse('/unknown')), isNull); }); - test('trailing slash in module URL is tolerated', () { + test('trailing slash in module URL is tolerated', () async { // /checkout/ should decode the same as /checkout. - final withSlash = codec.decode(Uri.parse('/checkout/')); - final withoutSlash = codec.decode(Uri.parse('/checkout')); + final withSlash = await codec.decode(Uri.parse('/checkout/')); + final withoutSlash = await codec.decode(Uri.parse('/checkout')); expect(withSlash, equals(withoutSlash)); }); }); @@ -240,22 +245,27 @@ void main() { ], ); - test('non-module config delegates to base codec', () { + test('non-module config delegates to base codec', () async { final uri = codec.encode(KaiselConfig(mainStack: const [_Home()])); expect(uri.path, '/'); }); - test('module config with module state encodes to prefix + segments', () { - final uri = codec.encode( - KaiselConfig( - mainStack: const [_CheckoutMount()], - nestedState: KaiselModuleConfig(stack: const [_Cart(), _Shipping()]), - ), - ); - expect(uri.pathSegments, ['checkout', 'shipping']); - }); + test( + 'module config with module state encodes to prefix + segments', + () async { + final uri = codec.encode( + KaiselConfig( + mainStack: const [_CheckoutMount()], + nestedState: KaiselModuleConfig( + stack: const [_Cart(), _Shipping()], + ), + ), + ); + expect(uri.pathSegments, ['checkout', 'shipping']); + }, + ); - test('module root state encodes to just the prefix', () { + test('module root state encodes to just the prefix', () async { final uri = codec.encode( KaiselConfig( mainStack: const [_CheckoutMount()], @@ -267,7 +277,7 @@ void main() { test( 'mount route on top but no module state yet encodes just the prefix', - () { + () async { // Cold state: mount has been pushed but module widget hasn't // registered yet, so nestedState is null. The composer should // still produce a sensible URL for the mount itself rather @@ -279,22 +289,25 @@ void main() { }, ); - test('round-trip: decode then encode is identity for module URLs', () { - for (final path in const [ - '/checkout', - '/checkout/shipping', - '/checkout/confirm', - ]) { - final decoded = codec.decode(Uri.parse(path)); - expect(decoded, isNotNull, reason: 'decode failed for $path'); - final encoded = codec.encode(decoded!); - expect(encoded.path, path, reason: 'round-trip mismatch for $path'); - } - }); + test( + 'round-trip: decode then encode is identity for module URLs', + () async { + for (final path in const [ + '/checkout', + '/checkout/shipping', + '/checkout/confirm', + ]) { + final decoded = await codec.decode(Uri.parse(path)); + expect(decoded, isNotNull, reason: 'decode failed for $path'); + final encoded = codec.encode(decoded!); + expect(encoded.path, path, reason: 'round-trip mismatch for $path'); + } + }, + ); }); group('ModuleMount prefix parsing', () { - test('leading slash is optional', () { + test('leading slash is optional', () async { const withSlash = ConfigCodecWithModules<_AppRoute>( baseCodec: _MainCodec(), modules: [ @@ -323,12 +336,12 @@ void main() { }); group('RouteModule.codec', () { - test('defaults to null when not overridden', () { + test('defaults to null when not overridden', () async { const module = _MinimalModule(); expect(module.codec, isNull); }); - test('returns the typed codec when overridden', () { + test('returns the typed codec when overridden', () async { const module = _CheckoutModule(); expect(module.codec, isA>()); // Round-trip via the typed API on the module's own codec. diff --git a/packages/kaisel_core/CHANGELOG.md b/packages/kaisel_core/CHANGELOG.md index f33a950..226e077 100644 --- a/packages/kaisel_core/CHANGELOG.md +++ b/packages/kaisel_core/CHANGELOG.md @@ -5,6 +5,13 @@ guarded mutation, with the anchor off-by-one and the no-match case owned by the library ([#62](https://github.com/Mastersam07/kaisel/issues/62)). - `popUntil` is now documented; it was reachable but missing from the guides. +- `KaiselConfigCodec.decode` and `KaiselStackCodec.decode` now return + `FutureOr`, so a deep link whose destination depends on state that must be + read first, such as an entitlement, a feature flag, or a cached profile, can + be expressed directly ([#64](https://github.com/Mastersam07/kaisel/issues/64)). + Existing synchronous codecs are unaffected: returning a plain value still + satisfies the contract. Code that *calls* `decode` directly now needs to + await it. ## 1.0.1 diff --git a/packages/kaisel_core/lib/src/kaisel_config.dart b/packages/kaisel_core/lib/src/kaisel_config.dart index c2728b1..2766286 100644 --- a/packages/kaisel_core/lib/src/kaisel_config.dart +++ b/packages/kaisel_core/lib/src/kaisel_config.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:meta/meta.dart'; import 'kaisel_notifier.dart'; @@ -216,7 +218,13 @@ abstract class KaiselConfigCodec { /// Decode a URL into a configuration, or return `null` if /// unrecognised (the parser will then use the fallback stack). - KaiselConfig? decode(Uri uri); + /// + /// May be asynchronous: a deep link's destination can depend on state that + /// has to be read first, such as an entitlement, a feature flag, or a + /// cached profile. + /// Returning a plain value stays valid, so synchronous codecs need no + /// change. + FutureOr?> decode(Uri uri); } /// Adapter so a [KaiselStackCodec] (stack-only URLs) works wherever @@ -238,10 +246,16 @@ class StackToConfigCodec Uri encode(KaiselConfig config) => stackCodec.encode(config.mainStack); @override - KaiselConfig? decode(Uri uri) { + FutureOr?> decode(Uri uri) { final stack = stackCodec.decode(uri); - return stack == null ? null : KaiselConfig(mainStack: stack); + return switch (stack) { + final Future?> pending => pending.then(_toConfig), + _ => _toConfig(stack), + }; } + + KaiselConfig? _toConfig(List? stack) => + stack == null ? null : KaiselConfig(mainStack: stack); } // Nested-router host machinery diff --git a/packages/kaisel_core/lib/src/kaisel_module_codec.dart b/packages/kaisel_core/lib/src/kaisel_module_codec.dart index 21ae222..7781268 100644 --- a/packages/kaisel_core/lib/src/kaisel_module_codec.dart +++ b/packages/kaisel_core/lib/src/kaisel_module_codec.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:meta/meta.dart'; import 'kaisel_config.dart'; @@ -205,7 +207,7 @@ class ConfigCodecWithModules } @override - KaiselConfig? decode(Uri uri) { + FutureOr?> decode(Uri uri) { final segments = uri.pathSegments .where((s) => s.isNotEmpty) .toList(growable: false); diff --git a/packages/kaisel_core/lib/src/kaisel_stack_codec.dart b/packages/kaisel_core/lib/src/kaisel_stack_codec.dart index c4c62ff..6c2824b 100644 --- a/packages/kaisel_core/lib/src/kaisel_stack_codec.dart +++ b/packages/kaisel_core/lib/src/kaisel_stack_codec.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'kaisel_codec.dart'; import 'kaisel_route.dart'; @@ -42,7 +44,10 @@ abstract class KaiselStackCodec { /// Decode a URL to a stack. Return `null` if the URL is unrecognised /// — the parser will fall back to the configured fallback stack. - List? decode(Uri uri); + /// + /// May be asynchronous when the destination depends on state that has to be + /// read first. Returning a plain value stays valid. + FutureOr?> decode(Uri uri); } /// Adapts a single-route [KaiselCodec] to the multi-route diff --git a/packages/kaisel_core/test/kaisel_config_test.dart b/packages/kaisel_core/test/kaisel_config_test.dart index 1a71e20..2aaf10b 100644 --- a/packages/kaisel_core/test/kaisel_config_test.dart +++ b/packages/kaisel_core/test/kaisel_config_test.dart @@ -260,10 +260,10 @@ void main() { }); group('StackToConfigCodec adapter', () { - test('round-trips a stack-only URL through KaiselConfig', () { + test('round-trips a stack-only URL through KaiselConfig', () async { const adapter = StackToConfigCodec<_Top>(_LegacyStackCodec()); - final decoded = adapter.decode(Uri(path: '/settings')); + final decoded = await adapter.decode(Uri(path: '/settings')); expect(decoded, isNotNull); expect(decoded!.mainStack, const [_Shell(), _Settings()]); expect(decoded.nestedState, isNull); diff --git a/packages/kaisel_core/test/kaisel_module_codec_test.dart b/packages/kaisel_core/test/kaisel_module_codec_test.dart index 52db863..d89418c 100644 --- a/packages/kaisel_core/test/kaisel_module_codec_test.dart +++ b/packages/kaisel_core/test/kaisel_module_codec_test.dart @@ -167,8 +167,8 @@ void main() { test( 'decode of a module URL yields mount on main stack + module state', - () { - final result = codec.decode(Uri.parse('/checkout/shipping')); + () async { + final result = await codec.decode(Uri.parse('/checkout/shipping')); expect(result, isNotNull); final config = result ?? KaiselConfig(mainStack: const [_Home()]); expect(config.mainStack, const [_CheckoutMount()]); @@ -184,20 +184,23 @@ void main() { }, ); - test('decode of a URL matching no module and no host route is null', () { - expect(codec.decode(Uri.parse('/unknown')), isNull); - // Under the module prefix but rejected by the module codec — the - // composer returns null and does NOT fall through to the host codec. - expect(codec.decode(Uri.parse('/checkout/nope')), isNull); - }); + test( + 'decode of a URL matching no module and no host route is null', + () async { + expect(await codec.decode(Uri.parse('/unknown')), isNull); + // Under the module prefix but rejected by the module codec — the + // composer returns null and does NOT fall through to the host codec. + expect(await codec.decode(Uri.parse('/checkout/nope')), isNull); + }, + ); - test('decode then encode is identity for module URLs', () { + test('decode then encode is identity for module URLs', () async { for (final path in const [ '/checkout', '/checkout/shipping', '/checkout/confirm', ]) { - final decoded = codec.decode(Uri.parse(path)); + final decoded = await codec.decode(Uri.parse(path)); expect(decoded, isNotNull, reason: 'decode failed for $path'); final config = decoded ?? KaiselConfig(mainStack: const [_Home()]); expect(codec.encode(config).path, path, reason: 'round-trip $path');