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
7 changes: 7 additions & 0 deletions packages/view_model/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 1.0.2
- Fix listener mutation safety during notification dispatch.
- Fix recreate failure handling to keep previous instance valid.
- Fix disposal cleanup in instance manager and pause providers.
- Improve cached access error handling and DevTools active state reporting.
- Internal cleanup: remove duplicated binding extension implementations.

## 1.0.1
- Fix: DevToolsService not clearing singleton `_instance` on dispose
- Fix: typo "suucess" → "success" in DevToolsService log
Expand Down
4 changes: 4 additions & 0 deletions packages/view_model/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

analyzer:
exclude:
- example/**

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
Expand Down
2 changes: 1 addition & 1 deletion packages/view_model/lib/src/devtool/service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ class DevToolsService {
'id': vm.instanceId,
'type': vm.typeName,
'label': '${vm.typeName}\n${vm.key ?? vm.instanceId}',
'isActive': true,
'isActive': !vm.isDisposed,
})
.toList(),
'edges': dependencies,
Expand Down
14 changes: 10 additions & 4 deletions packages/view_model/lib/src/get_instance/auto_dispose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class AutoDisposeInstanceController {
/// the given action to each ViewModel instance.
void performForAllInstances(void Function(ViewModel viewModel) action) {
for (final notifier in _instanceNotifiers) {
if (notifier.instance is ViewModel) {
if (!notifier.isDisposed && notifier.instance is ViewModel) {
action(notifier.instance as ViewModel);
}
}
Expand All @@ -184,7 +184,11 @@ class AutoDisposeInstanceController {
/// ```
void recycle(Object instance) {
_instanceNotifiers.removeWhere((e) {
if (e.instance == instance) {
if (!e.isDisposed && e.instance == instance) {
final listener = _notifierListeners.remove(e);
if (listener != null) {
e.removeListener(listener);
}
e.unbindAll();
return true;
} else {
Expand All @@ -199,7 +203,7 @@ class AutoDisposeInstanceController {
/// binders remain, the instance can be recycled automatically.
void unbindInstance(Object instance) {
for (final e in _instanceNotifiers) {
if (e.instance == instance) {
if (!e.isDisposed && e.instance == instance) {
e.unbind(viewModelBinding.id);
break;
}
Expand Down Expand Up @@ -227,10 +231,12 @@ class AutoDisposeInstanceController {
if (!e.isDisposed && e.instance is ViewModel) {
(e.instance as ViewModel).refHandler.removeRef(viewModelBinding);
}
e.unbind(viewModelBinding.id);
// Remove listener before unbind, because unbind may trigger _recycle()
// which disposes the ChangeNotifier, making removeListener fail.
if (_notifierListeners.containsKey(e)) {
e.removeListener(_notifierListeners[e]!);
}
e.unbind(viewModelBinding.id);
}
_notifierListeners.clear();
_instanceNotifiers.clear();
Expand Down
33 changes: 28 additions & 5 deletions packages/view_model/lib/src/get_instance/manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/// @author luwenjie on 2025/3/25 12:23:33
library;

import 'package:flutter/foundation.dart';
import 'package:view_model/src/view_model/state_store.dart';

import 'store.dart';
Expand Down Expand Up @@ -52,7 +53,11 @@ class InstanceManager {
T t, {
T Function()? builder,
}) {
final Store<T> s = _stores[T];
final store = _stores[T];
if (store is! Store<T>) {
throw ViewModelError("Cannot recreate $T instance. Store not found.");
}
final Store<T> s = store;
return s.recreate(
t,
builder: builder,
Expand All @@ -78,10 +83,22 @@ class InstanceManager {
///
/// Returns the [Store] instance for type [T].
Store<T> _getStore<T>() {
Store<T>? s = _stores[T];
s ??= Store<T>();
_stores[T] = s;
return s;
final cached = _stores[T];
if (cached is Store<T>) {
return cached;
}

late final Store<T> created;
created = Store<T>(
onStoreEmpty: () {
final current = _stores[T];
if (!identical(current, created) || !created.isEmpty) return;
_stores.remove(T);
created.dispose();
},
);
_stores[T] = created;
return created;
}

/// Gets a ViewModel instance directly.
Expand Down Expand Up @@ -175,6 +192,12 @@ class InstanceManager {
}
return _getStore<T>().getInstancesByTag(tag);
}

@visibleForTesting
int get debugStoreCount => _stores.length;

@visibleForTesting
bool debugHasStoreFor<T>() => _stores[T] != null;
}

/// Factory configuration for ViewModel instance creation and retrieval.
Expand Down
44 changes: 37 additions & 7 deletions packages/view_model/lib/src/get_instance/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ class _Undefined {
/// The store maintains a map of instances keyed by their unique identifiers
/// and automatically handles cleanup when instances are no longer needed.
class Store<T> {
Store({void Function()? onStoreEmpty}) : _onStoreEmpty = onStoreEmpty;

final void Function()? _onStoreEmpty;

/// Stream controller for broadcasting instance creation events.
final _streamController = StreamController<InstanceHandle<T>>.broadcast();

/// Map of cached instances keyed by their unique identifiers.
final Map<Object, InstanceHandle<T>> _instances = {};
bool _disposed = false;

bool get isEmpty => _instances.isEmpty;

/// Finds the most recently created instance, optionally filtered by tag.
///
Expand Down Expand Up @@ -106,6 +113,9 @@ class Store<T> {
case InstanceAction.dispose:
_instances.remove(notifier.arg.key);
notifier.removeListener(onNotify);
if (_instances.isEmpty) {
_onStoreEmpty?.call();
}
break;
case InstanceAction.recreate:
break;
Expand Down Expand Up @@ -136,6 +146,9 @@ class Store<T> {
/// Throws [ViewModelError] if no cached instance exists and no
/// builder is provided.
InstanceHandle<T> getNotifier({required InstanceFactory<T> factory}) {
if (_disposed) {
throw ViewModelError("Store<$T> has been disposed.");
}
final realKey = factory.arg.key ?? Object();
final bindingId = factory.arg.bindingId;
final arg = factory.arg.copyWith(
Expand Down Expand Up @@ -199,6 +212,13 @@ class Store<T> {
);
return find.recreate(builder: builder);
}

void dispose() {
if (_disposed) return;
_disposed = true;
_instances.clear();
_streamController.close();
}
}

/// Handle for managing a ViewModel instance and its lifecycle.
Expand Down Expand Up @@ -235,6 +255,7 @@ class InstanceHandle<T> with ChangeNotifier {

/// The actual ViewModel instance (null when disposed).
late T? _instance;
bool _disposed = false;

bool get isDisposed => _instance == null;

Expand Down Expand Up @@ -334,8 +355,14 @@ class InstanceHandle<T> with ChangeNotifier {
T recreate({
T Function()? builder,
}) {
onDispose();
_instance = (builder?.call()) ?? factory.call();
final previous = _instance;
if (previous == null) {
throw ViewModelError(
"Cannot recreate $T instance. Instance is disposed.");
}
final recreated = (builder?.call()) ?? factory.call();
_tryCallInstanceDispose(previous);
_instance = recreated;
onCreate(arg);
_action = InstanceAction.recreate;
notifyListeners();
Expand Down Expand Up @@ -367,12 +394,12 @@ class InstanceHandle<T> with ChangeNotifier {
/// This method attempts to call the onDispose lifecycle method if the
/// instance implements [InstanceLifeCycle]. Errors are logged but don't
/// prevent the disposal process.
void _tryCallInstanceDispose() {
if (_instance is InstanceLifeCycle) {
void _tryCallInstanceDispose(Object? target) {
if (target is InstanceLifeCycle) {
try {
(_instance as InstanceLifeCycle).onDispose(arg);
target.onDispose(arg);
} catch (e) {
viewModelLog("${_instance.runtimeType} onDispose error $e");
viewModelLog("${target.runtimeType} onDispose error $e");
}
}
}
Expand All @@ -382,8 +409,11 @@ class InstanceHandle<T> with ChangeNotifier {
/// This method calls the instance's disposal lifecycle method,
/// clears all watchers, and nullifies the instance reference.
void onDispose() {
_tryCallInstanceDispose();
if (_disposed) return;
_disposed = true;
_tryCallInstanceDispose(_instance);
_instance = null;
super.dispose();
}
}

Expand Down
7 changes: 3 additions & 4 deletions packages/view_model/lib/src/view_model/pause_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ class AppPauseProvider with ViewModelBindingPauseProvider {

@override
void dispose() {
super.dispose();
_subscription?.cancel();
_subscription = null;
_controller.close();
super.dispose();
}
}

Expand Down Expand Up @@ -80,9 +79,9 @@ class TickerModePauseProvider with ViewModelBindingPauseProvider {

@override
void dispose() {
super.dispose();
_notifier?.removeListener(_onChange);
_notifier = null;
super.dispose();
}
}

Expand Down Expand Up @@ -120,8 +119,8 @@ class PageRoutePauseProvider with ViewModelBindingPauseProvider, RouteAware {

@override
void dispose() {
super.dispose();
unsubscribe(_observer);
super.dispose();
}

@override
Expand Down
18 changes: 13 additions & 5 deletions packages/view_model/lib/src/view_model/view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ class ChangeNotifierViewModel extends ChangeNotifier with ViewModel {}
/// }
/// }
/// ```
mixin class ViewModel implements InstanceLifeCycle, Listenable {
mixin class ViewModel
implements InstanceLifeCycle, Listenable, ViewModelBindingHost {
/// Returns the [ViewModelBinding] interface for accessing other ViewModels.
///
/// This property allows you to use
Expand Down Expand Up @@ -135,7 +136,7 @@ mixin class ViewModel implements InstanceLifeCycle, Listenable {
static T? maybeReadCached<T extends ViewModel>({Object? key, Object? tag}) {
try {
return readCached<T>(key: key, tag: tag);
} catch (e) {
} on ViewModelError {
return null;
}
}
Expand Down Expand Up @@ -376,7 +377,9 @@ mixin class ViewModel implements InstanceLifeCycle, Listenable {
/// is provided, errors are logged. This prevents one listener from
/// affecting others.
void notifyListeners() {
for (final element in _listeners) {
final listeners = List<VoidCallback>.of(_listeners);
for (final element in listeners) {
if (!_listeners.contains(element)) continue;
try {
element.call();
} catch (e, stack) {
Expand Down Expand Up @@ -634,7 +637,10 @@ abstract class StateViewModel<T> with ViewModel {
if (_isDisposed) return;

// Phase 1: Notify state listeners with previous and current state
for (final element in _stateListeners) {
final stateListeners =
List<Function(T? previous, T state)>.of(_stateListeners);
for (final element in stateListeners) {
if (!_stateListeners.contains(element)) continue;
try {
element.call(event.previousState, event.currentState);
} catch (e, stack) {
Expand All @@ -648,7 +654,9 @@ abstract class StateViewModel<T> with ViewModel {
}

// Phase 2: Notify general listeners
for (final element in _listeners) {
final listeners = List<VoidCallback>.of(_listeners);
for (final element in listeners) {
if (!_listeners.contains(element)) continue;
try {
element.call();
} catch (e, stack) {
Expand Down
Loading
Loading