Say I have an Input handler which references a rep...
# ballast
r
Say I have an Input handler which references a repository. Is it "safe" to call a function on the repository which then dispatches an input for the repository? Or should all such calls be done in a side job? I ran into a situation where a call to a repository was executing a remote RPC, which was then failing with a
ChildCancelledException
, I guess because of the original input being cancelled. Ideally the input handler would not need to "know" which methods on the repository are safe to call directly and which need to be executed in a side job.
c
In general, if the ViewModel is using the
FifoInputStrategy
, then this should be safe, but if it’s using the
LifoInputStrategy
(which is the default), it may not be safe. This is because the Repository may process the call quickly and update its state concurrently with the VM, which could then cancel the VM’s Input as it tries to accept the new Repository State. Moving it to a sideJob would definitely solve the problem with either case, but adds considerable boilerplate that isn’t ideal. Since I released this library 1.5 years ago, I’ve come to realize the
FifoInputStrategy
is probably a better default, but it’s a bit difficult to switch the default without potentially breaking existing projects. I’m trying to figure out a gently migration strategy, but probably will not be included in 3.0.0. I’d suggest explicitly setting the InputStrategy for your VMs and Repos to be
FifoInputStrategy
, but be aware that it may introduce delays between an Input being sent and it being processed by the InputHandler
r
Ah that makes sense and I believe that is exactly what is happening. I think I'm good with
Fifo
-- that should lead to more predictable behavior in general.
In your docs you still recommend Lifo for UI view models -- would you say that Fifo would be a better default even for those? I don't see how to reliably avoid problems like the one I just encountered otherwise.
c
Yup, Fifo is definitely more predictable and safer, but leaves the VM open to becoming unresponsive is something suspends in the InputHandler and never completes. Basically the same problem with calling blocking APIs on the main thread of an Android application, causing an ANR. There’s pros/cons to both strategies, so just be aware of those and you should be fine.
Those docs were written a while ago, and are in line with the current default of using Lifo for UI VMs (to avoid ANRs) and Fifo for non-UI cases likes Repositories. I still think it’s a good idea of use Lifo, as it leads to a better user experience (when a user takes an action, it gets processed immediately, rather than processed “eventually” and making a laggy UI), but does cause a lot of challenges with making Repository calls and things like that. Ultimately, it’s difficult for me to really strongly recommend one strategy over the other, as they both have great use-cases, but are so highly dependent upon how you want your app to work. But Fifo is definitely more natural to how one would likely think about things in general, which is why I’m considering that as a better a default
r
@Casey Brooks Was thinking about this a bit and had a thought about a general, flexible, and backward compatible (though somewhat complex) approach which allows users to define "dependencies" between state and inputs. So the specifics may change here on reflection, but the general idea is to allow users to start from a FIFO, LIFO, or parallel strategy but then to "optimize" the behavior by defining the dependencies between each instance of state and input -- thus allowing users to take advantage of the pros of FIFO/LIFO/parallel without (or at least controlling for) the associated cons. Say we have an interface like this:
Copy code
interface BallastDependencyKeyed {
  fun keys(): Set<Any>
}
Any
State
or
Input
could implement this interface, and by doing so it expresses the idea that any other
State
or
Input
that returns
keys()
that intersect with it, are dependent / related. Given the inputs:
Copy code
A(keys=[1]), B(keys=[2]), C(keys=[1])
A default LIFO strategy would cancel A and skip B on receipt of C. But with the dependencies we can express the idea we want to skip B on receipt of C, but not A. Or minimally:
Copy code
A(keys=[1]), B, C(keys=[1])
A default FIFO strategy would still execute A and B on receipt of C. But with the dependencies we can express the idea that we are allowed to skip B. Or minimally:
Copy code
A, B(keys=[2]), C
A parallel strategy would execute them all in parallel by default. But with the dependencies we can express the idea that A and B can run in parallel, but C has to wait until A (and B because it is running in parallel with A) are done (fork-join style parallelism). Not implementing the
BallastDependencyKeyed
interface for the state type or any input results in the same behavior as today.
c
That is definitely an intriguing concept, and I think it could be handled with the current APIs by making a custom InputStrategy. I'm not sure it's something I would want to tackle though, as it's a pretty niche use-case, and greatly adds to the complexity of understanding and working with the MVI model. But you're welcome to try making your own InputStrategy to accomplish this logic, which basically is just collecting a Flow. I'd be interested to see what you could come up with!
Just a quick thought on a way to accomplish this type of behavior, that might be a bit easier to understand and more generalizable: turning the main Queue into something like a PriorityQueue. Have each Input given a Priority, and if it’s processing one Input and another arrives with a higher Priority, it would be handled in LIFO fashion as normal, but if it’s lower priority it just gets buffered and waits until the current Input finishes. So for cases like reading a Repository state, the RepositoryStateUpdate Input should have a lower priority than the Inputs sent by the UI, for example.
Yeah, these kinds of subtleties are why I’m in favor of moving the default to FIFO, because it’s a lot easier to control for not suspending the queue than it is to control concurrent updates. But to the point of changing how LIFO works, cancelling the running Input is the whole point, to ensure any Input that gets sent is handled immediately. The motivating use-case was for situations where a user was navigating between screens, so that API calls get cancelled if a screen is left early rather than making the user wait until it finishes. And in a similar manner, if they’re doing something like updating filters on a list (like filtering products in an ecommerce storefront) or we need to make an API call to verify text (like checking that a username is not already taken), that the long-running filter/validation is cancelled as they update so they don’t need to wait between subsequent updates. I may be misunderstanding what you mean by “Could the default behavior of LIFO be to do LIFO as now, but not cancel any already ongoing operations?“, though. Could you clarify that a bit?
r
I think you understood it correctly, and the reasoning for why it works that way makes sense.
I think the docs could use some color describing all of this
Its very easy to introduce bugs if this behavior isn't deeply understood
The usage guide doesn't even mention any of the complexities around this (or mention it at all), for example
c
This section explains a bit more how to think in these terms, though admittedly it is pretty abstract. It could definitely do with some more concrete examples and a more thorough explanation of when to choose one strategy vs another. Thanks for the feedback!
r
Yes, note that section doesn't even mention the strategy options, and how they affect the system, even in abstract terms, let alone concrete.
Well, no, I misspoke. It does say:
and actually is the default strategy Ballast uses (though it can be changed).
I may be misunderstanding what you mean by “Could the default behavior of LIFO be to do LIFO as now, but not cancel any already ongoing operations?“, though. Could you clarify that a bit?
Actually, maybe I think there is something here. In the examples you gave of a user navigating away from a page or typing something into a filter list, another input would be received. This would cancel the previously running input. That isn't the behavior I saw. The behavior I saw was that an input cancelled itself simply by updating state.
c
It shouldn’t cancel itself unless something in your UI reacts to a state change and sends a subsequent Input. That logic in general might be the problem, it sounds like you might be trying to synchronize the VM with the state of the UI, rather than driving the UI entirely from the State. For example, rather than the UI being like, “The dialog has closed, let the VM know I’m now closed”, it should be more passive, where the dialog doesn’t close unless the VM tells it to close. And that value would be set in that first Input, so there’s no need for the follow-up Input sent by the Dialog upon it being closed
r
You're right. What is happening is that the popup has an
onClose
event which sends a "DismissPopup" input. That event is meant to trigger when the popup is closed by the user, but it is also triggering when the popup is closed via a state change.
So that second
DismissPopup
input is canceling the running input, exactly as you said.
c
What UI framework are you using Ballast with?
r
Compose web
c
DOM or Canvas?
r
Yep. I'm digging into the events. I'm using kmdc and it has
onClosed
and
onClosing
events, trying to figure out what
onClosing
does.
From the names, I would think I am using them "correctly"
Closing should be informational, closed should be explicit
c
From a quick glance, those look to be JS DOM attributes, which almost certainly don’t really play nicely with a pure MVI model. Using
scrimClickAction
or
escapeKeyAction
(https://github.com/mpetuska/kmdc/blob/master/sandbox/src/jsMain/showcases/MDCDialog.kt#L88-L89) might be a better way to be explicit about when the user chooses to close the dialog
r
I think I see why. The dialog wrapper we have has an
onClose
callback, and it is being called by both the dialog
onClosed
event, AND when the user explicitly clicks the close button.
This has all been very educational (UI dev is pretty new to me). Thank you so much for talking this through.
c
Yeah, and just thinking semantically about what I see in that snippet, it does lead me to believe that the dialog is modifying its internal state and getting hidden before
onClosed
is called, which makes the dialog not strictly obeying its
open
value. Might be “good enough” for the example I linked above, but in a true MVI/UDF model, this dialog implementation is not strictly obeying its contract. Obviously, it can only do so much when it’s trying to synchronize “imperative JS” with “declarative compose”. Synchronizing state with traditional UIs is incredibly difficult to get right, which is probably why the Compose team chose to just rewrite Android’s UI rather than make it a layer above the existing View system 😅. But it might be worth opening an issue to the KMDC repo about this behavior, at least to get the maintainer to look deeper and see if this actually what’s going on here
But to wrap things up, since you’re new to UI dev, I would recommend using FIFO as your default InputStrategy. it will cause you fewer headaches and things will still work out just fine in nearly all use-cases. And when it fails, it’s much more obvious how to fix things
And for cases where you want long-running work to be cancelled, put that work in a SideJob so you can still cancel and restart it, without sacrificing the safety of the whole VM
r
BTW @Casey Brooks you were right about using
scrimClickAction
or
escapeKeyAction
to be explicit about whether the user chose to close it, or its closing based on an input. The out-of-the-box behavior is for the
onClose
event detail
action
to be
null
when not closed by the user, and `scrimClickAction`/`escapeKeyAction` when closed by the user using either of those two methods (which defaults somewhat confusingly to "close").
Even though changing the model to FIFO "fixes" the issue, doing the onClose handler properly makes it work in LIFO mode too.