From f0dd6b87890136c87a6ed8daa415247af78835e8 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:12:56 +0100 Subject: [PATCH 1/3] feat: add pushAndPopUntil and popUntilRoot to KaiselRouter --- packages/kaisel_core/CHANGELOG.md | 8 ++ .../kaisel_core/lib/src/kaisel_router.dart | 18 +++ .../test/kaisel_pop_verbs_test.dart | 119 ++++++++++++++++++ skills/kaisel/NAVIGATION.md | 31 +++++ 4 files changed, 176 insertions(+) create mode 100644 packages/kaisel_core/test/kaisel_pop_verbs_test.dart 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..0530d28 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,21 @@ 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. + Future pushAndPopUntil(R route, {required bool Function(R) predicate}) { + final anchor = stack.lastIndexWhere(predicate); + return set([...stack.take(anchor + 1), route]); + } + + /// Pop every route above the root. Runs through guards. + Future popUntilRoot() => set([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..5fcaeb3 --- /dev/null +++ b/packages/kaisel_core/test/kaisel_pop_verbs_test.dart @@ -0,0 +1,119 @@ +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('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()]); + }); + }); +} diff --git a/skills/kaisel/NAVIGATION.md b/skills/kaisel/NAVIGATION.md index 3d1155d..34ad0f7 100644 --- a/skills/kaisel/NAVIGATION.md +++ b/skills/kaisel/NAVIGATION.md @@ -42,6 +42,9 @@ below show the typed form first. | `replaceTop(route)` | Removes top, pushes new | `Future` | Swap current screen in place (no back history) | | `pushOrReplaceTop(route)` | Push if top differs in runtime type; replace if same | `Future` | Adaptive master-detail; tab-style in-place updates | | `set(routes)` | Replaces entire stack | `Future` | Auth state transitions, deep-link landing | +| `popUntil(predicate)` | Pops down to an anchor | `Future` | "Back to the cart", unwinding several screens at once | +| `pushAndPopUntil(route, predicate:)` | Pops to an anchor, then pushes | `Future` | Finish a flow and land on a result screen | +| `popUntilRoot()` | Pops everything above the root | `Future` | "Home" from anywhere | | `run(flow)` | Opens a typed modal flow | `Future` (flow result) | Modal sub-flows (payment, wizard, picker) | `pushForResult` and `run` both return `Future`. The difference is @@ -284,6 +287,34 @@ page state — but derivation happens on state *events*, not widget builds, and every derived stack still flows through the guard pipeline. `stackFor` is a pure function you can unit test without a widget tree. +## popUntil / pushAndPopUntil / popUntilRoot + +```dart +final router = context.router(); + +router.popUntil((route) => route is Cart); +router.pushAndPopUntil(const Receipt(), predicate: (route) => route is Home); +router.popUntilRoot(); +``` + +Anchor-relative unwinding, for when "go back" means several screens at once. +Each is one mutation through the guard pipeline, not a loop of pops. + +**Use for:** finishing a checkout onto a receipt without leaving the payment +screens behind it; a "back to cart" affordance; a Home button that clears +everything above the root. + +**Notes:** + +- The anchor is the **topmost** entry matching the predicate; anything below + it stays. +- **When nothing matches, the two differ deliberately.** `popUntil` keeps the + root — the stack can never be emptied — while `pushAndPopUntil` replaces + the whole stack with the pushed route, since there was no anchor to stop + at. +- `set` is still the primitive; these are the common shapes named, with the + off-by-one at the anchor handled for you. + ## run ```dart From 6058c010a763f0a7e0b8e6673ea01960a6416643 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:14:38 +0100 Subject: [PATCH 2/3] fix: keep pop-to-anchor verbs on push/pop history semantics --- .../kaisel_core/lib/src/kaisel_router.dart | 13 ++++++--- .../test/kaisel_pop_verbs_test.dart | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/kaisel_core/lib/src/kaisel_router.dart b/packages/kaisel_core/lib/src/kaisel_router.dart index 0530d28..2cf5fc8 100644 --- a/packages/kaisel_core/lib/src/kaisel_router.dart +++ b/packages/kaisel_core/lib/src/kaisel_router.dart @@ -487,13 +487,18 @@ class KaiselRouter extends KaiselChangeNotifier /// 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. - Future pushAndPopUntil(R route, {required bool Function(R) predicate}) { + /// 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 set([...stack.take(anchor + 1), route]); - } + return _navigate([...stack.take(anchor + 1), route]); + }); /// Pop every route above the root. Runs through guards. - Future popUntilRoot() => set([stack.first]); + 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 diff --git a/packages/kaisel_core/test/kaisel_pop_verbs_test.dart b/packages/kaisel_core/test/kaisel_pop_verbs_test.dart index 5fcaeb3..2daeca9 100644 --- a/packages/kaisel_core/test/kaisel_pop_verbs_test.dart +++ b/packages/kaisel_core/test/kaisel_pop_verbs_test.dart @@ -99,6 +99,35 @@ void main() { }); }); + 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()]); From e5f042e1e36abfcc419cd5ae64e59cccc5b432a9 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:18:55 +0100 Subject: [PATCH 3/3] chore: move pop-verb reference docs out of the code branch --- skills/kaisel/NAVIGATION.md | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/skills/kaisel/NAVIGATION.md b/skills/kaisel/NAVIGATION.md index 34ad0f7..3d1155d 100644 --- a/skills/kaisel/NAVIGATION.md +++ b/skills/kaisel/NAVIGATION.md @@ -42,9 +42,6 @@ below show the typed form first. | `replaceTop(route)` | Removes top, pushes new | `Future` | Swap current screen in place (no back history) | | `pushOrReplaceTop(route)` | Push if top differs in runtime type; replace if same | `Future` | Adaptive master-detail; tab-style in-place updates | | `set(routes)` | Replaces entire stack | `Future` | Auth state transitions, deep-link landing | -| `popUntil(predicate)` | Pops down to an anchor | `Future` | "Back to the cart", unwinding several screens at once | -| `pushAndPopUntil(route, predicate:)` | Pops to an anchor, then pushes | `Future` | Finish a flow and land on a result screen | -| `popUntilRoot()` | Pops everything above the root | `Future` | "Home" from anywhere | | `run(flow)` | Opens a typed modal flow | `Future` (flow result) | Modal sub-flows (payment, wizard, picker) | `pushForResult` and `run` both return `Future`. The difference is @@ -287,34 +284,6 @@ page state — but derivation happens on state *events*, not widget builds, and every derived stack still flows through the guard pipeline. `stackFor` is a pure function you can unit test without a widget tree. -## popUntil / pushAndPopUntil / popUntilRoot - -```dart -final router = context.router(); - -router.popUntil((route) => route is Cart); -router.pushAndPopUntil(const Receipt(), predicate: (route) => route is Home); -router.popUntilRoot(); -``` - -Anchor-relative unwinding, for when "go back" means several screens at once. -Each is one mutation through the guard pipeline, not a loop of pops. - -**Use for:** finishing a checkout onto a receipt without leaving the payment -screens behind it; a "back to cart" affordance; a Home button that clears -everything above the root. - -**Notes:** - -- The anchor is the **topmost** entry matching the predicate; anything below - it stays. -- **When nothing matches, the two differ deliberately.** `popUntil` keeps the - root — the stack can never be emptied — while `pushAndPopUntil` replaces - the whole stack with the pushed route, since there was no anchor to stop - at. -- `set` is still the primitive; these are the common shapes named, with the - off-by-one at the anchor handled for you. - ## run ```dart