diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index aac18d85..cfa937a4 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -45,7 +45,6 @@ export default defineConfig({ 'core/modals', 'core/testing', 'core/provider-retrieval-process', - 'core/configuration', ], }, { diff --git a/docs/src/content/docs/core/configuration.md b/docs/src/content/docs/core/configuration.md deleted file mode 100644 index d5cc1ae1..00000000 --- a/docs/src/content/docs/core/configuration.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Configuration -description: How to set up the application-wide configuration for Disco. ---- - -Disco aims to be minimally opinionated. To customize the default configuration, set the desired preferences using `DiscoConfig` before calling `runApp`. For example: - -```dart -DiscoConfig.lazy = false; -runApp( - // ... -); -``` - -### All options - -| Option | Default | Description | -| -------------- | ------- | ----------- | -| `lazy` | true | The values of the providers provided in a `ProviderScope` are created lazily. | diff --git a/docs/src/content/docs/core/immutability.mdx b/docs/src/content/docs/core/immutability.mdx index 211df106..d07ccb57 100644 --- a/docs/src/content/docs/core/immutability.mdx +++ b/docs/src/content/docs/core/immutability.mdx @@ -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: // ... +) +``` + + + +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. + + + +## 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. diff --git a/docs/src/content/docs/core/modals.mdx b/docs/src/content/docs/core/modals.mdx index d5fdef91..44a79528 100644 --- a/docs/src/content/docs/core/modals.mdx +++ b/docs/src/content/docs/core/modals.mdx @@ -46,7 +46,7 @@ runApp( MaterialApp( home: Scaffold( body: ProviderScope( - providers: [numberProvider], + providers: [numberProvider()], child: Builder( builder: (context) { return ElevatedButton( diff --git a/docs/src/content/docs/core/provider-retrieval-process.mdx b/docs/src/content/docs/core/provider-retrieval-process.mdx index e8d820bb..9c407976 100644 --- a/docs/src/content/docs/core/provider-retrieval-process.mdx +++ b/docs/src/content/docs/core/provider-retrieval-process.mdx @@ -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. -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. + +## 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. + + diff --git a/docs/src/content/docs/core/providers.mdx b/docs/src/content/docs/core/providers.mdx index 3e5923ca..3d19ad67 100644 --- a/docs/src/content/docs/core/providers.mdx +++ b/docs/src/content/docs/core/providers.mdx @@ -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. + + ### 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. @@ -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. + + + +### 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 createState() => _EagerProvidersState(); +} + +class _EagerProvidersState extends State { + @override + void initState() { + super.initState(); + engineProvider.of(context); + } + + @override + Widget build(BuildContext context) => widget.child; +} +``` + + + + + + + +## 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. @@ -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. diff --git a/docs/src/content/docs/core/scoped-di.mdx b/docs/src/content/docs/core/scoped-di.mdx index c782b235..486e25c4 100644 --- a/docs/src/content/docs/core/scoped-di.mdx +++ b/docs/src/content/docs/core/scoped-di.mdx @@ -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: // ... ) ``` @@ -42,6 +42,14 @@ ProviderScope( ) ``` + + ### 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. @@ -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); @@ -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); +``` + + + +### 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. diff --git a/docs/src/content/docs/core/testing.mdx b/docs/src/content/docs/core/testing.mdx index 4bf0c886..dfc23f16 100644 --- a/docs/src/content/docs/core/testing.mdx +++ b/docs/src/content/docs/core/testing.mdx @@ -5,7 +5,11 @@ description: How to use overrides for testing. import { Aside } from '@astrojs/starlight/components'; -Testing is done with overrides. You need to place a `ProviderScopeOverride` and then specify the `overrides` argument with a list containing the providers followed by `.overrideWithValue(T value)`. +Testing is done with overrides. You need to place a `ProviderScopeOverride` and then specify the `overrides` argument with a list containing the providers followed by `.overrideWith(provider)`. + +An override replaces a provider **entirely**, and not just the value it holds: the provider passed to `overrideWith` is a regular provider, with its own `create` and `dispose`. The replacement must use the same provider kind as the provider being overridden: use `Provider.withArgument` to override a provider created with `Provider.withArgument`. This means a mock can, for instance, inject other providers through its context, exactly like the provider it replaces. The value of the original provider is never created at all. + +An override also takes the place of the provider it replaces in every `ProviderScope` below the `ProviderScopeOverride`. The mock therefore has the very same lifecycle as the original provider: its value is created lazily where the value of the original provider would have been created, it is disposed when that `ProviderScope` is disposed, and there is one value per `ProviderScope` providing it.