diff --git a/packages/kaisel/doc/migration/from-auto-route.md b/packages/kaisel/doc/migration/from-auto-route.md index 8651719..9b550a4 100644 --- a/packages/kaisel/doc/migration/from-auto-route.md +++ b/packages/kaisel/doc/migration/from-auto-route.md @@ -170,6 +170,45 @@ are now separate — the screen takes its parameter directly, no value equality so two `ProductDetail('sku-42')` instances are equal to each other (matters for stack diffing). +**Routes that carried callbacks.** A closure field breaks both `const`-ness +and value equality, so `onComplete` / `onClose` / `onAction` can't travel on a +kaisel route. Move the behaviour to the page builder, which has the router in +scope, and turn the callback into a result: + +```dart +// route carries data only; the builder wires the behaviour +EddFlow(:final type) => EddFlowPage(type: type, onComplete: router.pop), +``` + +That works, but a bare `pop()` is indistinguishable from the user backing out +— both arrive as `null` at `pushForResult`. When the caller needs to tell +"completed" from "dismissed", either pop an explicit value +(`onComplete: () => router.pop(true)`) or — for a multi-step sub-experience — +reach for [`run`](/guides/modal-flows/) with a `KaiselModalRoute`, whose +typed completion contract exists for exactly this conversion. + +**Parameters inherited from a parent router.** auto_route lets a nested child +read a path parameter declared on its parent via +`@PathParam.inherit('countryCode')`, resolved from the URL at build time — so +the child's generated args class never carries it. kaisel routes are explicit +data with no inheritance: the child must declare the field itself, which also +means **every construction site for that route gains an argument**. + +```dart +final class BillProvider extends AppRoute { + const BillProvider({required this.countryCode, required this.providerId}); + final String countryCode; + final String providerId; + @override + List get props => [countryCode, providerId]; +} +``` + +The change is mechanical but nothing in the type system points at it — a naive +port compiles and then has no idea what country it is for. Grep for +`PathParam.inherit` before you start; each hit is a field to add and a set of +call sites to update. + Group your routes into a single sealed hierarchy: ```dart diff --git a/skills/kaisel/SHELLS.md b/skills/kaisel/SHELLS.md index 19f72b2..fcfa46e 100644 --- a/skills/kaisel/SHELLS.md +++ b/skills/kaisel/SHELLS.md @@ -344,6 +344,23 @@ branch shares **one** route type `R`, so there's no per-branch typing. It builds its own `ShellRouter` internally from `branchInitials` — you don't construct one, and there's no `shell:` parameter. +**A screen that is both a tab and a full screen needs two route values.** +This follows from the scoping rule below, and it looks like duplication until +you see why. A "recipients" screen might be a bottom-nav tab (no app bar of +its own — the shell chrome supplies one) *and* be reachable as a full screen +from a settings drawer (its own app bar, covering the bottom bar). Those are +genuinely different navigation states, so each family gets a value: + +```dart +final class RecipientsTab extends DashboardTabRoute { const RecipientsTab(); } +final class Recipients extends AppRoute { const Recipients(); } +``` + +Both build the same page widget with different chrome. The alternative — +one value in two families — isn't expressible, and shouldn't be: pushing the +tab's value onto the main stack would render a tab body with no shell around +it. + **Critical: `R` must be a sealed type scoped to the shell's routes, not your app-wide `AppRoute`.** The `pageBuilder` switch is exhaustive over `R`; if `R` is `AppRoute`, the switch has to handle *every* route the app