rodydavis / signals.dart

Reactive programming made simple for Dart and Flutter
http://dartsignals.dev
Apache License 2.0
437 stars 50 forks source link

Exception: "This widget has been unmounted..." #245

Closed kalafut closed 5 months ago

kalafut commented 5 months ago

I'm using v5.0.0 and have a ListView with ListItems that themselves use Watch.builder(...). When the whole list needs to rebuild (I'm speculating on that bit because this started failing when I added a key attribute to fix an unrelated bug), I'm hitting this exception in framework.dart:

  BuildContext get context {
    assert(() {
      if (_element == null) {
        throw FlutterError(
          'This widget has been unmounted, so the State no longer has a context (and should be considered defunct). \n'
          'Consider canceling any active work during "dispose" or using the "mounted" getter to determine if the State is still active.',
        );
      }
      return true;
    }());
    return _element!;
  }

The caller, in signals_flutter, is in watch/widget.dart:

void rebuild() async {
    if (!mounted) return;
    final result = widget.builder(context);
    if (result == child) return;
    child = result;
    if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) {
      await SchedulerBinding.instance.endOfFrame;
    }
    if (!context.mounted) return;  // <------ statement leading to the exception.
    (context as Element).markNeedsBuild();
  }
PaulHalliday commented 5 months ago

I've been hitting this issue too -- I can trigger it in certain scenarios by performing a hot reload. Not sure if it's related to #209 and #206.

rodydavis commented 5 months ago

Hot reload should have been fixed, but have not run into with my tests. Would love to find a way to simulate hot reload in tests too.

rodydavis commented 5 months ago

Can you provide a minimal example to reproduce the issue?

PaulHalliday commented 5 months ago

I've reproduced my original issue, but, this was specific to the use of signals with lite_ref overrides.

I have a minimal here for that, but looking at it, my issue may be different than the one mentioned here in #245.

kalafut commented 5 months ago

At least as of yesterday, I could reliably make this happen in my app. Hopefully that is still the case today 😅 and I'll see if I can strip it down to a minimal example.

kalafut commented 5 months ago

OK, I think I've crunched this down to the essentials. Run it, press +, press Back, and it should crash (might take a few tries, and a larger number of initial items seems to make it occur more quickly).

import 'package:flutter/material.dart';
import 'package:random_string/random_string.dart';
import 'package:signals/signals_flutter.dart';

final games = mapSignal(<String, String>{});

final filteredGames = computed(() {
  final v = games.values.toList();
  v.sort((a, b) =>
      b.compareTo(a)); // If I remove this statement, the bug disappears ?!?
  return v;
});

void initStore() {
  // The quantity of items here seems to affect the bug. A lower number (say 5)
  // and you have to press back a lot more times to trigger the bug.
  for (var i = 0; i < 15; i++) {
    games[randomAlpha(10)] = randomAlpha(10);
  }
}

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

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const GameEdit(),
              ),
            );
          },
          child: const Icon(Icons.add),
        ),
        body: Watch.builder(builder: (_) {
          return ListView(
            children: [
              for (var game in filteredGames.value)
                GameTile(
                  key: ValueKey(game), // if I don't set this, no crash
                ),
            ],
          );
        }));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Watch.builder(builder: (context) {
      // If I make this const, the bug goes away ???!!??
      return ListTile(
        title: Text('yay'),
      );
    });
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Game Details'),
        leading: BackButton(
          onPressed: () {
            games[randomAlpha(10)] = randomAlpha(10);
            Navigator.pop(context);
          },
        ),
      ),
      body: const Text('Press back and it might crash'),
    );
  }
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  initStore();
  runApp(const MyApp());
}
rodydavis commented 5 months ago

Thanks for the code snippet, will try to reproduce 👍🏼

PaulHalliday commented 5 months ago

Yup -- the key seems to be when there is a Watch within a widget that already has a Watch further up the tree.