jinyus / dart_beacon

A reactive primitive (signals) and state management library for dart and flutter
https://pub.dev/packages/state_beacon
27 stars 1 forks source link

feat: a way to chain FutureBeacons without losing dependency registration #115

Open btrautmann opened 1 month ago

btrautmann commented 1 month ago

Description

Within the README, the Pitfalls section clearly calls out the potential footgun of registering dependencies following an async gap. One thing that's not touched on, though, is the case where you don't really have a choice. Consider the following:

final categories = $categoriesStore.inView(const ExpensesView()).toFuture(); // A Future<List<Category>>

// Can't compile, TransactionsView requires an input of List<Category>, not Future<List<Category>>
final transactions = $transactionsStore.inView(const TransactionsView(categories: categories)).toFuture(); 

To get the above to compile, I need to await categories before the second line. But doing that will cause a failure to register the call to inView which is itself a BeaconFamily.

I am not listing requirements here because I understand this may not be possible/feasible, but I would love to know if there's a workaround I can employ.

jinyus commented 1 month ago

This is a tricky one, Ill have to brainstorm a bit to see if there's an ergonomic solution.

For a quick workaround, I would create a .lazyInView where that has a .setTransaction() method.

btrautmann commented 1 month ago

The workaround I've gone with is something like the following:

// Mapping here because we cannot await, otherwise the call to
// txnStore.inView won't be registered as a dependency.
final categoryView = categories.value.mapOr(
  (c) => CategoriesWithIds(c.ids), // Invoked if categories.value is AsyncData
  const AllCategories(), // Invoked in all other cases
);

final txnsFuture = $txnStore()
    .inView(
      TxnsView(
        dateRange: range,
        accounts: const AllAccounts(),
        categories: categoryView,
        filter: const ExpenseFilter(),
      ),
    )
    .toFuture();

The downside to this is that there's a potential for an intermediate "fake/incorrect" state if the setting the user has configured is still loading. In my case, this currently only has the potential to occur in 2 small sections of the app, so the scope of the downside is limited, but still exists. In a lot of other cases I was able to work around the issue entirely by passing an object as the CategoryView that did not depend on the ids produced by user action, something like:

// Note that `ExpensesInSelectedView` is passed both here, and...
final $categories = $categoriesStore().inView(const ExpensesInSelectedView()).toFuture();

final $txns = $txnStore()
    .inView(
      TxnsView(
        dateRange: const SelectedDateRange(),
        accounts: accountsView,
        categories: const ExpensesInSelectedView(), // ...here!
        filter: const ExpenseFilter(),
      ),
    )
    .toFuture();

In this case, both Future's understand the meaning of ExpensesInSelectedView and do the same work, producing results influenced by the same inputs. But in the first case above, the ids are required for the result to be correct, and therefore I need a "fake" intermediate result (AllCategories) while the categories load.

btrautmann commented 1 month ago

Commenting to add that the async gap problem becomes particularly challenging when you need code to run once and produce a deterministic result.

This is just a restating of the above, but I have a case where my app offers Home Screen Widgets and to keep these up-to-date, I run the update logic both on process start and then periodically in the background. For process starts, a Beacon.effect that uses the strategy above (mapOr in the case of a FutureBeacon whose resolved data needs to be an input to another FutureBeacon) works great as the effect will run again once the initial FutureBeacon is fully loaded and produce the correct result. That API might look something like:

final (dispose, settled) = Beacon.effect(() => ...);
await settled; // The effect's emission queue is empty

Disclaimer: The above is entirely made up and I have no idea whether such a queue even exists!

However on a background process that wants to re-use this code, it's not ideal to have to "wait" for this effect to run enough times to produce the correct result (also there's AFAIK no way to say "tell me when this effect has emitted everything and is "up-to-date"... If there was, this use case could probably be solved).

The simplest path forward would be to abstract all the non-beacon-y logic out of the update-widget logic and have one codepath that uses an effect and another that doesn't. The latter would await the FutureBeacon#toFuture() wherever needed, ensuring the correct result is produced the first time. But this would still have non-trivial duplication.

Right now what I'm currently doing is running the effect and then adding a few Future.delayed() calls to essentially purge the effect, but it's hacky and not exactly a perfect science.

jinyus commented 1 month ago

IIUC, effects are always alive until disposed so I'm not sure how I would define "settled". I would like to see a sample/abstract of the hacky code.