Skip to content
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
8 changes: 8 additions & 0 deletions packages/kaisel_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<T>` called on a flow's sub-router (what `context.router<R>()`
Expand Down
23 changes: 23 additions & 0 deletions packages/kaisel_core/lib/src/kaisel_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ class KaiselRouter<R extends KaiselRoute> 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<void> popUntil(bool Function(R route) predicate) => _enqueueOrigin(() {
final next = [...stack];
while (next.length > 1 && !predicate(next.last)) {
Expand All @@ -477,6 +480,26 @@ class KaiselRouter<R extends KaiselRoute> 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<void> 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<void> 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
Expand Down
148 changes: 148 additions & 0 deletions packages/kaisel_core/test/kaisel_pop_verbs_test.dart
Original file line number Diff line number Diff line change
@@ -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()]);
});
});
}
Loading