Skip to content
Back to skills

Dart Lifecycle Disposed Flag Overload

ASecurity

Fix Dart/Flutter services where calling start() after stop() is a silent no-op because stop() sets a _disposed (or similar) flag that start()'s guard short-circuits on. Use when: (1) A repository/service/controller has startListening/stopListening, subscribe/unsubscribe, open/close, or similar lifecycle methods, (2) Re-opening the service after closing it appears to do nothing — no subscription, no events, no error, (3) Unit tests that only exercise a single mount/open/start cycle pass while ...

  • 265 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added May 27, 2026
documentationgoapi

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add divinevideo/divine-mobile --skill dart-lifecycle-disposed-flag-overload --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dart Lifecycle Disposed Flag Overload?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dart Lifecycle Disposed Flag Overload
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/divinevideo-dart-lifecycle-disposed-flag-overload/badge)](https://www.skillsdirectory.com/skills/divinevideo-dart-lifecycle-disposed-flag-overload)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dart-lifecycle-disposed-flag-overload
description: |
  Fix Dart/Flutter services where calling start() after stop() is a silent no-op
  because stop() sets a _disposed (or similar) flag that start()'s guard short-circuits
  on. Use when: (1) A repository/service/controller has startListening/stopListening,
  subscribe/unsubscribe, open/close, or similar lifecycle methods, (2) Re-opening
  the service after closing it appears to do nothing — no subscription, no events,
  no error, (3) Unit tests that only exercise a single mount/open/start cycle pass
  while the real app breaks on the second visit to a screen, (4) A boolean flag
  is used both for "in the middle of tearing down this instance forever" AND for
  "currently stopped, can be re-started". Common in Riverpod/Bloc-driven screens
  that wire startListening() in initState and stopListening() in dispose — the
  second time the user visits the screen, nothing happens.
author: Claude Code
version: 1.0.0
date: 2026-04-05
---

# Dart Lifecycle `_disposed` Flag Overload

## Problem

A Dart/Flutter class with start/stop lifecycle methods sets a "disposed"/"stopped"
boolean inside `stop()`, but the guard clause in `start()` short-circuits whenever
that boolean is true. After a stop→start cycle, `start()` silently returns without
doing anything. The class has two distinct concerns conflated into one flag:

1. **"This instance is permanently torn down"** (e.g. user switched accounts, object
   is being discarded) — should prevent any further work.
2. **"Currently not listening, but could be re-started"** (e.g. user navigated away
   from the inbox screen and may come back) — must allow future `start()` calls.

When those concerns share a single flag, the second concern silently breaks the
first.

## Context / Trigger Conditions

- A class (repository, service, controller, bloc, cubit, notifier) has methods like:
  - `startListening()` / `stopListening()`
  - `subscribe()` / `unsubscribe()`
  - `connect()` / `disconnect()`
  - `open()` / `close()`
- `stop()` includes a line like `_disposed = true;` or `_stopped = true;`
- `start()` begins with a guard like:
  ```dart
  if (_subscription != null || _disposed || !isInitialized) return;
  ```
- Symptom: the feature works on first open, breaks on every subsequent open
- Unit tests that mock the dependencies pass because they only exercise one cycle
  OR because the mock doesn't model the real instance's internal state
- Manual QA finds that leaving and returning to a screen breaks the feature silently
  (no error thrown, no log emitted, no visible indication)

## Solution

**Separate the two concerns.** Reserve the permanent-teardown flag for the code path
that actually tears the instance down for good (typically a `_resetState()` called on
user-switch or full logout), and do NOT set it inside `stop()`.

### Before (broken)

```dart
class MyRepository {
  bool _disposed = false;
  StreamSubscription<Event>? _subscription;

  void startListening() {
    if (_subscription != null || _disposed || !isInitialized) return;
    _subscription = _client.subscribe(...).listen(...);
  }

  Future<void> stopListening() async {
    _disposed = true;                    // ← THE BUG
    await _subscription?.cancel();
    _subscription = null;
  }

  void _resetState() {
    _disposed = true;
    // ... wipe credentials ...
    _disposed = false;
  }
}
```

After `stopListening()`, `_disposed == true` forever until `_resetState()` is called
(which only happens on user switch). Any subsequent `startListening()` hits the guard
and returns silently.

### After (fixed)

```dart
class MyRepository {
  bool _disposed = false;
  StreamSubscription<Event>? _subscription;

  void startListening() {
    // Guard still checks _disposed for the permanent-teardown case — that
    // window is only open during _resetState()'s synchronous body.
    if (_subscription != null || _disposed || !isInitialized) return;
    _subscription = _client.subscribe(...).listen(...);
  }

  Future<void> stopListening() async {
    // Do NOT set _disposed here — _disposed is reserved for _resetState()
    // (permanent teardown, e.g. user switch). Setting it would make a
    // subsequent startListening() call a silent no-op and break re-open
    // flows like "user leaves the screen and comes back later".
    await _subscription?.cancel();
    _subscription = null;
  }

  void _resetState() {
    _disposed = true;
    // ... wipe credentials, cancel subscription, etc. ...
    _disposed = false;
  }
}
```

The `_subscription != null` half of the guard is still sufficient to make
`startListening()` idempotent against double-calls within a single listening lifetime.

## Verification

1. **Add a regression test** that exercises start → stop → start and asserts the
   start work happened twice:

   ```dart
   test('startListening after stopListening re-opens the subscription', () async {
     final repo = createRepository();
     repo.initialize(...);

     repo.startListening();
     await repo.stopListening();
     repo.startListening();

     // Both opens must hit the client.
     verify(() => mockClient.subscribe(any(), ...)).called(2);

     await repo.stopListening();
   });
   ```

2. **Manual QA:** visit the screen that drives the lifecycle, back out of it,
   visit it again. The feature should work on the second visit identically to
   the first.

3. **Run the existing test for the permanent-teardown path** (e.g. user switch /
   `_resetState()`) and confirm it still passes. The fix should not affect that path.

## Example

From divine-mobile (PR #2769, April 2026): `DmRepository` drove NIP-17 gift-wrap
subscription lifecycle from the inbox screen's `initState`/`dispose`. On the second
visit to the inbox, DMs silently stopped arriving. Root cause: `stopListening()` had
`_disposed = true;` as its first line. Fix: delete that line, leave an explanatory
comment, add a regression test that asserts `mockNostrClient.subscribe` was called
twice after an open → close → open cycle. Commit
`bd1420eb3 fix(dm): allow startListening() to succeed after stopListening()`.

## Notes

- **Why mocks hide this bug:** unit tests that mock the dependency (e.g. a mock
  `NostrClient`) only verify that the repository calls `subscribe()` once when
  `startListening()` is called. They don't exercise the real state machine across
  multiple cycles unless the test explicitly cycles start→stop→start and verifies
  the second start also called `subscribe`. Add that cycle to your lifecycle test
  suite preemptively.
- **Alternative name for the flag:** if you need two flags because both concerns
  genuinely exist, name them for their actual meaning: `_permanentlyDisposed` (or
  `_torn_down`) vs `_isListening` (or `_started`). A single `bool` with an overloaded
  meaning is the root smell.
- **Riverpod/Bloc lifecycle binding:** this bug is especially common when a screen
  wires `startListening()` in `initState` and `stopListening()` in `dispose` and
  the user can leave and return to the screen. If that flow is new, always add a
  "visit twice" test to your widget test for that screen.
- **Watch for asymmetric reconnect paths:** `onDone` callbacks on cancelled streams
  may also read the flag and decide whether to schedule a reconnect. After separating
  the flags, audit every read of the old flag to confirm the new semantics still
  match the callsite's intent.

## References

- Dart `StreamSubscription.cancel()` docs: https://api.dart.dev/stable/dart-async/StreamSubscription/cancel.html
  (cancellation does not deliver a `done` event to the listener, which is relevant
  when auditing onDone reconnect paths after this fix.)
- Flutter lifecycle (`State.initState` / `State.dispose`): https://api.flutter.dev/flutter/widgets/State-class.html

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…