Declarative Routing
ทำไม imperative push/pop ถึงพังตอนใช้จริง
หัวข้อที่มีชื่อว่า “ทำไม imperative push/pop ถึงพังตอนใช้จริง”Imperative navigation ทำงานได้ดีจนกว่าคุณจะต้องการ deep link เมื่อ user เปิด myapp.com/orders/42 จาก cold start ไม่มีลำดับการเรียก push ที่เคยเกิดขึ้นเลย — คุณต้องสร้าง stack ทั้งหมด (Home → Orders → Order 42) ขึ้นมาใหม่จาก URL การทำแบบนั้นด้วยมือผ่าน Navigator.push นั้นยุ่งยากและมีโอกาสผิดพลาดสูง
ทางแก้คือทำให้ route stack เป็น function ของ state แบบ declarative — กำหนด URL หรือ app state ปัจจุบัน แล้วคำนวณ stack ออกมา นี่คือ Navigator 2.0 (บทก่อนหน้าครอบคลุมฝั่ง Router/RouterDelegate) เพราะ raw API ยาวมาก เกือบทุกคนเลยใช้ go_router (คอร์สนี้ pin ไว้ที่ 18.0.1 ใน probe; pub.dev ปัจจุบันตอนที่เขียนคือ 18.0.2 — การ pin เป็นแค่ snapshot ของ lockfile ไม่ใช่การอ้างว่า 18.0.1 เป็นตัวล่าสุด) ซึ่งเป็น package ทางการที่สร้างอยู่บนมัน
flowchart LR state["app state / URL /orders/42"] --> router["go_router (route table)"] router --> stack["computed stack Home then Orders then Order 42"]
การ config go_router
หัวข้อที่มีชื่อว่า “การ config go_router”คุณประกาศ route ครั้งเดียวเป็น data go_router map path ไปยัง builder รองรับ typed path parameter และ sync browser URL อัตโนมัติบน web
final router = GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute( path: '/orders/:id', // typed path parameter builder: (context, state) { final id = state.pathParameters['id']!; return OrderScreen(orderId: id); }, ), ],);
// Navigate by path — no manual stack juggling:context.go('/orders/42');context.go('/orders/42') ตั้ง location; router คำนวณ stack ใหม่ บน web, URL bar อัปเดตให้ตรงกัน และการ paste URL นั้นสร้างหน้าจอเดียวกันขึ้นมาใหม่ — deep linking ได้มาฟรี คู่มือ deep-linking ของ Flutter เอง ครอบคลุมการต่อสาย platform-side (intent filter ใน AndroidManifest.xml, iOS Universal Links) ที่ต้องมีบน mobile — go_router จัดการฝั่ง Flutter route matching เมื่อ link มาถึงแอปคุณแล้ว
Nested / shell route และ StatefulShellRoute.indexedStack
หัวข้อที่มีชื่อว่า “Nested / shell route และ StatefulShellRoute.indexedStack”shell route ห่อ route กลุ่มหนึ่งด้วย UI ที่ใช้ร่วมกัน — แอป bottom-navigation แบบคลาสสิกที่ tab bar อยู่คงที่ในขณะที่เนื้อหาข้างในเปลี่ยน
ShellRoute( builder: (context, state, child) => ScaffoldWithNavBar(child: child), routes: [ GoRoute(path: '/feed', builder: (context, state) => const FeedScreen()), GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()), ],);ShellRoute ใช้ Navigator ตัวเดียวร่วมกันทุก branch — สลับ tab ก็แค่ขยับภายใน stack เดียวกัน ดังนั้น route ที่ push ไว้ใต้ /feed จะหายไปทันทีที่คุณออกจาก branch นั้น สำหรับแอป bottom-nav ที่ แต่ละ tab ต้องมี back-stack เป็นของตัวเองอิสระจากกัน (สลับไป Profile, เจาะลึกเข้า Settings, สลับกลับ Feed, สลับกลับ Profile — Settings ควรยังอยู่) ทางแก้คือ StatefulShellRoute.indexedStack: มันให้ทุก branch มี Navigator ของตัวเองแยกกัน และเก็บทุกตัวไว้ให้ยังทำงานพร้อมกันใน IndexedStack — แสดงแค่ Navigator ของ branch ที่ active อยู่เท่านั้น แต่ไม่มีตัวไหนถูก dispose เพราะการสลับ tab เลย
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
GoRouter buildShellRouter() => GoRouter( initialLocation: '/a', routes: [ StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => ShellScaffold(navigationShell: navigationShell), branches: [ StatefulShellBranch( routes: [ GoRoute( path: '/a', builder: (context, state) => const BranchAScreen(), routes: [ GoRoute(path: 'detail', builder: (context, state) => const BranchADetailScreen()), ], ), ], ), StatefulShellBranch( routes: [ GoRoute(path: '/b', builder: (context, state) => const BranchBScreen()), ], ), ], ), ],);
class ShellScaffold extends StatelessWidget { const ShellScaffold({super.key, required this.navigationShell}); final StatefulNavigationShell navigationShell;
@override Widget build(BuildContext context) { return Scaffold( body: navigationShell, bottomNavigationBar: BottomNavigationBar( currentIndex: navigationShell.currentIndex, onTap: (index) => navigationShell.goBranch(index), items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Tab A'), BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Tab B'), ], ), ); }}
class BranchAScreen extends StatelessWidget { const BranchAScreen({super.key}); @override Widget build(BuildContext context) => Scaffold( body: Center( child: TextButton( onPressed: () => context.push('/a/detail'), child: const Text('A root — go to detail'), ), ), );}
class BranchADetailScreen extends StatelessWidget { const BranchADetailScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('A detail')));}
class BranchBScreen extends StatelessWidget { const BranchBScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('B root')));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/shell_app.dart';
void main() { testWidgets( "StatefulShellRoute.indexedStack keeps each branch's own navigator stack alive across tab switches", (tester) async { final router = buildShellRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); expect(find.text('A root — go to detail'), findsOneWidget);
await tester.tap(find.text('A root — go to detail')); await tester.pumpAndSettle(); expect(find.text('A detail'), findsOneWidget);
await tester.tap(find.text('Tab B')); await tester.pumpAndSettle(); expect(find.text('B root'), findsOneWidget); expect(find.text('A detail'), findsNothing);
await tester.tap(find.text('Tab A')); await tester.pumpAndSettle(); expect( find.text('A detail'), findsOneWidget, reason: "IndexedStack kept branch A's Navigator — and its pushed Detail route — alive while tab B was visible", ); }, );}✓ StatefulShellRoute.indexedStack keeps each branch's own navigator stack alive across tab switches
00:00 +1: All tests passed!ถ้านี่เป็น ShellRoute ธรรมดา การสลับไป Tab B แล้วกลับมาจะเห็น BranchAScreen อีกครั้ง ไม่ใช่ BranchADetailScreen — จะมี Navigator ที่ใช้ร่วมกันแค่ตัวเดียว และการสลับ branch ไม่ได้ push/pop มันเลย มันแค่สลับว่า GoRoute ระดับบนสุดตัวไหน active อยู่ข้างใต้
trap ของ /pets/:id เทียบ /pets/new
หัวข้อที่มีชื่อว่า “trap ของ /pets/:id เทียบ /pets/new”go_router match route ตามลำดับที่ประกาศ และใช้ตัวที่ match ตัวแรก — มันไม่ได้ให้ priority กับ path segment ที่เป็น literal เหนือกว่าตัวที่มี parameter โดยอัตโนมัติ ประกาศ route ที่มี parameter ก่อน sibling ที่เป็น literal แล้ว parameter จะกลืนมันไป
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
/// BUGGY order: '/pets/:id' is declared BEFORE '/pets/new', so ':id' matches/// "new" as a literal id — a real trap AI-generated route tables fall into.GoRouter buildPetsRouterWrongOrder() => GoRouter( initialLocation: '/pets', errorBuilder: (context, state) => const NotFoundScreen(), routes: [ GoRoute(path: '/pets', builder: (context, state) => const PetsListScreen()), GoRoute( path: '/pets/:id', builder: (context, state) => PetDetailScreen(id: state.pathParameters['id']!), ), GoRoute(path: '/pets/new', builder: (context, state) => const NewPetScreen()), ],);
/// FIXED order: the literal segment '/pets/new' is declared BEFORE the/// parameterized '/pets/:id' — first match wins, so the literal must come first.GoRouter buildPetsRouterFixedOrder() => GoRouter( initialLocation: '/pets', errorBuilder: (context, state) => const NotFoundScreen(), routes: [ GoRoute(path: '/pets', builder: (context, state) => const PetsListScreen()), GoRoute(path: '/pets/new', builder: (context, state) => const NewPetScreen()), GoRoute( path: '/pets/:id', builder: (context, state) => PetDetailScreen(id: state.pathParameters['id']!), ), ],);
class PetsListScreen extends StatelessWidget { const PetsListScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Pets list'));}
class NewPetScreen extends StatelessWidget { const NewPetScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('New pet form'));}
class PetDetailScreen extends StatelessWidget { const PetDetailScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) => Scaffold(body: Text('Pet id: $id'));}
class NotFoundScreen extends StatelessWidget { const NotFoundScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('404 — route not found'));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/pets_router.dart';
void main() { testWidgets("declaring '/pets/:id' before '/pets/new' makes :id swallow the literal 'new' segment", (tester) async { final router = buildPetsRouterWrongOrder(); router.go('/pets/new'); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); expect(find.text('Pet id: new'), findsOneWidget, reason: ':id matched first and treated "new" as a literal id — the real bug'); expect(find.text('New pet form'), findsNothing); });
testWidgets("declaring '/pets/new' before '/pets/:id' fixes it — literal segments must come first", (tester) async { final router = buildPetsRouterFixedOrder(); router.go('/pets/new'); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); expect(find.text('New pet form'), findsOneWidget); expect(find.text('Pet id: new'), findsNothing); });
testWidgets('errorBuilder renders a fallback screen for an unmatched path', (tester) async { final router = buildPetsRouterFixedOrder(); router.go('/does-not-exist'); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); expect(find.text('404 — route not found'), findsOneWidget); });}✓ declaring '/pets/:id' before '/pets/new' makes :id swallow the literal 'new' segment✓ declaring '/pets/new' before '/pets/:id' fixes it — literal segments must come first✓ errorBuilder renders a fallback screen for an unmatched path
00:00 +3: All tests passed!Redirect เป็น auth guard และ refreshListenable
หัวข้อที่มีชื่อว่า “Redirect เป็น auth guard และ refreshListenable”redirect รันก่อน route จะ build และส่ง user ไปที่อื่นได้ — เป็นที่ที่เหมาะสมสำหรับ authentication guard return path ใหม่เพื่อ redirect หรือ null เพื่อดำเนินต่อ ตามลำพัง redirect จะ re-run แค่ตอนที่ มีการ navigate เกิดขึ้น เท่านั้น — มันไม่มีทางรู้ว่า auth state ของคุณเปลี่ยน ด้วยตัวเอง (token หมดอายุ, login สำเร็จจาก background stream) refreshListenable ปิดช่องว่างนั้น: ส่ง Listenable อะไรก็ได้เข้าไป (ส่วนใหญ่คือ ChangeNotifier) แล้ว router จะ re-evaluate redirect ทุกครั้งที่ listenable นั้น notify — โดยไม่ต้องมีการเรียก navigate เองเลย
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
class AuthNotifier extends ChangeNotifier { bool loggedIn = false; void logIn() { loggedIn = true; notifyListeners(); }}
GoRouter buildAuthRouter(AuthNotifier auth) => GoRouter( initialLocation: '/', refreshListenable: auth, redirect: (context, state) { final goingToLogin = state.matchedLocation == '/login'; if (!auth.loggedIn && !goingToLogin) return '/login'; if (auth.loggedIn && goingToLogin) return '/'; return null; }, routes: [ GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), ],);
class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Home (protected)'));}
class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Login'));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/auth_redirect_router.dart';
void main() { testWidgets( 'redirect sends a logged-out user to /login, and refreshListenable re-evaluates it on external state change alone', (tester) async { final auth = AuthNotifier(); final router = buildAuthRouter(auth); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); expect(find.text('Login'), findsOneWidget, reason: 'redirect ran before the / route ever built, because loggedIn was false');
auth.logIn(); // no navigation call at all — only external state changes await tester.pumpAndSettle(); expect( find.text('Home (protected)'), findsOneWidget, reason: 'refreshListenable told the router to re-run redirect() when auth notified, without any go()/push() call', ); }, );}✓ redirect sends a logged-out user to /login, and refreshListenable re-evaluates it on external state change alone
00:00 +1: All tests passed!context.go เทียบ context.push เทียบ Navigator.push แบบ imperative — ปนกันในแอปเดียว
หัวข้อที่มีชื่อว่า “context.go เทียบ context.push เทียบ Navigator.push แบบ imperative — ปนกันในแอปเดียว”ทั้งสามแบบอยู่ร่วมกันใต้ MaterialApp.router ได้ และพฤติกรรมต่างกันในแบบที่สำคัญ:
context.go(path)ตั้ง location — stack ที่ได้คือสิ่งที่ route table ของgo_routerบอกว่า location นั้นควรมี stack เป็นอะไร สำหรับ top-level sibling route สองตัว การไปจาก/เป็น/detailจะ แทนที่ stack เลย ไม่มีอะไรข้างล่างให้ pop กลับไปcontext.push(path)push entry เพิ่มเติม ขึ้นบน stack ปัจจุบัน โดยgo_routerติดตามมันในฐานะImperativeRouteMatchคู่กับ match แบบ declarative ปกติของมันNavigator.of(context).push(...)push ตรงบน FlutterNavigatorที่อยู่ข้างใต้ — มันขยาย widget-tree stack เหมือนกับcontext.pushเป๊ะ แต่go_routerไม่เห็นมันเลย ความรู้เรื่อง “location ปัจจุบัน” ของมันไม่ถูกแตะเลย เพราะ route นั้นไม่เคยผ่าน route matching เลย
import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
GoRouter buildMixedRouter() => GoRouter( initialLocation: '/', routes: [ GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute(path: '/detail', builder: (context, state) => const GoDetailScreen()), ],);
class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Column( mainAxisSize: MainAxisSize.min, children: [ TextButton( onPressed: () => context.go('/detail'), child: const Text('context.go to Detail'), ), TextButton( onPressed: () => context.push('/detail'), child: const Text('context.push to Detail'), ), TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute<void>(builder: (context) => const ImperativeDetailScreen()), ), child: const Text('Navigator.push to Detail (imperative)'), ), ], ), ); }}
class GoDetailScreen extends StatelessWidget { const GoDetailScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Detail (via go_router)'));}
class ImperativeDetailScreen extends StatelessWidget { const ImperativeDetailScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Detail (imperative Navigator.push)'));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/mixed_navigation_router.dart';
void main() { testWidgets( 'context.go replaces the location (no back stack); context.push and imperative Navigator.push both grow the stack, but only context.push is known to go_router', (tester) async { final goRouter = buildMixedRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: goRouter)); await tester.tap(find.text('context.go to Detail')); await tester.pumpAndSettle(); expect(find.text('Detail (via go_router)'), findsOneWidget); var navigator = tester.state<NavigatorState>(find.byType(Navigator).first); expect(navigator.canPop(), isFalse, reason: 'go() replaced the current location — / and /detail are siblings, not parent/child, so nothing is left to pop to'); expect(goRouter.routerDelegate.currentConfiguration.uri.toString(), '/detail');
final pushRouter = buildMixedRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: pushRouter)); await tester.tap(find.text('context.push to Detail')); await tester.pumpAndSettle(); expect(find.text('Detail (via go_router)'), findsOneWidget); navigator = tester.state<NavigatorState>(find.byType(Navigator).first); expect(navigator.canPop(), isTrue, reason: 'push() adds a new entry on top — Home is still underneath');
final imperativeRouter = buildMixedRouter(); await tester.pumpWidget(MaterialApp.router(routerConfig: imperativeRouter)); await tester.tap(find.text('Navigator.push to Detail (imperative)')); await tester.pumpAndSettle(); expect(find.text('Detail (imperative Navigator.push)'), findsOneWidget); navigator = tester.state<NavigatorState>(find.byType(Navigator).first); expect(navigator.canPop(), isTrue, reason: 'the raw Flutter Navigator stack grew, same as context.push, at the widget-tree level'); expect( imperativeRouter.routerDelegate.currentConfiguration.uri.toString(), '/', reason: "go_router's OWN known location is still / — it never saw this push; it went straight to the Navigator, bypassing route matching entirely", ); }, );}✓ context.go replaces the location (no back stack); context.push and imperative Navigator.push both grow the stack, but only context.push is known to go_router
00:00 +1: All tests passed!นี่คือเหตุผลที่ทั้งสองสไตล์ “ชนกัน” ในแอปเดียวเป็น bug class จริง ๆ ไม่ใช่แค่เรื่อง style: โค้ดที่รู้จัก go_router ที่อ่าน GoRouterState.of(context) หรือพึ่ง URL bar (web) ให้สะท้อนหน้าจอปัจจุบัน จะพลาดหน้าจอที่ไปถึงผ่าน Navigator.push แบบ imperative อย่างเงียบ ๆ ในทางกลับกัน static flag ของ GoRouter เอง optionURLReflectsImperativeAPIs (ยกจาก doc comment ตรง ๆ จาก source 18.0.1 ที่ติดตั้งอยู่ lib/src/router.dart) ควบคุมขอบเขตเดียวกันสำหรับ imperative API ของ go_router เอง: “Whether the imperative API affects browser URL bar. The Imperative APIs refer to push, pushReplacement, or replace. … Defaults to false. … This option only affects web platform.” — แม้แต่ context.push (ของ go_router เอง ไม่ใช่ Navigator.push ดิบ ๆ) ก็ ไม่ อัปเดต browser URL โดย default; default นั้นมีอยู่เพราะเหตุผลตรง ๆ ว่า URL ของ route ที่ push แบบ imperative “is not always deeplink-able” ตาม comment เดียวกัน
Typed route ด้วย go_router_builder
หัวข้อที่มีชื่อว่า “Typed route ด้วย go_router_builder”state.pathParameters['id']! ที่พิมพ์เองเป็นการ lookup runtime ที่ key ด้วย String — พิมพ์ key ผิดแล้วได้ null-check crash ไม่ใช่ compile error go_router_builder (ปัจจุบัน: 4.5.0 บน pub.dev) generate GoRouteData subclass ที่ typed ต่อ route จาก annotation @TypedGoRoute ดังนั้น path parameter กลายเป็น field ของ constructor จริง ๆ
dev_dependencies: go_router_builder: ^4.5.0 build_runner: ^2.16.1import 'package:flutter/material.dart';import 'package:go_router/go_router.dart';
part 'typed_routes.g.dart';
@TypedGoRoute<HomeRoute>(path: '/')class HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute();
@override Widget build(BuildContext context, GoRouterState state) => const HomeScreen();}
@TypedGoRoute<PetDetailRoute>(path: '/pets/:id')class PetDetailRoute extends GoRouteData with $PetDetailRoute { const PetDetailRoute({required this.id}); final String id;
@override Widget build(BuildContext context, GoRouterState state) => PetDetailScreen(id: id);}
class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) => const Scaffold(body: Text('Home'));}
class PetDetailScreen extends StatelessWidget { const PetDetailScreen({super.key, required this.id}); final String id; @override Widget build(BuildContext context) => Scaffold(body: Text('Pet $id'));}รันตัว generator (dart run build_runner build, output จริงจากโปรเจกต์ scratch ที่ pin เวอร์ชัน package เดียวกับ probe ของคอร์สนี้):
13s compiling builders/aot 0s go_router_builder on 3 inputs; lib/main.dart 3s go_router_builder on 3 inputs: 1 no-op; spent 2s analyzing; lib/typed_routes.dart 5s go_router_builder on 3 inputs: 1 output, 1 no-op; spent 4s analyzing; test/widget_test.dart 6s go_router_builder on 3 inputs: 1 output, 2 no-op; spent 4s analyzing, 1s resolving 0s source_gen:combining_builder on 3 inputs; lib/main.dart 0s source_gen:combining_builder on 3 inputs: 1 output, 2 no-op Built with build_runner/aot in 20s; wrote 2 outputs.ไฟล์ generated จริง ไม่ได้แก้อะไรเลย — GoRouteData/TypedGoRoute ถูก export มาจาก go_router เอง (ไม่ใช่ go_router_builder ซึ่งเป็นเครื่องมือ codegen แบบ dev-only) ดังนั้นมัน compile ได้ใน probe ของบทนี้โดยไม่ต้องมี runtime dependency เพิ่ม:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'typed_routes.dart';
// **************************************************************************// GoRouterGenerator// **************************************************************************
List<RouteBase> get $appRoutes => [$homeRoute, $petDetailRoute];
RouteBase get $homeRoute => GoRouteData.$route( path: '/', hasOverriddenOnExit: false, factory: $HomeRoute._fromState,);
mixin $HomeRoute on GoRouteData { static HomeRoute _fromState(GoRouterState state) => const HomeRoute();
@override String get location => GoRouteData.$location('/');
@override void go(BuildContext context) => context.go(location);
@override Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override void pushReplacement(BuildContext context) => context.pushReplacement(location);
@override void replace(BuildContext context) => context.replace(location);}
RouteBase get $petDetailRoute => GoRouteData.$route( path: '/pets/:id', hasOverriddenOnExit: false, factory: $PetDetailRoute._fromState,);
mixin $PetDetailRoute on GoRouteData { static PetDetailRoute _fromState(GoRouterState state) => PetDetailRoute(id: state.pathParameters['id']!);
PetDetailRoute get _self => this as PetDetailRoute;
@override String get location => GoRouteData.$location('/pets/${Uri.encodeComponent(_self.id)}');
@override void go(BuildContext context) => context.go(location);
@override Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override void pushReplacement(BuildContext context) => context.pushReplacement(location);
@override void replace(BuildContext context) => context.replace(location);}import 'package:flutter_test/flutter_test.dart';import 'package:my_app/typed_routes.dart';
void main() { test('PetDetailRoute(id:) builds the URL — a typo in a field name is a compile error, not a runtime 404', () { expect(const PetDetailRoute(id: '42').location, '/pets/42'); expect(const HomeRoute().location, '/'); });}✓ PetDetailRoute(id:) builds the URL — a typo in a field name is a compile error, not a runtime 404
00:00 +1: All tests passed!สังเกตว่า _fromState อ่าน state.pathParameters['id']! แค่ครั้งเดียว ที่ generate ไว้ข้างใน $PetDetailRoute — คุณไม่ต้องเขียน lookup นั้นเองเลย การอ่าน id ที่เหลือทั้งหมดคือ field id แบบ typed navigate ด้วย const PetDetailRoute(id: '42').go(context) แทน context.go('/pets/42') — path segment ที่เปลี่ยนชื่อหรือสลับตำแหน่งจะกลายเป็น build error ที่ call site ทุกจุด ไม่ใช่ string ที่พังแบบเงียบ ๆ ที่ไหนสักแห่ง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”go_router คำนวณ Navigator stack ทั้งหมดจาก state ชิ้นเดียว: location ปัจจุบัน (บวกกับ imperative push ใด ๆ ที่ซ้อนอยู่ข้างบน) redirect คือ pure function ที่ veto หรือเขียน location ใหม่ก่อนที่อะไรจะ build ได้ refreshListenable ก็แค่ “รัน pure function นั้นอีกครั้ง” ที่ถูก trigger ด้วยอะไรก็ได้ที่ไม่ใช่ navigation StatefulShellRoute.indexedStack ขยายไอเดียเดียวกันต่อ branch: แทนที่จะมี location เดียวสร้าง stack เดียว แต่ละ branch เป็นเจ้าของ location-to-stack mapping ของตัวเอง และ IndexedStack คือสิ่งที่ทำให้ทุกตัวยัง resident อยู่ ดังนั้นการสลับ branch คือ การเปลี่ยนความมองเห็น ไม่ใช่การ rebuild trap ของ ordering กับความต่างของ context.go/push/Navigator.push ทั้งคู่มาจากข้อเท็จจริงเดียวกัน: go_router ก็แค่ list อีกอันที่ถูก evaluate จากบนลงล่าง และมันรู้จักแค่ path ที่มันได้ evaluate เท่านั้น
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
ShellRoute | ง่าย, Navigator ตัวเดียวใช้ร่วมกัน | branch ไม่มี state/stack เป็นของตัวเองแยกกัน |
StatefulShellRoute.indexedStack | แต่ละ branch เก็บ stack และ scroll/form state ของตัวเองข้ามการสลับ tab ได้ | widget tree ของทุก branch ยัง resident อยู่ (memory), setup มากกว่า |
redirect เดี่ยว ๆ | ง่าย รันทุกครั้งที่ navigate | มองไม่เห็น state change ที่ไม่ได้ trigger จาก navigation |
redirect + refreshListenable | ตอบสนอง auth/state change ได้โดยไม่ต้องเรียก navigate เพิ่ม | ต้อง wire object เพิ่มอีกหนึ่งตัว (ChangeNotifier) และดูแลให้ sync |
state.pathParameters['id']! แบบพิมพ์เอง | ไม่มีขั้นตอน codegen | เป็น string-typed; พิมพ์ผิดคือ null-check crash ตอน runtime ไม่ใช่ compile error |
typed route ของ go_router_builder | สร้าง path แบบ compile-time-safe, มีจุด lookup เดียวแทนที่จะกระจัดกระจาย | มี build_runner อยู่ในลูป, generated code คือโค้ดจริงที่ analyzer ตรวจ |
ปน context.push/context.go กับ Navigator.push ดิบ ๆ | ไม่มี — นี่คือ trap ไม่ใช่ตัวเลือก | go_router หลุด track หน้าจอที่ push แบบ imperative; URL/state อาจ drift ไปจากสิ่งที่แสดงอยู่จริง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
ShellRouteกับStatefulShellRoute.indexedStackพฤติกรรมเหมือนกัน แค่ syntax ใหม่กว่า” ไม่จริง:ShellRouteใช้Navigatorตัวเดียวร่วมกันทุก branch (สลับ tab ไม่เคยรักษา sub-route ที่ push ไว้);StatefulShellRoute.indexedStackให้แต่ละ branch มีของตัวเอง พิสูจน์แล้วข้างบนด้วย Detail screen ที่รอดจากการสลับ tab - “go_router เลือก route ที่ specific กว่าเหนือกว่า route ที่มี parameter โดยอัตโนมัติ” ไม่จริง — มันคือ first-match-in-declaration-order พิสูจน์แล้วข้างบน (
/pets/:idที่ประกาศก่อนกลืน/pets/newจริง ๆ) - “
redirectเดี่ยว ๆ ก็พอสำหรับ auth guard ที่ต้องตอบสนองต่อ login state ที่เปลี่ยนใน background”redirectre-run แค่ตอน navigate เท่านั้น ถ้าไม่มีrefreshListenableการ login ที่เสร็จจาก background stream จะทิ้ง user ไว้ที่/loginจนกว่าจะ trigger navigation อะไรก็ได้ - “
context.goกับcontext.pushใช้แทนกันได้ แค่ verb ต่างกัน”goแทนที่ stack ทั้งหมดของ location ปัจจุบัน (พิสูจน์แล้วข้างบน:canPop()เป็นfalse);pushเพิ่มขึ้นบน (canPop()เป็นtrue) — UX ของปุ่ม/gesture back ต่างกันมาก - “เพราะ go_router คือ routing ที่ config ให้
MaterialApp.routerแล้วNavigator.pushใด ๆ ในแอปนั้นจะถูกมันติดตามโดยอัตโนมัติด้วย” พิสูจน์แล้วว่าไม่จริงข้างบน:Navigator.pushแบบ imperative ขยาย widget-tree stack จริง แต่currentConfiguration.uriของgo_routerเองไม่เปลี่ยนเลย — มันข้าม route matching ของ go_router ไปทั้งหมด
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”route table ที่ AI-generated เจอบ่อยมาก เขียนหน้าจอตามลำดับที่ถูกขอ ไม่ใช่ตามลำดับความ specific — เป็น trap เดียวกับที่พิสูจน์ไว้ข้างบนเป๊ะ
// AI-generated: "add a route for editing a specific order, and one for creating a new order"GoRoute(path: '/orders/:orderId', builder: (context, state) => OrderDetailScreen(id: state.pathParameters['orderId']!)),GoRoute(path: '/orders/new', builder: (context, state) => const NewOrderScreen()),โค้ดนี้ analyze ผ่านสะอาด — ไม่มี error อะไรที่ flutter analyze จับได้เลย เพราะทั้งสอง path ถูกต้องแยกกัน bug จะโผล่ตอน runtime เท่านั้น เหมือนที่พิสูจน์ไว้ข้างบนสำหรับ /pets/new เป๊ะ การสลับสองบรรทัดนี้คือ fix ทั้งหมด — ไม่ต้องแก้โค้ดอื่นเลย
🐛 หา bug ในโค้ดนี้
// AI-generated: "guard the settings page behind login"final router = GoRouter(initialLocation: '/settings',redirect: (context, state) { if (!auth.isLoggedIn) return '/login'; return null;},routes: [ GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()),],);เฉลย
โค้ดนี้ compile ผ่านและ redirect ถูกต้องด้วยตอนโหลดครั้งแรก — แต่ถ้า user อยู่ที่ /login อยู่แล้วในสถานะ logged out ทุกครั้งที่ redirect ถูก re-evaluate (รวมถึงครั้งที่เกิดจากการ navigate ไปยัง /login เอง) จะ return '/login' อีกครั้ง เพราะเงื่อนไขไม่เคยยกเว้น route ของ login เองเลย ในแอป go_router จริง กรณีนี้ไม่ infinite-loop (go_router ตรวจจับ redirect ที่ map location กลับไปยังตัวเองแล้วหยุด) แต่มันหมายความว่า user ที่ login แล้วจะไม่มีทางถูก redirect ออกจาก /login หลัง login เลย เพราะไม่มี branch if (auth.isLoggedIn && goingToLogin) return '/' ทางแก้คือรูปแบบสองสาขาที่ใช้ข้างบน: เช็ค state.matchedLocation == '/login' แล้วจัดการทั้งสองทิศทางตรง ๆ เหมือนที่ buildAuthRouter ทำในบทนี้เป๊ะ