From 14b6be635742a5b634376c7f977fde102dab58e5 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:28:50 +0100 Subject: [PATCH 1/4] feat: allow codec decode to be asynchronous --- packages/kaisel/CHANGELOG.md | 11 +- .../src/kaisel_route_information_parser.dart | 2 +- .../lib/src/kaisel_router_delegate.dart | 11 +- .../kaisel/test/kaisel_async_codec_test.dart | 123 ++++++++++++++++++ .../kaisel/test/kaisel_module_codec_test.dart | 119 +++++++++-------- packages/kaisel_core/CHANGELOG.md | 10 ++ .../kaisel_core/lib/src/kaisel_config.dart | 19 ++- .../lib/src/kaisel_module_codec.dart | 4 +- .../lib/src/kaisel_stack_codec.dart | 7 +- .../kaisel_core/test/kaisel_config_test.dart | 4 +- .../test/kaisel_module_codec_test.dart | 23 ++-- 11 files changed, 257 insertions(+), 76 deletions(-) create mode 100644 packages/kaisel/test/kaisel_async_codec_test.dart diff --git a/packages/kaisel/CHANGELOG.md b/packages/kaisel/CHANGELOG.md index 571f2a3..1d2b782 100644 --- a/packages/kaisel/CHANGELOG.md +++ b/packages/kaisel/CHANGELOG.md @@ -1,3 +1,12 @@ +# Changelog + +## Unreleased + +- 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 No library changes. Packaging and examples only: @@ -9,8 +18,6 @@ No library changes. Packaging and examples only: - Example: `main_tutorial.dart` — the finished app from the [docs tutorial](https://kaisel.dev/tutorial/). -# Changelog - ## 1.0.0 First stable release. The API surface is frozen under semantic versioning: 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 25e5f12..2b9551e 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -843,7 +843,10 @@ class KaiselRouterDelegate } bool roundTrips; try { - roundTrips = codec.decode(uri) != null; + final decoded = codec.decode(uri); + // An async codec can't be resolved inside this synchronous snapshot, so + // it is reported as round-tripping rather than as a problem. + roundTrips = decoded is Future || decoded != null; } catch (_) { roundTrips = false; } @@ -868,7 +871,7 @@ class KaiselRouterDelegate if (codec == null) return null; try { final config = codec.decode(Uri.parse(url)); - if (config == null) return null; + if (config is! KaiselConfig) return null; final lines = [ 'main: ${config.mainStack.map((r) => '$r').join(' → ')}', ]; @@ -899,7 +902,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..e99fe92 --- /dev/null +++ b/packages/kaisel/test/kaisel_async_codec_test.dart @@ -0,0 +1,123 @@ +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 _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('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 7419b51..57f3bc7 100644 --- a/packages/kaisel_core/CHANGELOG.md +++ b/packages/kaisel_core/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +- `KaiselConfigCodec.decode` and `KaiselStackCodec.decode` now return + `FutureOr`, so a deep link whose destination depends on state that must be + read first — an entitlement, a feature flag, 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 - Fix: `run` called on a flow's sub-router (what `context.router()` diff --git a/packages/kaisel_core/lib/src/kaisel_config.dart b/packages/kaisel_core/lib/src/kaisel_config.dart index c2728b1..ff9c864 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,12 @@ 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 — an entitlement, a feature flag, 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 +245,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'); From 27936837b0949653ece36ad2c8d7fd430d0375ac Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:34:19 +0100 Subject: [PATCH 2/4] chore: let the round-trip check read without a comment --- packages/kaisel/lib/src/kaisel_router_delegate.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/kaisel/lib/src/kaisel_router_delegate.dart b/packages/kaisel/lib/src/kaisel_router_delegate.dart index 2b9551e..cf75ad9 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -844,9 +844,8 @@ class KaiselRouterDelegate bool roundTrips; try { final decoded = codec.decode(uri); - // An async codec can't be resolved inside this synchronous snapshot, so - // it is reported as round-tripping rather than as a problem. - roundTrips = decoded is Future || decoded != null; + final resolvesAsynchronously = decoded is Future; + roundTrips = resolvesAsynchronously || decoded != null; } catch (_) { roundTrips = false; } From 666f61cf9517a9741c9aa58d7abad45e5fd911f2 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 08:19:32 +0100 Subject: [PATCH 3/4] fix: don't drop a rejecting async decode in the DevTools paths --- .../lib/src/kaisel_router_delegate.dart | 17 ++++++++--- .../kaisel/test/kaisel_async_codec_test.dart | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/kaisel/lib/src/kaisel_router_delegate.dart b/packages/kaisel/lib/src/kaisel_router_delegate.dart index cf75ad9..c5483a2 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -844,8 +844,12 @@ class KaiselRouterDelegate bool roundTrips; try { final decoded = codec.decode(uri); - final resolvesAsynchronously = decoded is Future; - roundTrips = resolvesAsynchronously || decoded != null; + if (decoded case final Future?> pending) { + pending.ignore(); + roundTrips = true; + } else { + roundTrips = decoded != null; + } } catch (_) { roundTrips = false; } @@ -869,8 +873,13 @@ class KaiselRouterDelegate final codec = _codec; if (codec == null) return null; try { - final config = codec.decode(Uri.parse(url)); - if (config is! KaiselConfig) 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(' → ')}', ]; diff --git a/packages/kaisel/test/kaisel_async_codec_test.dart b/packages/kaisel/test/kaisel_async_codec_test.dart index e99fe92..cafe23b 100644 --- a/packages/kaisel/test/kaisel_async_codec_test.dart +++ b/packages/kaisel/test/kaisel_async_codec_test.dart @@ -43,6 +43,16 @@ class _EntitlementCodec extends KaiselStackCodec<_R> { }; } +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(); @@ -110,6 +120,24 @@ void main() { 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)); From 071822ee50a37751abe5c5f70f7e84dccd501440 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sat, 29 Aug 2026 14:50:26 +0100 Subject: [PATCH 4/4] docs: drop dashes from the async decode docs and changelog --- packages/kaisel_core/CHANGELOG.md | 4 ++-- packages/kaisel_core/lib/src/kaisel_config.dart | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kaisel_core/CHANGELOG.md b/packages/kaisel_core/CHANGELOG.md index a3bf6ca..226e077 100644 --- a/packages/kaisel_core/CHANGELOG.md +++ b/packages/kaisel_core/CHANGELOG.md @@ -7,8 +7,8 @@ - `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 — an entitlement, a feature flag, a cached profile — can be - expressed directly ([#64](https://github.com/Mastersam07/kaisel/issues/64)). + 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. diff --git a/packages/kaisel_core/lib/src/kaisel_config.dart b/packages/kaisel_core/lib/src/kaisel_config.dart index ff9c864..2766286 100644 --- a/packages/kaisel_core/lib/src/kaisel_config.dart +++ b/packages/kaisel_core/lib/src/kaisel_config.dart @@ -220,7 +220,8 @@ abstract class KaiselConfigCodec { /// unrecognised (the parser will then use the fallback stack). /// /// May be asynchronous: a deep link's destination can depend on state that - /// has to be read first — an entitlement, a feature flag, a cached profile. + /// 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);