Skip to content

Conversation

@rkishan516
Copy link
Contributor

Feat: Add unused dependency cleanup
fixes: #106549

Before this PR

If any widget add dependency to other InheritedWidget, it never gets removed until widget is out of tree. e.g.

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => Theme.of(context),
      child: const Text('Tap'),
    );
  }
}

Once the button is tapped, any change in theme is applied to ButtonDependsOnTheme widget.

After this PR

If any widget add dependency to other InheritedWidget and in next rebuild it doesn't keep it as in example, it will be removed from dependency.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

@github-actions github-actions bot added the framework flutter/packages/flutter repository. See also f: labels. label Jun 6, 2025
@Piinks
Copy link
Contributor

Piinks commented Aug 5, 2025

Hi @rkishan516 do you still have plans for this change?

@rkishan516
Copy link
Contributor Author

Hi @Piinks, This was a proposal PR for @justinmc to review. If we want to continue with this, then I can surely update this.

@Piinks
Copy link
Contributor

Piinks commented Oct 20, 2025

This has come back around to stale PR triage. We don't review draft PRs, but have you reached out on Discord for the feedback you were seeking?

@rkishan516
Copy link
Contributor Author

rkishan516 commented Oct 21, 2025

This has come back around to stale PR triage. We don't review draft PRs, but have you reached out on Discord for the feedback you were seeking?

Hey, last time we couldn't conclude, I will continue that discussion again today.

@rkishan516 rkishan516 marked this pull request as ready for review October 21, 2025 02:46
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkishan516 I really appreciate you opening this PR and getting a discussion going. Flutter needs more bold ideas like this.

It looks like there are two failing customer tests in the provider package, and they're kind of concerning. I wouldn't expect this change to cause them to fail, but maybe it's because I don't understand the inner workings of provider. Do you know what's causing the failure?

https://github.com/rrousselGit/provider/blob/558bdcd26ddb2b922bec06e3fa0d91bc59479baf/packages/provider/test/null_safe/context_test.dart#L489-L520

I think I agree with @rrousselGit in the original issue (#106549), who said that this should probably be opt-in. I really can't think of any reasonable situation where you would logically need the original behavior, can you? But I worry due to the (slight) performance effect.

Overall I agree that Flutter should have this feature, probably behind an opt-in flag.

}());
}
}
context.cleanupRemovedDependencies();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the performance implications of this. I believe this method will end up being O(n), where n is the number of elements in _dependencies.

  • _dependencies!.difference is called, which I would guess is O(n). The docs don't say but Gemini agrees with me.
  • Iterating the removed dependencies will just be another n operations in the worst case.
  • Set.remove and Map.remove should be O(1).
  • Set.clear should be O(1).

So my guess is that this is not a performance concern, because the number of dependencies is very unlikely to be large.

@benthillerkus
Copy link
Contributor

It looks like there are two failing customer tests in the provider package, and they're kind of concerning. I wouldn't expect this change to cause them to fail, but maybe it's because I don't understand the inner workings of provider. Do you know what's causing the failure?

https://github.com/rrousselGit/provider/blob/558bdcd26ddb2b922bec06e3fa0d91bc59479baf/packages/provider/test/null_safe/context_test.dart#L489-L520

The test is incorrect as per Providers own documentation: https://pub.dev/documentation/provider/latest/provider/ChangeNotifierProvider-class.html#:~:text=DON%27T%20reuse%20an%20existing%20ChangeNotifier%20using%20the%20default%20constructor

I think I agree with @rrousselGit in the original issue (#106549), who said that this should probably be opt-in. I really can't think of any reasonable situation where you would logically need the original behavior, can you? But I worry due to the (slight) performance effect.

Overall I agree that Flutter should have this feature, probably behind an opt-in flag.

Maybe tests could be run if this actually affects performance? Because I can also come up with some pathological cases where this should boost performance.

IMO it would be too bad to have the desired / correct behaviour only available behind a flag / different API & having to wait for the entire ecosystem to migrate.

@rkishan516
Copy link
Contributor Author

Thanks @justinmc! I've investigated the failing provider tests and found the bug in my implementation.

The issue was in where cleanupRemovedDependencies() was being called. My original implementation called it only once on the root context element in buildScope(), but _flushDirtyElements rebuilds many elements during a build cycle.

For the fix, I moved cleanupRemovedDependencies from BuildOwner.buildScope() to ComponentElement.performRebuild. Now each element cleans up its own dependencies after it rebuilds.

On the opt in choice, I am not really sure. I can see some advantage of opt in, so for now I am also leaning towards it.

@rkishan516 rkishan516 force-pushed the dependency-cleanup branch 6 times, most recently from 9d98a94 to d0374cc Compare October 22, 2025 15:31
@chunhtai chunhtai self-requested a review October 24, 2025 20:50
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like customer tests are still failing, but now it's because a class extends InheritedWidget and has its own dependOnInheritedElement implementation without the new parameter: https://github.com/rrousselGit/provider/blob/558bdcd26ddb2b922bec06e3fa0d91bc59479baf/packages/provider/lib/src/inherited_provider.dart#L606-L609

So now I think we'd have to create a new method(s) with the new functionality?

/// If [trackForCleanup] is true, this dependency will be tracked and
/// automatically removed when the widget rebuilds and no longer depends on it.
/// This helps prevent unnecessary rebuilds but requires careful consideration
/// as it changes the default behavior. Defaults to false for backward compatibility.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc should not mention previous behavior. That will be confusing for 1st time reader.

Just state the behavior and the current default

InheritedWidget dependOnInheritedElement(
InheritedElement ancestor, {
Object? aspect,
bool trackForCleanup = false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API may introduce some bug that let's say there are two dependencies [A, B]
where B is called with trackForCleanup to false, and A is called with trackForCleanup to true.

B will end up get removed.

It feels like this API relies on all of the dependencies are called with trackForCleanup = true or all called with trackForCleanup = false.

Should we rewrite the mechanism to be per inherited element setting?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yaa, I was thinking about this. And rather than making both dependencies and currentBuildDependencies to HashMap. But I think having a setting per inherited element will be better idea.

@rkishan516 rkishan516 force-pushed the dependency-cleanup branch 5 times, most recently from 0963a57 to bf7806b Compare October 31, 2025 05:09
@dkwingsmt
Copy link
Contributor

Since the current behavior (never unsubscribe) is intentional, I assume we'll never make this behavior the default. In this case, do you think it'll be easier to have another method to call than to configure this when defining the entire class? This allows the app developers to decide whether to adopt it, so that this feature can be applied to existing inherited models as well.

I'm imagining a removeDependency method, since the developer should know the timing that they're no longer using the dependency. Or we can have a slightly smarter method such as checkDependencyCleanup. In general my idea is that if the developer feels like unsubscribing, they can call it in similar places as the dependOn methods.

@rrousselGit
Copy link
Contributor

rrousselGit commented Nov 10, 2025

The very reason this feature is requested is that developers dont have a good place to unsubscribe.

Folks can already override InheritedElement.removeDependent.
They thing is that the only mean for a package to know when a dependency is no-longer useful is to override ComponentElement.build. That requires dissociating from Stateless/StatefulWidget, making this solution incompatible with a large chunk of the ecocystem.

(the alternative of providing Builder-like widgets has the same compatibility issue)

benthillerkus added a commit to benthillerkus/provider that referenced this pull request Nov 11, 2025
These tests fail when Flutter automatically cleans up unused `InheritedWidget` dependencies as noted by @justinmc in flutter/flutter#170104.

As per https://pub.dev/documentation/provider/latest/provider/ChangeNotifierProvider-class.html#:~:text=DON%27T%20reuse%20an%20existing%20ChangeNotifier%20using%20the%20default%20constructor already existing `ChangeNotifier` instances should not be passed into the `create` method of a `ChangeNotifierProvider` Widget (because it would then tie it to its own lifecycle). Instead instances can be passed through `ChangeNotifierProvider.value` and then disposable can be manually handled by the caller (in case of these tests via `addTearDown`).
@chunhtai
Copy link
Contributor

chunhtai commented Dec 11, 2025

The implementation most looks good to me, just left some suggestion to see if we can squeeze out as much performance as possible

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkishan516 Did you try this out on a low end device? I'm interested to see the performance impact.

As long as we're ok with any performance changes I'm on board with this approach.

(d.widget as InheritedWidget).cleanupUnusedDependents &&
!_currentBuildDependencies!.contains(d),
)
.toList();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might be able to gain a bit of performance by omitting this toList. I think you can still loop over the Iterable as expected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or another performance idea: Just loop over _dependencies once instead of using where.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think initial implementation was with loop, but after suggestion we moved to this version.

Copy link
Member

@loic-sharma loic-sharma Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Justin. Widget rebuilds is a very hot path, I'd prefer performance over succinctness here.

where(...) will allocate a WhereIterable, and toList() will allocate a List and then add elements. This does more work and adds GC pressure. Let's avoid that if we can.

@rkishan516
Copy link
Contributor Author

@rkishan516 Did you try this out on a low end device? I'm interested to see the performance impact.

I have tried on iPhone 13 but i don't thats the low end, we are looking for. I will search for some old android phone and check.

@loic-sharma loic-sharma self-requested a review December 31, 2025 23:46
@chunhtai chunhtai self-requested a review January 2, 2026 23:23
}

return const SizedBox();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a variant of this test where dependencies are used in the didChangeDependencies method instead of the build method?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is currently broken, see: #170104 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, if dependency is established in didChangeDependency, that doesn't exist in build, get removed in build.

}

return const SizedBox();
}
Copy link
Member

@loic-sharma loic-sharma Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a variant of this test where dependencies are used in a layout callback like LayoutBuilder.builder?

I believe the current implementation won't work in LayoutBuilder. The layout callback is called during the layout phase, which is after this build method has returned.

The inherited widget cleanup might need to happen in a post frame callback to correctly handle this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I have added tests for these. Which are marked as skip because they are failing.

@override
@pragma('vm:notify-debugger-on-exception')
void performRebuild() {
_currentBuildDependencies?.clear();
Copy link
Member

@loic-sharma loic-sharma Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this line drop dependencies registered by didChangeDependencies?

Notice how StatefulElement.performRebuild calls didChangeDependencies before calling ComponentElement.performRebuild:

@override
void performRebuild() {
if (_didChangeDependencies) {
state.didChangeDependencies();
_didChangeDependencies = false;
}
super.performRebuild();
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does drop dependcies registered by didChangeDependencies.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh nice catch, yeah that is not something we should do. Can we do this in rebuild method instead? also, why do we do the clean up in ComponentElement given than the _dependencies are defined in Element?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have moved _currentBuildDependencies?.clear(); to rebuild and _currentBuildDependencies to Element.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I partially remember, _currentBuildDependencies was in Element and then we moved to ComponentElement for some reason but I couldn't find it.

@loic-sharma
Copy link
Member

loic-sharma commented Jan 3, 2026

Hello, thank you for the contribution and all the excellent work. While I am supportive of allowing widgets to clean up unused dependencies, I'm concerned with the current design and I don't think we should move forward with this implementation as-is:

  1. A widget can depend on an inherited widget in its didChangeDependencies method. With this change, such a widget would be broken if its build method does not also depend on that inherited widget - the next rebuild might not call didChangeDependencies, which would remove the dependency.

    Example app...
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(MaterialApp(home: MyPage()));
    }
    
    class MyPage extends StatefulWidget {
      @override
      State<MyPage> createState() => _MyPageState();
    }
    
    class _MyPageState extends State<MyPage> {
      int counter = 0;
     
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          // InheritedA cleans up dependents.
          // InheritedB does not clean up dependents.
          body: InheritedA(
            value: counter,
            child: InheritedB(
              value: counter,
              child: Column(children: [
                InheritedText<InheritedA>(),
                InheritedText<InheritedB>(),
              ]),
            )
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => setState(() => counter++),
            child: const Icon(Icons.add),
          ),
        );
      }
    }
    
    class InheritedText<T extends InheritedWidget> extends StatefulWidget {
      @override
      State<InheritedText> createState() => _InheritedTextState<T>();
    }
    
    class _InheritedTextState<T extends InheritedWidget> extends State<InheritedText<T>> {
      late int value;
      
      @override
      void didChangeDependencies() {
        // Use dependencies in didChangeDependencies but not build.
        final inheritedWidget = context.dependOnInheritedWidgetOfExactType<T>();
        value = switch (inheritedWidget) {
          InheritedA(value: final value) => value,
          InheritedB(value: final value) => value,
          _ => throw new Exception('Not reachable ${inheritedWidget.runtimeType}'),
        };
        super.didChangeDependencies();
      }
    
      @override
      Widget build(BuildContext context) {
        // If this widget rebuilds but the dependencies didn't change,
        // nothing uses the dependencies.
        // As a result, InheritedA will be cleaned up unexpectedly.
        return Text('Value: $value');
      }
    }
    
    class InheritedA extends InheritedWidget {
      const InheritedA({super.key, this.value = 0, required super.child});
    
      final int value;
    
      @override
      bool get cleanupUnusedDependents => true;
    
      @override
      bool updateShouldNotify(InheritedA oldWidget) => value != oldWidget.value;
    }
    
    class InheritedB extends InheritedWidget {
      const InheritedB({super.key, this.value = 0, required super.child});
    
      final int value;
    
      @override
      bool get cleanupUnusedDependents => false;
    
      @override
      bool updateShouldNotify(InheritedB oldWidget) => value != oldWidget.value;
    }
  2. You can't tell whether an inherited widget will clean up unused dependents or not. For example:

    void build(BuildContext context) {
      return TextButton(
        onPressed: () {
          Foo.of(context);       
        },
        child: Text('Hello'),
      );
    }

    If I press the button, will my widget rebuild whenever Foo changes? There's no way to tell without checking Foo's implementation.

    Keep in mind that some .of methods are seriously non-trivial. For example, see Theme.of, which is actually multiple inherited widgets.

  3. Do we plan to update the framework's inherited widgets to turn on cleanupUnusedDependents?

    If yes, what's that migration look like?

    If no, I don't think we should move forward with this pull request. The ideal solution would be usable by the framework.

I see that @goderbauer had expressed similar reservations on a previous pull request: #107112 (comment), #107112 (comment)

I apologize for bringing this feedback late, months after you started this pull request. Thank you again for all the excellent work!

@rkishan516
Copy link
Contributor Author

Hello, thank you for the contribution and all the excellent work. While I am supportive of allowing widgets to clean up unused dependencies, I'm concerned with the current design and I don't think we should move forward with this implementation as-is:

Hey @loic-sharma, thanks for your feedback. It's not require to land this PR in current state or current way, our main goal is to have cleanup for unused dependency. So, if you have a better idea, i can work on that. Current implementation is just result of initial implementation + feedback based iteration.

For your concerns

  1. Yes, any dependency established in didChangeDependencies, drops immediately in build method.
  2. Yes, Foo decides it will cleanup dependents or not (dependents are totally unaware).
  3. For framework's inherited widget, we have not thought about yet. But eventually, we might update framework's widgets.

@loic-sharma
Copy link
Member

loic-sharma commented Jan 3, 2026

This is a very hard problem.

What do you think of this idea instead? #106549 (comment)

I wonder if we can partially solve this problem by using the following inherited widget pattern:

  1. Foo.of(context) - This looks up Foo and adds a dependency on it for the lifetime of context.
  2. Foo.peek(context) - This looks up Foo but does not add a dependency on it. You can use this in your callbacks.

We'd need to add .peek(context) and .maybePeek(context) methods to all existing inherited widgets, like MediaQuery, Theme, AppLocalizations, etc...

This an easier path that would make this problem less bad. We'd still need the team to buy-off on the approach though.

@rkishan516
Copy link
Contributor Author

This is a very hard problem.

Agree. People just ignore issue like #163467 but if we solve this, it will give clarity.

What do you think of this idea instead? #106549 (comment)

I think we already have this with dependOnInheritedWidgetOfExactType as Foo.of and getInheritedWidgetOfExactType as Foo.peek. So, its upto developer if they are exposing peek or not.

We'd still need the team to buy-off on the approach though.

Yup.

@justinmc justinmc self-requested a review January 13, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a variant to context.dependOnInheritedWidget that removes the unused dependencies when the dependent no-longer depends on it

8 participants