Consistency when resetting Time.every

I came across this while trying to implement a way to delay an API call until a user has “stopped typing” in an input field. Help from discord led me to this possible solution and some further questions.

You place a subscription as a variable in the model, and point subscriptions to that variable. By default it is Sub.none, but when a user types (onInput) in the input field you change it to “every X TypingEnded”. When TypingEnded happens you set it back to Sub.none and call the API.

What happens is that setting the variable to “every X TypingEnded” repeatedly does not reset the timer. However if I alternate between X and X+1 it does reset. My knowledge of Elm internals is not good enough to know if this means the function is acting based on previous input, or if assigning the same thing to a variable results in it not being updated.

So my question is if this is how Time.every should/has to work, or if it is possible to have for instance a “one-shot” flag or reset flag?

My understanding is that the elm rumtime will call the subscriptions function you pass to it on every update loop. So you definitely don’t want it to reset every time it receives the same timer subscription (with no Sub.none inbetween), otherwise none of the timers, except perhaps extremely short ones, would every fire!

I haven’t examined the underlying source code, but my guess would be that each time the runtime calls subscriptions it does a diff between what it gets and what it had last time and only alters the set of subscriptions being followed based on that diff.

The only issue I’ve ever had with this mechanism is that if you have two identical subscriptions this can lead to undefined behaviour (e.g. if you have a subscription to Time.every 5000 and then add another subscription to Time.every 5000 with the same Msg destination, what should the runtime do when later only one of these susbscriptions is removed?) Personally I think it would be good for the runtime to refuse to accept identical subscriptions, however since the Msg constructors can be functions I imagine the problem is that the runtime can’t easily tell if two subscriptions are indistinguishable, and it is anyway an extremely unusual corner case.

Anyway: with regards to your problem, I think the solution would be to have a field in your model that stores the Time.Posix that any input was last entered, and then just subscribe to Time.every with some sensible resolution and compare the received value to this field. So something like:

type alias Model =
    { input : String
    , lastInput : Posix.Time
    , timeSubscription : Bool
    }


type Msg
    = InputChanged String
    | InputTime Time.Posix
    | Tick Time.Posix
    | TypingEnded


subscriptions : Model -> Sub Msg
subscriptions {timeSubscription} =
    if timeSubscription then
        Time.every 100
    else
        Sub.none


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputChanged value ->
            ( { model | input = value, timeSubscription = True }
            , Task.perform InputTime Time.now
            )

        InputTime time ->
            ( { model | lastInput = time }, Cmd.none )

        Tick time ->
            if (Time.posixToMillis time) - (Time.posixToMillis model.lastInput) > 2000 then
                update TypingEnded { model | timeSubscription = False }
            else
                ( model, Cmd.none )

        TypingEnded ->
            -- Do whatever you need to here

Hope that helps!

3 Likes

I had a look at the source code for Time. It maintains a Dict that maps Float intervals to Processes, which you can see here.

Each Process is a timer based on JavaScript’s setInterval. If a new interval is inserted into the Dict, it starts a new Process for it. And if an interval is removed from the Dict, it kills the Process. But if your interval already exists in that Dict then it just leaves it alone.

I guess the assumption is that if you’re returning the same value from your update then you don’t want to change anything. That’s usually how things tend to work in Elm.

There’s nothing in the Time API to reset the timer. (Actually, come to think of it, JavaScript setInterval doesn’t have any reset either. You also have to kill it and start a new one.)

So to do a reset you’d have to force two updates to happen in quick succession. But that approach really feels like an imperative style rather than declarative/functional style, and things tend to get nasty in Elm if you do that. For example you might later add some other types of Msg to your program that could happen at any time (API calls? clicks?). But your subscriptions function runs after every update. So those API calls or clicks could reset the timer you only intended for keystrokes…

Bottom line: @Jess_Bromley’s suggestion sounds good to me!

Sounds like you could trigger a Process.sleep after each keystroke and keep track of how many are out there. So you’d track a count in your model, and when a key is pressed you get count + 1 and when a timeout with Process.sleep comes back, you get count - 1. If the count goes from 1 to 0 you do whatever you were waiting to do.

1 Like

If you’re interested in seeing what other people are doing for this sort of thing, you might be interested to checkout the published debouncing libraries in Elm — I was trying to do something like this a while ago and didn’t know “debouncing” was the word for it until later.

1 Like

But in general terms:

The behavior that Time.every works different depending on what the “subscriptions” call have returned before feels a bit “ugly”/“unclean” to me. If it is the first time that I return with for example 100, i know that I will get a message in 100ms. But if “subscriptions” have returned 100 before, I have no idea.

I understand why the design requires it and it might not be a practical problem, but it still nags me a bit.
(A scenario that might be a “problem”: I periodically update a value through a HTTP call, but I also have a button for manually updating the value. The the optimal would be to reset the timer so that the automatic update doesn’t happen right after the manual. Not sure if it a good example, but…)

Do you have a way of looking at it that might make it feel cleaner for me, or how do you see it?

I think of it as just that: a set of subscriptions. If I am subscribed to something, then telling the runtime that I am still subscribed to that thing shouldn’t to my mind cause any kind of reset.

You have to remember that the subscriptions mechanism can handle all sorts of things (communications with JS via ports, websockets, Http tasks, etc.). Viewed in this context it becomes clearer why Time.every works the way it does. It also explains why there is no single-shot timer available as a subscription from the Time package, because such a thing really would not be a subscription.

(In fact you can have a single shot timer, by using the andrewMacmurray/elm-delay package but you’ll see that in that package it is correctly given as a Cmd not a subscription.)

Yes. I agree to some degree. Time is a bit special though since all the others are for stuff that you want a response to “as soon as they get there”. The time.Every gives you a way of controlling the period, but not a real way of controlling the start time or “phase” in any way for the underlying timer that you want to subscribe to. setInterval in javascript does give you that power, since it always starts when you call setInterval.

Your point about no one-shot makes total sense and I agree having thought about it.

It’s true that Elm doesn’t give you control over the phase like this. But I think for the intended use case of Time.every (which isn’t really your use case, see next paragraph) you wouldn’t really want that. I think Time.every is designed for applications that need a regular ‘beat’, such as an application modelling a chess clock. Since even in Javascript the timer delay is not guaranteed such applications need to choose a beat somewhat smaller than their actual requirements and then use the Time.Posix returned by it to determine when to actually act, so that they can account for drift in the actual delay. Otherwise any moderately long running application would deviate from the desired behaviour. From this standpoint, the phase ceases to be important. (Although perhaps you or somebody else can think of a counterexample?)

The more I think about it, the more I realise that the reason you are having trouble is that Time.every is not a good match for your use case. You really want a oneshot timer that fires N seconds after a particular event last occurred, but with the ability to restart the timer every time the event reoccurs.

Given this, I think the suggestion @evancz gave is much closer to the intention behind your code (although my earlier answer would produce the same effect). Note that another way to do it this way is just to compare the received timer id against the most recent id despatched, rather than having to count them all in and out. You could do this directly with Process's or you could just use andrewMacmurray/elm-delay in which case your code would look something like.

type alias Model =
    { input : String
    , waitingFor : Int
    }


type Msg
    = InputChanged String
    | Timeout Int
    | TypingEnded


-- NOTE: NO SUBSCRIPTIONS NEEDED NOW


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputChanged value ->
            let
                newId =
                    model.waitingFor + 1
            in
                ( { model | input = value, waitingFor = newId }
                , Delay.after 2 Delay.Second (Timeout newId)
                )

        Timeout id ->
            if id == model.waitingFor then
                update TypingEnded model
            else
                ( model, Cmd.none )

        TypingEnded ->
            -- Do whatever you need to here
2 Likes

Please note that I’m not the thread starter. I’m just academically interested in the design, not really related to the thread user-case.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.