From 236b78e57af9c54bffbd341a0dc30520de6e6371 Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Sat, 18 Jul 2026 10:59:47 -0300 Subject: [PATCH 1/3] added cockpit folder --- .cockpit/tasks.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .cockpit/tasks.json diff --git a/.cockpit/tasks.json b/.cockpit/tasks.json new file mode 100644 index 00000000..9ee0b2fc --- /dev/null +++ b/.cockpit/tasks.json @@ -0,0 +1,44 @@ +{ + // .cockpit/tasks.json — Cockpit Task Run config (JSONC: // , /* */ and + // trailing commas are allowed; they're stripped before parsing). + // Lives at the workspace root you open in Cockpit. Detected tasks (npm + // scripts, pubspec) appear automatically; this file adds/overrides them. + // Full reference: cockpit/docs/tasks-json.md + "tasks": [ + { + "label": "Example", // shown in the Tasks list + "cwd": "example", // run dir, relative to this file (monorepo-friendly) + "command": "flutter", // base executable + "args": ["run"], // base args, before the profile + "kind": "watch", // "watch" = long-running (dev server); else "oneShot" + // Interactive keys -> buttons that write a key to the process stdin. + // primary=true shows a fixed button; the rest go under a key menu. + // icon: bolt | refresh | restart | stop (omit -> a chip with the key). + "interactiveKeys": [ + { "key": "r", "label": "Hot reload", "icon": "bolt", "primary": true }, + { "key": "R", "label": "Hot restart", "icon": "restart", "primary": true }, + { "key": "p", "label": "Toggle debug paint" }, + { "key": "o", "label": "Toggle platform" } + ], + // Reload-on-save: `flutter run` doesn't reload on save by itself (that's + // an IDE plugin) — Cockpit watches the files and fires `onChange`. + "watch": { + "paths": ["lib", "assets"], // dirs to watch (relative to cwd) + "ignore": ["build", ".dart_tool"], // skip these (avoid loops) + "onChange": "Hot reload", // an interactiveKey label, or "__restart__" + "debounceMs": 300 // wait after a change before firing + }, + // Drive the building/running badge by matching the output. + "progressPatterns": [ + { "begin": "Performing hot reload", "end": "Reloaded .* in .*ms" }, + { "begin": "Performing hot restart", "end": "Restarted application in .*ms" } + ], + // Named arg/env variants, picked by the chip before Run (flavor / + // dart-define just become args here — no stack-specific keys). + "profiles": [ + { "name": "web", "args": ["-d", "chrome"] }, + { "name": "macos", "args": ["-d", "macos"] } + ] + } + ] +} From 5da6bbe21e46163d5031be2a4e1dbc6780f33ecc Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Sat, 19 Sep 2026 11:37:59 -0300 Subject: [PATCH 2/3] fix(outlet): activate feature module binds for outlet routes RouterOutlet kept its own sub-stack but never reported entries to the ModuleManager, so a feature module reached only through an outlet had no binds ("X not registered"). Outlet entries now enter/leave the manager on push, navigate, replace, pop and dispose. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/navigation/outlet.dart | 102 +++++++++++++++++--------- test/outlet_feature_binds_test.dart | 110 ++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 test/outlet_feature_binds_test.dart diff --git a/lib/src/navigation/outlet.dart b/lib/src/navigation/outlet.dart index 3007c84b..4ed6b8b0 100644 --- a/lib/src/navigation/outlet.dart +++ b/lib/src/navigation/outlet.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:auto_injector/auto_injector.dart'; import 'package:flutter/material.dart'; +import '../module/module.dart'; import '../route/modular_route.dart'; import '../route/route_state.dart'; import '../state/scoped.dart'; @@ -113,6 +114,7 @@ class RouterOutletState extends State { Uri? _seedUri; final List<_OutletEntry> _stack = []; int _seq = 0; + ModuleManager? _manager; /// Whether this outlet has a sub-route above its seed to pop. bool get canPop => _stack.length > 1; @@ -134,30 +136,61 @@ class RouterOutletState extends State { void didChangeDependencies() { super.didChangeDependencies(); _scope = _OutletScope.of(context)!; + final delegate = Router.maybeOf(context)?.routerDelegate; + _manager = delegate is ModularRouterDelegate ? delegate.manager : null; // (Re)seed the sub-stack when the top route (the scope URL) changes. if (_seedUri != _scope.uri) { _seedUri = _scope.uri; - for (final e in _stack) { - if (!e.completer.isCompleted) e.completer.complete(null); - } - _stack - ..clear() - ..add(_entry(_scope.uri, _scope.arguments)); + _clear(); + _add(_entry(_scope.uri, _scope.arguments)); } } - _OutletEntry _entry(Uri uri, Object? arguments) => _OutletEntry( - uri, - ValueKey('outlet-${identityHashCode(this)}-${_seq++}'), - Completer(), - arguments, - ); + @override + void dispose() { + _clear(); + super.dispose(); + } + + /// Creates an entry and ACTIVATES its owning feature module(s) in the + /// [ModuleManager] — like a root stack entry — so a feature reached only + /// through this outlet gets its binds before its page builds. + _OutletEntry _entry(Uri uri, Object? arguments) { + final id = 'outlet-${identityHashCode(this)}-${_seq++}'; + final tags = _scope.routes.match(uri)?.last.route.ownerTags ?? const []; + return _OutletEntry( + uri, + ValueKey(id), + id, + Completer(), + arguments, + tags, + ); + } + + void _add(_OutletEntry entry) { + _manager?.enter(entry.id, entry.ownerTags); + _stack.add(entry); + } + + /// Completes [entry]'s future and releases its feature module(s). + void _detach(_OutletEntry entry, Object? result) { + if (!entry.completer.isCompleted) entry.completer.complete(result); + _manager?.leave(entry.id, entry.ownerTags); + } + + void _clear() { + for (final e in _stack) { + _detach(e, null); + } + _stack.clear(); + } /// Pushes [path] onto THIS outlet's sub-stack (the parent shell persists); /// the returned future completes with the value passed to `pop(result)`. Future push(String path, {Object? arguments}) { final entry = _entry(Uri.parse(path), arguments); - setState(() => _stack.add(entry)); + setState(() => _add(entry)); _reportLocation(); return entry.completer.future.then((value) => value as T?); } @@ -178,13 +211,12 @@ class RouterOutletState extends State { /// Replaces this outlet's WHOLE sub-stack with [path] — the shell "navigate" /// (a bottom-bar tab switch swaps the body without stacking history). void navigate(String path, {Object? arguments}) { - for (final entry in _stack) { - if (!entry.completer.isCompleted) entry.completer.complete(null); - } + final entry = _entry(Uri.parse(path), arguments); setState(() { - _stack - ..clear() - ..add(_entry(Uri.parse(path), arguments)); + _add(entry); + _stack.remove(entry); + _clear(); + _stack.add(entry); }); _reportLocation(); } @@ -192,11 +224,10 @@ class RouterOutletState extends State { /// Replaces this outlet's TOP sub-route with [path]. Future replace(String path, {Object? arguments}) { if (_stack.isNotEmpty) { - final top = _stack.removeLast(); - if (!top.completer.isCompleted) top.completer.complete(null); + _detach(_stack.removeLast(), null); } final entry = _entry(Uri.parse(path), arguments); - setState(() => _stack.add(entry)); + setState(() => _add(entry)); _reportLocation(); return entry.completer.future.then((value) => value as T?); } @@ -208,8 +239,7 @@ class RouterOutletState extends State { !predicate( RouteState(uri: _stack.last.uri, arguments: _stack.last.arguments), )) { - final top = _stack.removeLast(); - if (!top.completer.isCompleted) top.completer.complete(null); + _detach(_stack.removeLast(), null); changed = true; } if (changed && mounted) setState(() {}); @@ -223,11 +253,10 @@ class RouterOutletState extends State { Object? arguments, }) { if (_stack.isNotEmpty) { - final top = _stack.removeLast(); - if (!top.completer.isCompleted) top.completer.complete(result); + _detach(_stack.removeLast(), result); } final entry = _entry(Uri.parse(path), arguments); - setState(() => _stack.add(entry)); + setState(() => _add(entry)); _reportLocation(); return entry.completer.future.then((value) => value as T?); } @@ -240,7 +269,7 @@ class RouterOutletState extends State { Object? arguments, }) { final entry = _entry(Uri.parse(path), arguments); - _stack.add(entry); + _add(entry); while (_stack.length > 1 && !predicate( RouteState( @@ -248,8 +277,7 @@ class RouterOutletState extends State { arguments: _stack[_stack.length - 2].arguments, ), )) { - final removed = _stack.removeAt(_stack.length - 2); - if (!removed.completer.isCompleted) removed.completer.complete(null); + _detach(_stack.removeAt(_stack.length - 2), null); } setState(() {}); _reportLocation(); @@ -270,8 +298,7 @@ class RouterOutletState extends State { void _remove(Key? key, Object? result) { final index = _stack.indexWhere((e) => e.key == key); if (index == -1) return; - final entry = _stack.removeAt(index); - if (!entry.completer.isCompleted) entry.completer.complete(result); + _detach(_stack.removeAt(index), result); if (mounted) setState(() {}); _reportLocation(); } @@ -317,9 +344,18 @@ class RouterOutletState extends State { } class _OutletEntry { - _OutletEntry(this.uri, this.key, this.completer, this.arguments); + _OutletEntry( + this.uri, + this.key, + this.id, + this.completer, + this.arguments, + this.ownerTags, + ); final Uri uri; final LocalKey key; + final String id; + final List ownerTags; final Completer completer; final Object? arguments; } diff --git a/test/outlet_feature_binds_test.dart b/test/outlet_feature_binds_test.dart new file mode 100644 index 00000000..0799bd2e --- /dev/null +++ b/test/outlet_feature_binds_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Feature-level repositories, bound only while their feature is active. +class CatalogRepo {} + +class SearchRepo implements Disposable { + static int disposed = 0; + @override + void dispose() => disposed++; +} + +final catalogModule = createModule( + path: '/catalog', + register: (c) => c + ..addSingleton(CatalogRepo.new) + ..route( + '/', + child: (ctx, s) { + inject(); + return Column( + children: [ + const Text('catalog'), + TextButton( + onPressed: () => ctx.navigate('/search/'), + child: const Text('go-search'), + ), + TextButton( + onPressed: () => ctx.pushNamed('/search/'), + child: const Text('push-search'), + ), + ], + ); + }, + ), +); + +final searchModule = createModule( + path: '/search', + register: (c) => c + ..addSingleton(SearchRepo.new) + ..route( + '/', + child: (ctx, s) { + inject(); + return TextButton( + onPressed: () => ctx.navigate('/catalog/'), + child: const Text('search'), + ); + }, + ), +); + +/// A shell at `/` whose body is a RouterOutlet hosting the feature modules. +final shellModule = createModule( + register: (c) => c.route( + '/', + child: (ctx, s) => const Scaffold(body: RouterOutlet()), + children: (sub) => sub + ..module(catalogModule) + ..module(searchModule), + ), +); + +Future _boot(WidgetTester tester) async { + final boot = bootstrapModule(shellModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + manager: boot.manager, + initialRoute: '/catalog/', + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('navigate inside an outlet activates the target feature binds', ( + tester, + ) async { + SearchRepo.disposed = 0; + await _boot(tester); + expect(find.text('catalog'), findsOneWidget); + + await tester.tap(find.text('go-search')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('search'), findsOneWidget); + + // Leaving the feature releases its binds. + await tester.tap(find.text('search')); + await tester.pumpAndSettle(); + expect(find.text('catalog'), findsOneWidget); + expect(SearchRepo.disposed, 1); + }); + + testWidgets('pushNamed inside an outlet activates the target feature binds', ( + tester, + ) async { + await _boot(tester); + await tester.tap(find.text('push-search')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('search'), findsOneWidget); + }); +} From dd71f76d64fcef5ab2aff3de64bb937be54c8cc8 Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Sat, 19 Sep 2026 11:39:55 -0300 Subject: [PATCH 3/3] chore: release 7.1.1 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 +++++++++ pubspec.yaml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3120122a..e786d979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 7.1.1 + +- **Fix: feature modules reached through a `RouterOutlet` now get their binds.** + The outlet keeps its own sub-stack but never reported its entries to the + module manager, so a feature mounted as a child of a shell (e.g. `/` with a + `RouterOutlet` body) failed with "X not registered" when navigated to inside + the outlet. Outlet entries now activate and release their feature modules on + push, navigate, replace, pop and dispose, just like root stack entries. + ## 7.1.0 - **Feature modules can now consume shared/core dependencies directly.** A diff --git a/pubspec.yaml b/pubspec.yaml index ed5faa2d..5c9675ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_modular description: Smart project structure with dependency injection and route management for Flutter. -version: 7.1.0 +version: 7.1.1 homepage: https://github.com/Flutterando/modular repository: https://github.com/Flutterando/modular issue_tracker: https://github.com/Flutterando/modular/issues