From 22b846e08001a5a1cb681b38085e5f1dc949b72f Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Tue, 23 Jun 2026 22:13:25 -0300 Subject: [PATCH 1/2] feat(di): feature modules resolve shared/core deps (release 7.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire _bind to auto_injector 2.2.0's opt-in upward resolution (addInjector(injector, resolveUpward: true)) so a feature module's module-level binds resolve dependencies registered in a root-owned shared/core module — closing the asymmetry where only page-scoped `provide` could reach the core. Local binds still shadow same-typed core binds (core is the fallback). Bumps auto_injector constraint to >=2.2.0 and adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 +++++++ lib/src/module/module.dart | 2 +- pubspec.yaml | 4 +- test/feature_resolves_core_test.dart | 71 ++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 test/feature_resolves_core_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bfc0be8..3120122a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 7.1.0 + +- **Feature modules can now consume shared/core dependencies directly.** A + module-level bind inside a feature (a module with a `path`) — `addSingleton`, + `add`, `addLazySingleton` — now resolves dependencies registered in a + root-owned shared module (a path-less `module(...)`, the "core"). Previously + only page-scoped `provide` binds could reach the core; a feature's + module-level binds ran in a leaf injector that couldn't see root-owned binds, + forcing shared deps to be threaded in by hand. This removes that asymmetry: + **a core consumer can be a `provide`, the core itself, OR a feature + module-level bind** — all alike. A feature's own bind still shadows a + same-typed core bind (local wins; core is the fallback). +- Requires `auto_injector >= 2.2.0`, which adds the opt-in upward resolution + (`addInjector(child, resolveUpward: true)`) this builds on, fixes a + dispose-listener accumulation in its layer graph, and adds an + `UpwardResolutionCycle` guard against mutual upward links. + ## 7.0.3 - **Customizable route transitions.** `route(transition:)` and the new app-wide diff --git a/lib/src/module/module.dart b/lib/src/module/module.dart index 5c58ac04..0b7c65b5 100644 --- a/lib/src/module/module.dart +++ b/lib/src/module/module.dart @@ -187,7 +187,7 @@ class ModuleManager { } root ..uncommit() - ..addInjector(injector) + ..addInjector(injector, resolveUpward: true) ..commit(); } diff --git a/pubspec.yaml b/pubspec.yaml index a5a26a7a..ed5faa2d 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.0.3 +version: 7.1.0 homepage: https://github.com/Flutterando/modular repository: https://github.com/Flutterando/modular issue_tracker: https://github.com/Flutterando/modular/issues @@ -9,7 +9,7 @@ environment: sdk: ">=3.11.0 <4.0.0" dependencies: - auto_injector: ">=2.1.0 <3.0.0" + auto_injector: ">=2.2.0 <3.0.0" meta: ">=1.3.0 <2.0.0" web: ">=0.5.0 <2.0.0" flutter: diff --git a/test/feature_resolves_core_test.dart b/test/feature_resolves_core_test.dart new file mode 100644 index 00000000..40928f92 --- /dev/null +++ b/test/feature_resolves_core_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A shared (root-owned) dependency, registered in a path-less "core" module. +class ApiConfig { + ApiConfig(this.baseUrl); + final String baseUrl; +} + +/// A FEATURE module-level service whose constructor needs the core [ApiConfig]. +/// Before 7.1.0 this could not resolve — a feature's binds ran in a leaf +/// injector blind to root-owned binds. With `auto_injector >= 2.2.0` and +/// `_bind`'s `resolveUpward`, it resolves the core dep. +class FeatureService { + FeatureService(this.config); + final ApiConfig config; +} + +final coreModule = createModule( + register: (c) => c.addInstance(ApiConfig('https://api.example')), +); + +String? resolvedBaseUrl; + +final featureModule = createModule( + path: '/feature', + register: (c) => c + // MODULE-LEVEL bind (eager singleton), depends on the core ApiConfig. + ..addSingleton(FeatureService.new) + ..route('/', child: (ctx, s) { + resolvedBaseUrl = inject().config.baseUrl; + return const Scaffold(body: Text('feature')); + }), +); + +final rootModule = createModule( + register: (c) => c + ..route('/', child: (ctx, s) => Scaffold( + body: TextButton( + onPressed: () => ctx.pushNamed('/feature'), + child: const Text('open'), + ), + )) + ..module(coreModule) + ..module(featureModule), +); + +void main() { + testWidgets( + 'a feature module-level bind resolves a root-owned (core) dependency', + (tester) async { + resolvedBaseUrl = null; + final boot = bootstrapModule(rootModule); + await tester.pumpWidget(MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + manager: boot.manager, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('feature'), findsOneWidget); + expect(resolvedBaseUrl, 'https://api.example'); + }, + ); +} From 8b0e54aa17b29abd5c37f99cf4d5e3af10a0f41c Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Tue, 23 Jun 2026 22:16:05 -0300 Subject: [PATCH 2/2] docs(di): cross-module resolution + async bootstrap pattern Document that a feature's binds resolve root-owned/core deps (7.1.0), the local-shadows-core precedence, and the "await once in a Future builder" idiom for Hive/SharedPreferences so main stays thin and features take no parameters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../flutter_modular/dependency-injection.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/doc/docs/flutter_modular/dependency-injection.md b/doc/docs/flutter_modular/dependency-injection.md index fd09b007..1be1a591 100644 --- a/doc/docs/flutter_modular/dependency-injection.md +++ b/doc/docs/flutter_modular/dependency-injection.md @@ -100,6 +100,92 @@ with a single page, prefer page‑scoped [`provide`](./state-management.md) over bind. ::: +## Sharing dependencies across modules + +Put a dependency that many features need — a config, an HTTP client, a session — in +a **root‑owned** (path‑less) "core" module, and depend on it **by type** anywhere. +You never thread it by hand: a feature's own binds, and its page‑scoped +[`provide`](./state-management.md) binds, both resolve it from the graph. + +```dart +// CORE (root-owned): the shared dependency, registered once +final coreModule = createModule( + register: (c) => c.addInstance(ApiConfig('https://api.example')), +); + +// FEATURE: its OWN bind depends on the core ApiConfig — supplied automatically +final ordersModule = createModule( + path: '/orders', + register: (c) => c + ..add(OrdersGateway.new) // OrdersGateway(ApiConfig) ← from core + ..route('/', child: (ctx, state) => const OrdersPage()), +); + +final appModule = createModule( + register: (c) => c + ..module(coreModule) // included once at the root → visible to every feature + ..module(ordersModule), // takes NO parameters; resolves ApiConfig by type +); +``` + +You include the shared module **once at the root** — there is no need to re‑import it +in each feature (simpler than Angular's per‑feature `SharedModule`). Because the binds +are root‑owned, every feature sees them. + +:::info Precedence — local shadows the core +If a feature registers its **own** bind of the same type, that local bind wins; the +core bind is the fallback. So a feature can override a shared default for itself +without affecting the rest of the app. +::: + +:::note Requires 7.1.0+ +Resolving a core dependency from a feature's **module‑level** bind needs +flutter_modular **7.1.0** (`auto_injector >= 2.2.0`). Page‑scoped `provide` binds +resolved the core in earlier versions too. +::: + +## Async bootstrap (Hive, SharedPreferences, a DB connection) + +`register` is **synchronous**, but some shared dependencies need an `await` to come up +— opening a Hive box, reading `SharedPreferences`, connecting a database. The idiom is +to do that `await` **once**, in a builder that *returns the module*, and capture the +ready instances in its closure. `main` stays thin and features take no parameters: + +```dart +Future buildCoreModule() async { + await Hive.initFlutter(); + final box = await Hive.openBox('app'); // awaited once, here + return createModule( + register: (c) => c + // the raw box stays private in the closure — expose the CONTRACT + ..addInstance(HiveSettingsRepository(box)), + ); +} + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final core = await buildCoreModule(); // one await, one instance + runApp(ModularApp(module: buildAppModule(core), child: const AppRoot())); +} + +Module buildAppModule(Module core) => createModule( + register: (c) => c + ..module(core) // shared, root-owned — features resolve it by type + ..module(ordersModule), // no parameters threaded in +); +``` + +Blocking on the bootstrap once, then registering the *ready* instances with +`addInstance`, keeps the rest of the app synchronous — no loading states sprinkled +through your widgets. Combined with cross‑module resolution above, `ordersModule`'s +binds resolve `SettingsRepository` from the core with **zero** parameter threading. + +:::warning Build the async module once +`buildCoreModule()` returns a **new** module each call, and composition dedups by +identity — so call it **once** and reference that single instance. Calling it twice +would open the Hive box twice and register two distinct modules. +::: + ## Next - Bind state to a page's lifecycle → [State management](./state-management.md)