diff --git a/packages/kaisel_core/CHANGELOG.md b/packages/kaisel_core/CHANGELOG.md index 7419b51..f33a950 100644 --- a/packages/kaisel_core/CHANGELOG.md +++ b/packages/kaisel_core/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +- `pushAndPopUntil(route, predicate:)` and `popUntilRoot()` on `KaiselRouter`, + joining the existing `popUntil` — anchor-relative unwinding as a single + 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. + ## 1.0.1 - Fix: `run` called on a flow's sub-router (what `context.router()` diff --git a/packages/kaisel_core/lib/src/kaisel_router.dart b/packages/kaisel_core/lib/src/kaisel_router.dart index b29984f..2cf5fc8 100644 --- a/packages/kaisel_core/lib/src/kaisel_router.dart +++ b/packages/kaisel_core/lib/src/kaisel_router.dart @@ -469,6 +469,9 @@ class KaiselRouter extends KaiselChangeNotifier /// Pop routes until [predicate] returns true for the top route, or /// only one route remains on the stack. Runs through guards. + /// + /// The stack always keeps its root: when nothing matches, this leaves the + /// bottom route rather than emptying the stack. Future popUntil(bool Function(R route) predicate) => _enqueueOrigin(() { final next = [...stack]; while (next.length > 1 && !predicate(next.last)) { @@ -477,6 +480,26 @@ class KaiselRouter extends KaiselChangeNotifier return _navigate(next); }); + /// Pop everything above the anchor [predicate] matches, then push [route] + /// on top of it. Runs through guards as a single mutation. + /// + /// The anchor is the **topmost** entry matching [predicate]; entries below + /// it are kept. When nothing matches, the stack becomes `[route]` — the + /// whole history is replaced, which is what "and pop until" means with no + /// anchor to stop at. + /// Like [push], this is forward navigation: it adds a browser history entry + /// rather than replacing one. + Future pushAndPopUntil( + R route, { + required bool Function(R) predicate, + }) => _enqueueOrigin(() { + final anchor = stack.lastIndexWhere(predicate); + return _navigate([...stack.take(anchor + 1), route]); + }); + + /// Pop every route above the root. Runs through guards. + Future popUntilRoot() => _enqueueOrigin(() => _navigate([stack.first])); + /// Used by the delegate to sync state when the navigator pops a page /// (e.g. system back). Synchronous: by the time the navigator notifies /// us, the page has already animated out, so we update state to match diff --git a/packages/kaisel_core/test/kaisel_pop_verbs_test.dart b/packages/kaisel_core/test/kaisel_pop_verbs_test.dart new file mode 100644 index 0000000..2daeca9 --- /dev/null +++ b/packages/kaisel_core/test/kaisel_pop_verbs_test.dart @@ -0,0 +1,148 @@ +import 'package:kaisel_core/kaisel_core.dart'; +import 'package:test/test.dart'; + +sealed class _R extends KaiselRoute { + const _R(); +} + +final class _Home extends _R { + const _Home(); +} + +final class _Cart extends _R { + const _Cart(); +} + +final class _Payment extends _R { + const _Payment(); +} + +final class _Receipt extends _R { + const _Receipt(); +} + +void main() { + KaiselRouter<_R> routerWith(List<_R> stack) => + KaiselRouter<_R>.fromStack(stack); + + group('pushAndPopUntil', () { + test('keeps the anchor and everything below it', () async { + final router = routerWith(const [_Home(), _Cart(), _Payment()]); + + await router.pushAndPopUntil( + const _Receipt(), + predicate: (route) => route is _Cart, + ); + + expect(router.stack, const [_Home(), _Cart(), _Receipt()]); + }); + + test('anchors on the topmost match', () async { + final router = routerWith(const [_Home(), _Cart(), _Home(), _Payment()]); + + await router.pushAndPopUntil( + const _Receipt(), + predicate: (route) => route is _Home, + ); + + expect(router.stack, const [_Home(), _Cart(), _Home(), _Receipt()]); + }); + + test('replaces the whole stack when nothing matches', () async { + final router = routerWith(const [_Home(), _Cart()]); + + await router.pushAndPopUntil( + const _Receipt(), + predicate: (route) => route is _Payment, + ); + + expect(router.stack, const [_Receipt()]); + }); + + test('runs through guards as one mutation', () async { + var runs = 0; + final router = KaiselRouter<_R>.fromStack( + const [_Home(), _Cart(), _Payment()], + guards: [ + (current, proposed) { + runs++; + return proposed; + }, + ], + ); + + await router.pushAndPopUntil( + const _Receipt(), + predicate: (route) => route is _Home, + ); + + expect(runs, 1); + expect(router.stack, const [_Home(), _Receipt()]); + }); + }); + + group('popUntil', () { + test('stops at the topmost match', () async { + final router = routerWith(const [_Home(), _Cart(), _Payment()]); + + await router.popUntil((route) => route is _Cart); + + expect(router.stack, const [_Home(), _Cart()]); + }); + + test('keeps the root when nothing matches', () async { + final router = routerWith(const [_Home(), _Cart(), _Payment()]); + + await router.popUntil((route) => route is _Receipt); + + expect(router.stack, const [_Home()]); + }); + }); + + group('history semantics match pop and push', () { + test('popUntilRoot does not replace the history entry', () async { + final router = routerWith(const [_Home(), _Cart(), _Payment()]); + + await router.popUntilRoot(); + + expect(router.replacesHistoryEntry, isFalse); + }); + + test('pushAndPopUntil adds an entry like push', () async { + final router = routerWith(const [_Home(), _Cart()]); + + await router.pushAndPopUntil( + const _Receipt(), + predicate: (route) => route is _Home, + ); + + expect(router.replacesHistoryEntry, isFalse); + }); + + test('set still replaces, for contrast', () async { + final router = routerWith(const [_Home(), _Cart()]); + + await router.set(const [_Home()]); + + expect(router.replacesHistoryEntry, isTrue); + }); + }); + + group('popUntilRoot', () { + test('leaves only the bottom route', () async { + final router = routerWith(const [_Home(), _Cart(), _Payment()]); + + await router.popUntilRoot(); + + expect(router.stack, const [_Home()]); + }); + + test('is a no-op at the root', () async { + final router = routerWith(const [_Home()]); + + await router.popUntilRoot(); + + expect(router.stack, const [_Home()]); + }); + }); +}