Repository: felangel/bloc

Issue:
BlocSelector fails to rebuild on selector change, causing outdated state
### Description

When using BlocSelector, updating the selector logic dynamically does not trigger a rebuild or reflect the new selection criteria immediately. It appears that BlocSelector continues using the old selector function until a new event is emitted in the associated bloc.

This leads to stale state being shown, even though the selector has changed — as long as the bloc instance remains the same and no new event is dispatched.

---

### 🔁 Steps to Reproduce

1. Create a widget using `BlocSelector` with a dynamic `selector` function. (ie where selector function logic may vary)
2. Inside a `StatefulWidget`, toggle the selector logic using `setState`.
3. Keep the bloc instance the same.
4. Notice that `BlocSelector` continues using the **previous selector logic** until a new event is emitted to the bloc.

#### Minimal Example

```dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}

class SelectorDemoPage extends StatelessWidget {
  const SelectorDemoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => CounterCubit(),
      child: Scaffold(
        body: Center(
          child: Container(height: 300, width: 500, child: SelectorDemo()),
        ),
      ),
    );
  }
}

class SelectorDemo extends StatefulWidget {
  @override
  _SelectorDemoState createState() => _SelectorDemoState();
}

class _SelectorDemoState extends State<SelectorDemo> {
  bool useEvenSelector = true;

  int evenSelector(int state) => state.isEven ? state : -1;
  int oddSelector(int state) => state.isOdd ? state : -1;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        ElevatedButton(
          onPressed: () {
            setState(() {
              useEvenSelector = !useEvenSelector;
            });
          },
          child: Text("Toggle Selector"),
        ),
        Text('Current Selector: ${useEvenSelector ? 'Even' : 'Odd'}'),
        ElevatedButton(
          onPressed: () {
            context.read<CounterCubit>().increment();
          },
          child: Text("Increment"),
        ),
        BlocSelector<CounterCubit, int, int>(
          selector:
              (state) =>
                  useEvenSelector ? evenSelector(state) : oddSelector(state),
          builder: (context, selected) {
            return Text(
              "Selected: $selected || (Actual Value: ${context.read<CounterCubit>().state}) > ",
            );
          },
        ),
      ],
    );
  }
}
```
---

#### 🧠 Code Explanation

- A `CounterCubit` emits incremented integers.
- Inside a `StatefulWidget`, we toggle between two selector functions:
  - `evenSelector` → returns the value only if it's even, else `-1`
  - `oddSelector` → returns the value only if it's odd, else `-1`
- `BlocSelector` uses `useEvenSelector` to decide which logic to apply, and that flag is toggled using `setState`.

---

#### ❗️The Problem in This Example

When we **toggle the selector** using `setState`, the `BlocSelector` widget does **not re-evaluate the selector** immediately. As a result:

- Even though we’ve updated the selector logic in `setState`, the widget still shows stale data.
- The new selector is only respected **after a new event is emitted** (e.g., tapping Increment).
- This leads to unexpected behavior where the UI **doesn’t reflect the correct selected state**, despite the selector having changed.

---

#### Expected Behavior

When the selector function passed to BlocSelector changes — for example, through a `setState` call in a StatefulWidget — the widget should recompute the selected value using the new selector, even if the bloc’s state hasn’t changed.

The widget should rebuild and reflect the new selected state immediately, based on the updated logic, without requiring a new event to be emitted from the bloc.

### ✅ Workaround

Wrap the `BlocSelector` with a `ValueKey(selected)` to force it to rebuild when the selected tab changes:

```dart
BlocSelector<..., ..., ...>(
  key: ValueKey(selected), // ← Needed for correct behavior
  selector: ...,
  builder: ...,
)
```
Hence

A temporary fix is to **wrap the `BlocSelector` with a `ValueKey`** that changes when your selector logic changes. This forces the widget to rebuild and re-evaluate the new selector.

```dart
BlocSelector<CounterCubit, int, int>(
  key: ValueKey(useEvenSelector), // 👈 forces rebuild on selector toggle
  selector: (state) =>
      useEvenSelector ? evenSelector(state) : oddSelector(state),
  builder: (context, selected) {
    return Text("Selected: $selected");
  },
)
```

> 💡 While this works, it feels more like a workaround than an intended behavior, and rebuilding via keys shouldn’t be necessary just to respect selector updates.


Discussion: ### ➕ Additional Example: Captain-based Team View Shows Stale State

Adding another minimal snippet that reproduces the same issue in a slightly more elaborate scenario.  
This version involves **two captains (Naruto & Sasuke)** managing separate teams, and showcases how `BlocSelector` fails to reflect the correct state when switching between tabs — unless the bloc emits a new event.

**Snippet**

```dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: NarutoTeamPage());
  }
}

/* Section Constants */

Map<String, Color> colorMap = {
  'naruto': const Color.fromARGB(255, 251, 187, 147),
  'sasuke': const Color.fromARGB(255, 160, 200, 232),
};

Map<String, Color> textColorMap = {
  'naruto': Colors.deepOrange,
  'sasuke': Colors.deepPurple,
};

const initialState = {
  'naruto': ['hinata'],
  'sasuke': ['sakura'],
  'available': ['ino', 'kiba', 'shikamaru'],
};

/* Section - Cubit */

class TeamCubit extends Cubit<Map<String, List<String>>> {
  TeamCubit() : super(initialState);

  void addValue(String key, String value) {
    final map = {...state};
    map[key] = [...(map[key] ?? []), value];
    map['available'] = List.of(map['available']!)..remove(value);
    emit(map);
  }

  void reset() {
    emit(initialState);
  }
}

/* Section - Widget */

class NarutoTeamPage extends StatefulWidget {
  const NarutoTeamPage({super.key});

  @override
  State<NarutoTeamPage> createState() => _NarutoTeamPageState();
}

class _NarutoTeamPageState extends State<NarutoTeamPage> {
  String selected = 'naruto';

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => TeamCubit(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Team Selector')),
        body: Center(
          child: SizedBox(
            height: 500,
            width: 500,
            child: Column(
              children: [
                _buildTabs(
                  context,
                  onTap: (newVal) {
                    setState(() {
                      selected = newVal;
                    });
                  },
                ),
                const SizedBox(height: 20),
                // BlocSelector to display the value
                Expanded(
                  child: BlocSelector<
                    TeamCubit,
                    Map<String, List<String>>,
                    List<String>
                  >(
                    // !! IF NOT USED KEY THEN BLOCSELECTOR WON'T HONOR `SELECTOR` ALWAYS !! (ESP WHEN DIDUPDATEWIDGET)
                    // key: ValueKey(selected),
                    selector: (state) {
                      return state[selected] ?? [];
                    },
                    builder: (context, items) {
                      return _buildTabView(context, items: items);
                    },
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  /// displays 2 tabs for 'Naruto' and 'Sasuke'
  Widget _buildTabs(
    BuildContext context, {
    required void Function(String) onTap,
  }) {
    return Row(
      spacing: 16,
      mainAxisAlignment: MainAxisAlignment.center,
      children:
          ['naruto', 'sasuke'].map((name) {
            final isSelected = selected == name;
            return InkWell(
              onTap: () => onTap(name),
              child: Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: isSelected ? Colors.green : Colors.grey.shade300,
                  borderRadius: BorderRadius.circular(10),
                ),
                child: Text(
                  name.toUpperCase(),
                  style: TextStyle(
                    color: isSelected ? Colors.white : Colors.black,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            );
          }).toList(),
    );
  }

  /// Displays the `Selected` members and `Available` members for selected captain (ie either 'Naruto' or 'Sasuke')
  Widget _buildTabView(BuildContext context, {required List<String> items}) {
    final remaningOptions = context.read<TeamCubit>().state['available'] ?? [];
    const gap = SizedBox(height: 8);
    return Expanded(
      child: Container(
        width: double.infinity,
        padding: const EdgeInsets.all(16),
        color: colorMap[selected],
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            // * Section 1 - Selected Members
            Text(
              'Selected TeamMates:',
              style: const TextStyle(
                fontSize: 24,
                color: Colors.white,
                fontWeight: FontWeight.bold,
              ),
            ),
            gap,
            Text(
              items.join(', '),
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: textColorMap[selected],
              ),
            ),
            Spacer(),
            // * Section 2 - Available Members
            Text(
              'Select TeamMates',
              style: const TextStyle(
                fontSize: 24,
                color: Colors.white,
                fontWeight: FontWeight.bold,
              ),
            ),
            gap,
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                for (final e in remaningOptions)
                  ActionChip(
                    onPressed: () {
                      context.read<TeamCubit>().addValue(selected, e);
                    },
                    label: Text(e),
                    backgroundColor: Colors.white,
                  ),
              ],
            ),
            gap,
            // * Reset All Button
            ElevatedButton(
              onPressed: () {
                context.read<TeamCubit>().reset();
              },
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.lime,
                padding: const EdgeInsets.symmetric(
                  horizontal: 20,
                  vertical: 10,
                ),
              ),
              child: const Text('Reset'),
            ),
          ],
        ),
      ),
    );
  }
}
```

#### 🧾 Code Snippet Overview

This example simulates a **team-building interface** with two captains — **Naruto** and **Sasuke**.  
Each captain has an individual list of teammates, and users can switch tabs to view or update either team.

- It uses `BlocSelector` to pick and display the current captain’s team from the overall state.
- Teammates are added via `ActionChip`s from a common pool of available members.
- The active tab is managed via `setState`, while team data is managed via a `TeamCubit`.

The UI is meant to reflect **team members unique to each captain**, but due to `BlocSelector` not updating properly when only the selector logic changes, the wrong team’s data is sometimes shown.

### 🔁 Steps to Reproduce

1. Run the attached code sample.
2. By default, you’re on **Naruto’s tab** — you’ll see **Hinata** as an existing teammate.
3. Tap on any available teammate (e.g., **Shikamaru**) to add them to Naruto’s team.
4. Now, switch to the **Sasuke** tab by tapping the **Sasuke** button.
5. ❗️You’ll still see **Naruto’s updated teammates** on Sasuke’s screen, even though the selector should show only **Sasuke’s team**.
6. Try switching back and forth — the state displayed doesn't align correctly unless the underlying bloc state changes again.

Fix patch:
diff --git a/packages/flutter_bloc/lib/src/bloc_selector.dart b/packages/flutter_bloc/lib/src/bloc_selector.dart
index fed6d2bab1e..e5b4d64f39f 100644
--- a/packages/flutter_bloc/lib/src/bloc_selector.dart
+++ b/packages/flutter_bloc/lib/src/bloc_selector.dart
@@ -89,6 +89,8 @@ class _BlocSelectorState<B extends StateStreamable<S>, S, T>
     if (oldBloc != currentBloc) {
       _bloc = currentBloc;
       _state = widget.selector(_bloc.state);
+    } else if (oldWidget.selector != widget.selector) {
+      _state = widget.selector(_bloc.state);
     }
   }
 


Test patch:
diff --git a/packages/flutter_bloc/test/bloc_selector_test.dart b/packages/flutter_bloc/test/bloc_selector_test.dart
index 9859435bf1c..c40b15058c7 100644
--- a/packages/flutter_bloc/test/bloc_selector_test.dart
+++ b/packages/flutter_bloc/test/bloc_selector_test.dart
@@ -184,6 +184,38 @@ void main() {
       expect(find.text('isEven: true'), findsNothing);
     });
 
+    testWidgets('rebuilds when selector changes', (tester) async {
+      final counterCubit = CounterCubit();
+      var selector = (int state) => state.isEven;
+
+      Widget buildWidget() {
+        return Directionality(
+          textDirection: TextDirection.ltr,
+          child: BlocSelector<CounterCubit, int, bool>(
+            bloc: counterCubit,
+            selector: selector,
+            builder: (context, state) => Text('Selected: $state'),
+          ),
+        );
+      }
+
+      await tester.pumpWidget(buildWidget());
+      expect(find.text('Selected: true'), findsOneWidget);
+
+      counterCubit.increment();
+      await tester.pumpAndSettle();
+      expect(find.text('Selected: false'), findsOneWidget);
+
+      selector = (state) => state.isOdd;
+
+      await tester.pumpWidget(buildWidget());
+      expect(find.text('Selected: true'), findsOneWidget);
+
+      counterCubit.increment();
+      await tester.pumpAndSettle();
+      expect(find.text('Selected: false'), findsOneWidget);
+    });
+
     testWidgets('overrides debugFillProperties', (tester) async {
       final builder = DiagnosticPropertiesBuilder();