Skip to content
Open
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
1 change: 0 additions & 1 deletion docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export default defineConfig({
'core/modals',
'core/testing',
'core/provider-retrieval-process',
'core/configuration',
],
},
{
Expand Down
19 changes: 0 additions & 19 deletions docs/src/content/docs/core/configuration.md

This file was deleted.

30 changes: 29 additions & 1 deletion docs/src/content/docs/core/immutability.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,35 @@ The typical usage for a provider is to provide a value for a specific page or wi
When the page or widget is disposed, the provider gets also disposed, and the value is no longer available.
When the Page is opened again, a new instance of the provider is created, and the value is provided again.

### Force the recreation of a provider
## The set of providers is fixed

The same reasoning applies to the `providers` list itself: it is read exactly once, when the `ProviderScope` is mounted. Inserting a provider into it, or removing one from it, on a later rebuild has no effect at all.

Since this would silently surface much later as a `ProviderWithoutScopeError`, it is reported as an error in debug mode:

```dart
// Reports an error in debug mode as soon as `showDetails` changes, and
// continues with the original providers.
ProviderScope(
providers: [
modelProvider(),
if (showDetails) detailsProvider(),
],
child: // ...
)
```

<Aside type="tip">
Provide a fixed set of providers, and put the conditional ones in a nested `ProviderScope` that is itself inserted conditionally. That way, their values are created when the scope appears and disposed when it goes away.
</Aside>

Note that only the *identity* of the providers is checked. Giving an argument provider a different argument on a rebuild remains allowed — and keeps being ignored, as shown in the example above.

<Aside>
The error is *reported* and not thrown: the `ProviderScope` keeps working with the providers it was mounted with. The check also only runs in debug mode; in release builds, a changed list is silently ignored, exactly as before.
</Aside>

## Force the recreation of a provider

There is one way to force the recreation of a provider, _which we discourage_, but can be used if you know what you are doing.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/core/modals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ runApp(
MaterialApp(
home: Scaffold(
body: ProviderScope(
providers: [numberProvider],
providers: [numberProvider()],
child: Builder(
builder: (context) {
return ElevatedButton(
Expand Down
49 changes: 38 additions & 11 deletions docs/src/content/docs/core/provider-retrieval-process.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,53 @@ Refer to the following graph to understand how the providers are retrieved.

### Steps

1. When a provider is injected, the first thing that is checked is if a `ProviderScopeOverride` exists.
1. When a provider is injected, the first `ProviderScope` ancestor is searched.

2. If it does, its internal map of overridden providers is checked to see if the provider is there.
2. If a `ProviderScope` ancestor is found, the provider is searched in its internal map of providers.

3. If is there, the overridden value is returned.
3. If the provider is found, its value is returned. If the value has not been created yet (i.e. it is the first time the provider is injected), it gets created right before it is returned.

4. Otherwise, the search continues for the first `ProviderScope` ancestor.
4. If the provider is not found, the search proceeds to the next `ProviderScope` ancestor, continuing recursively up the widget tree until the root is reached.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the lookup complexity claim.

This step can traverse multiple ProviderScope ancestors. The map lookup is O(1), but the complete lookup is O(number of traversed scopes) in the worst case. Update the following complexity aside.

🧰 Tools
🪛 LanguageTool

[style] ~21-~21: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...reated right before it is returned. 4. If the provider is not found, the search p...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/src/content/docs/core/provider-retrieval-process.mdx` at line 21, Update
the complexity aside in the provider retrieval process documentation to
distinguish the O(1) lookup within each ProviderScope from the overall
worst-case traversal cost of O(number of traversed ProviderScope ancestors).


5. If a `ProviderScope` ancestor is found, the provider is searched in its internal map of providers.
5. If the provider is not found, a `ProviderWithoutScopeError` is thrown.

6. If the provider is found, its value is returned. It the value was not computed yet (i.e. it is the first time a lazy provider is accessed), it will get created right before it is returned.

7. If the provider is not found, the search proceeds to the next `ProviderScope` ancestor, continuing recursively up the widget tree until the root is reached.

8. If the provider is not found, a `ProviderWithoutScopeError` is thrown.
A `ProviderScopeOverride` needs no special treatment in this process: it holds an internal `ProviderScope` providing the mocks, which takes part in the traversal like any other scope.

<Aside type="tip">
The error can be avoided by using `provider.maybeOf(context)`, which returns `null` if the provider is not found.
</Aside>

<Aside>
The lookup of a provider is __O(1)__ because it just involves a map lookup, in addition the tree is traversed using [getElementForInheritedWidgetOfExactType](https://api.flutter.dev/flutter/widgets/BuildContext/getElementForInheritedWidgetOfExactType.html), which is O(1) because it jumps from one InheritedWidget of type `T` to another, skipping the widgets in between.
The lookup of a provider within each ProviderScope is __O(1)__ because it just involves a map lookup. The tree traversal uses [getElementForInheritedWidgetOfExactType](https://api.flutter.dev/flutter/widgets/BuildContext/getElementForInheritedWidgetOfExactType.html), which is O(1) per step because it jumps from one InheritedWidget of type `T` to another, skipping the widgets in between. The overall worst case is therefore O(number of traversed ProviderScope ancestors) when a provider is not found until the root.
</Aside>

## The three layers of providers

The steps above talk about "the internal map of providers" of a scope. Knowing what that map actually contains makes the behavior of overrides — and of providers with an argument — easy to predict.

1. **Top-level providers.** These are the providers you declare in your files. They are never used to create anything: they only act as type-safe identifiers. This is why they can be declared globally without holding any global state.

2. **Intermediate providers.** Whenever a top-level provider is inserted into a `ProviderScope`, that scope generates an intermediate provider for it and stores the pair in its internal map. The intermediate provider is the one actually responsible for creating (and disposing) the value.

3. **Values.** These are the objects your widgets inject, and they are stored per intermediate provider.

The second layer is what makes overrides and arguments possible, because an intermediate provider can be *regenerated* from something else than the top-level provider it is registered under:

| Inserted provider | Intermediate provider |
| - | - |
| `myProvider()` | `myProvider` itself |
| `myProvider()`, with an override | a copy of the provider passed to `overrideWith` |
| `myArgProvider(arg)` | a provider combining `myArgProvider` with `arg` |
| `myArgProvider(arg)`, with an override | a provider combining the provider passed to `overrideWith` with `arg` |

Since the values are keyed by their intermediate provider, and since a fresh intermediate provider is generated for every override, the same mock can be reused to override several providers without the resulting values being shared.

## How overrides are applied

A `ProviderScopeOverride` merely *registers* its overrides. Every `ProviderScope` below it consults that registry while generating its own intermediate providers, so that the value of a mock lives exactly where the value of the original provider would have lived — and therefore shares its lifecycle.

There is one exception, and it concerns argument providers only: an argument provider cannot be instantiated by the `ProviderScopeOverride` itself, since no argument is available there. A plain `Provider` can, which is why a plain provider can be overridden even when no `ProviderScope` provides it at all.

<Aside>
The practical consequences are described in the [Testing](https://disco.mariuti.com/core/testing/) page. The recommendation of placing the `ProviderScopeOverride` as the root widget of your tests makes them irrelevant.
</Aside>
91 changes: 89 additions & 2 deletions docs/src/content/docs/core/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ class MyDatabase {
While providers can be declared globally, they **do not function globally**. They are just used as **identifiers** when registered in a scope.
</Aside>

<Aside>
As a consequence, the `create` function above is never called directly by you: a provider is called (i.e. `numberProvider()`) only to insert it into a `ProviderScope`, which is what creates and owns the value. See [Scoped DI](https://disco.mariuti.com/core/scoped-di/).
</Aside>

### Injection of other providers with context

Providers can leverage the context to inject other providers. The context will be relative to the scope in which they are provided.
Expand Down Expand Up @@ -77,7 +81,70 @@ final doubleNumberPlusArgProvider = Provider.withArgument((context, int arg) {
});
```

## Dispose and lazy parameters
## Lazy creation of the values

The value of a provider is **always created lazily**: the `create` function is called the first time the provider is injected, and never before. If a provider is never injected, its value is never created.

<Aside>
This keeps the rule simple: a value exists if, and only if, something injected it. It is also the same behavior as Riverpod, which likewise has no way to flag a provider as eager.
</Aside>

### Creating a value as soon as a scope is mounted

Sometimes a value has to exist even if no widget injects it yet, e.g. because it starts a subscription or warms up a cache. In that case, inject it in a widget placed **below** the scope providing it:

```dart
ProviderScope(
providers: [engineProvider(), userProvider(42)],
child: Builder(
builder: (context) {
engineProvider.of(context); // created here
userProvider.of(context); // works for argument providers too
return const MyPage();
},
),
)
```

The `Builder` is what makes this work: its context is a descendant of the `ProviderScope`, therefore the injection finds it. The value is created only once, even though the builder may run again.

If you prefer the injection to happen exactly once per mount, do it in the `initState` of a widget placed below the scope. This is safe, since `of(context)` does not make the widget depend on the provider:

```dart
class EagerProviders extends StatefulWidget {
const EagerProviders({required this.child, super.key});

final Widget child;

@override
State<EagerProviders> createState() => _EagerProvidersState();
}

class _EagerProvidersState extends State<EagerProviders> {
@override
void initState() {
super.initState();
engineProvider.of(context);
}

@override
Widget build(BuildContext context) => widget.child;
}
```

<Aside type="caution">
Injecting from a context which is **not** below the scope (for example in the widget that creates the `ProviderScope` itself, or in its `initState`) does not work: that context cannot see the scope, therefore a `ProviderWithoutScopeError` is thrown.
</Aside>

<Aside type="tip">
Keep the `child` in a variable (or `const`), so that the widget performing the eager injection can be rebuilt without rebuilding the subtree below it.
</Aside>

<Aside>
Both approaches create the value while the widget tree is being built. If your `create` function notifies some listener, postpone the notification (e.g. with `WidgetsBinding.instance.addPostFrameCallback`), otherwise Flutter complains that a widget was marked as needing to be rebuilt during a build.
</Aside>

## Optional parameters

When defining a provider, we need to pass the positional `create` argument, which is a function used to generate the value contained by the provider.

Expand All @@ -86,4 +153,24 @@ There are also two optional named parameters that can be specified.
| Parameter | Default | Description |
| -------------- | ------- | ----------- |
| `dispose` | null | The function to call when the scope containing the provider gets disposed. It is used to dispose correctly the value held by the provider. |
| `lazy` | `DiscoConfig.lazy`, which defaults to true | The provider's value is created lazily, meaning it is only created when first injected.|
| `debugName` | null | An optional name, shown in the error messages of this library, which makes a provider easier to recognize. |

## Overriding a provider

A provider can be replaced by another provider of the same type through `overrideWith`, which is meant to be used for testing:

```dart
final myProviderOverride = myProvider.overrideWith(
Provider((context) => MyMock()),
);
```

The same works for providers with an argument, as long as the mock takes an argument of the same type:

```dart
final myArgProviderOverride = myArgProvider.overrideWith(
Provider.withArgument((context, int arg) => MyMock(arg)),
);
```

Since the override is a provider itself, it also controls how the mocked value is created and disposed. Refer to the [Testing](https://disco.mariuti.com/core/testing/) page to see how overrides are inserted into the widget tree.
58 changes: 37 additions & 21 deletions docs/src/content/docs/core/scoped-di.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ In case the provider does not take an argument, we scope it the following way:

```dart
ProviderScope(
providers: [numberProvider]
providers: [numberProvider()]
child: // ...
)
```
Expand All @@ -42,6 +42,14 @@ ProviderScope(
)
```

<Aside>
Note the parentheses: a provider is never inserted into a `ProviderScope`
directly. Calling a provider returns the binding used to register it with
the scope. The provider value is created lazily, i.e. the first time it is
injected. Calling a provider is also what allows an argument to be passed.
This way, the syntax is the same for both kinds of providers.
</Aside>

### How to inject

Injecting is the act of retrieving a dependency. It is done with the methods `of(context)` and `maybeOf(context)`, the latter one being safer because it returns null instead of throwing if the provider is not found in any scopes.
Expand All @@ -65,7 +73,7 @@ runApp(
MaterialApp(
home: Scaffold(
body: ProviderScope(
providers: [numberProvider, doubleNumberPlusArgProvider],
providers: [numberProvider(), doubleNumberPlusArgProvider(10)],
child: Builder(
builder: (context) {
final number = numberProvider.of(context);
Expand All @@ -83,37 +91,45 @@ The solution is "5 20".

## Scoping correctly with context

Some providers might have a dependency on other providers, to handle such cases, you can:
1. Access another provider if it is declared in an ancestor `ProviderScope`.
2. Declare the dependent provider after the provider it depends on, within the same `ProviderScope`. The order of declaration matters here otherwise a `ProviderForwardReferenceError` will be thrown at runtime.
Some providers might have a dependency on other providers. The `context` a provider receives in its `create` is the context of the widget injecting it, therefore a provider can inject:

### Wrong example
1. any provider declared in an ancestor `ProviderScope`;
2. any provider declared in its own `ProviderScope`, in any order.

The provider `doubleNumberPlusArgProvider` depends on `numberProvider`. Therefore, when scoping them both in the same `ProviderScope`, `numberProvider` must be declared first.
Therefore, the following code will lead to a `ProviderForwardReferenceError`:
The order in which the providers are declared does not matter, because all the providers of a `ProviderScope` are registered as soon as the scope is mounted, while their values are only created on demand:

```dart title="Wrong example"
```dart
// Both of these work, and behave identically.
ProviderScope(
providers: [
doubleNumberPlusArgProvider(10),
numberProvider,
],
providers: [numberProvider(), doubleNumberPlusArgProvider(10)],
child: // ...
)
```

The error can be prevented by reordering the list of providers:

```dart title="Correct example"
ProviderScope(
providers: [
numberProvider,
doubleNumberPlusArgProvider(10),
],
providers: [doubleNumberPlusArgProvider(10), numberProvider()],
child: // ...
)
```

### Circular dependencies

The only combination that cannot work is a cycle, i.e. a provider that directly or indirectly injects itself. This is detected while the value is being created and reported with a `ProviderCircularDependencyError`, which lists the providers taking part in the cycle:

```dart
// Throws a ProviderCircularDependencyError the first time either provider is
// injected.
final aProvider = Provider((context) => bProvider.of(context) + 1);
final bProvider = Provider((context) => aProvider.of(context) + 1);
```

<Aside type="tip">
A cycle usually means that a dependency is being resolved too early. Injecting the value where it is *used* instead of where it is *created* is often enough to break it.
</Aside>

### Disposal order

Because the values are created lazily, a value is always created *after* the values it depends on. When a `ProviderScope` is disposed, its values are therefore disposed in the reverse order of creation, so that a value is always disposed *before* its own dependencies. This means that the `dispose` of a provider can safely use the values it injected in its `create`.

## Graphical representation

When you inject a provider, you need to ensure that one of the ancestors of the widget — where the injection takes place — is a `ProviderScope` providing that provider. If this is not the case, an error will be thrown at runtime.
Expand Down
Loading
Loading