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
44 changes: 44 additions & 0 deletions .cockpit/tasks.json
Original file line number Diff line number Diff line change
@@ -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"] }
]
}
]
}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
102 changes: 69 additions & 33 deletions lib/src/navigation/outlet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +114,7 @@ class RouterOutletState extends State<RouterOutlet> {
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;
Expand All @@ -134,30 +136,61 @@ class RouterOutletState extends State<RouterOutlet> {
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<Object?>(),
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<Object?>(),
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<T?> push<T extends Object?>(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?);
}
Expand All @@ -178,25 +211,23 @@ class RouterOutletState extends State<RouterOutlet> {
/// 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();
}

/// Replaces this outlet's TOP sub-route with [path].
Future<T?> replace<T extends Object?>(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?);
}
Expand All @@ -208,8 +239,7 @@ class RouterOutletState extends State<RouterOutlet> {
!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(() {});
Expand All @@ -223,11 +253,10 @@ class RouterOutletState extends State<RouterOutlet> {
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?);
}
Expand All @@ -240,16 +269,15 @@ class RouterOutletState extends State<RouterOutlet> {
Object? arguments,
}) {
final entry = _entry(Uri.parse(path), arguments);
_stack.add(entry);
_add(entry);
while (_stack.length > 1 &&
!predicate(
RouteState(
uri: _stack[_stack.length - 2].uri,
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();
Expand All @@ -270,8 +298,7 @@ class RouterOutletState extends State<RouterOutlet> {
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();
}
Expand Down Expand Up @@ -317,9 +344,18 @@ class RouterOutletState extends State<RouterOutlet> {
}

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<String> ownerTags;
final Completer<Object?> completer;
final Object? arguments;
}
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
110 changes: 110 additions & 0 deletions test/outlet_feature_binds_test.dart
Original file line number Diff line number Diff line change
@@ -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>(CatalogRepo.new)
..route(
'/',
child: (ctx, s) {
inject<CatalogRepo>();
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>(SearchRepo.new)
..route(
'/',
child: (ctx, s) {
inject<SearchRepo>();
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<void> _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);
});
}
Loading