Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Input field reverts to old value while db updates #219

Closed
brjann opened this issue Oct 30, 2020 · 15 comments
Closed

Input field reverts to old value while db updates #219

brjann opened this issue Oct 30, 2020 · 15 comments
Assignees
Labels
Milestone

Comments

@brjann
Copy link

brjann commented Oct 30, 2020

Also posted in Clojureverse https://clojureverse.org/t/input-field-blinks-before-update-in-re-frame/6758

I'm using the re-com/input-text element. The value is dispatched to the db when the input loses focus, which is the default behavior. However, after leaving the input field, the old value is shown for a short time, and then the new value is shown again.

Basically, the code looks like this

(re-frame/reg-sub :test-sub
  (fn [db]
    (:test-sub db)))

(re-frame/reg-event-db :test-event
  (fn [db [_ value]]
    (assoc db :test-sub value)))

(defn main-panel []
  (let [test (re-frame/subscribe [:test-sub])]
    [re-com/v-box
     :height "100%"
     :children [[:span @test]
                [re-com/input-text
                 :model test
                 :on-change #(re-frame/dispatch [:test-event %])]]]))

Can I prevent this behavior somehow? I've made a quick demonstration of the phenomenon based on the re-frame Leiningen template available at https://github.com/brjann/re-frame-test

@prook
Copy link

prook commented Nov 18, 2020

This problem has been bugging me for months now, so I spent last night debugging and playing around with input-text, and I'm quite confident I see what the problem is. IMHO, it's these external-model resets:

https://github.com/day8/re-com/blob/master/src/re_com/misc.cljs#L127
https://github.com/day8/re-com/blob/master/src/re_com/misc.cljs#L134
https://github.com/day8/re-com/blob/master/src/re_com/misc.cljs#L141

The thing with re-frame events is that they are dispatched asynchronously -- they will be handled (very) soon, but not right now. By resetting external-model before calling on-change, the component 1) triggers an update on itself, and 2) makes a false assumption about the outside world state -- appdb will be updated eventually, but for now, it's still at the old value.

Let's replay what happens, in slow motion:

  1. Starting with {:internal-model "" :external-model "" :latest-ext-model ""}, user types "HELLO" in the text-input
  2. And thus: {:internal-model "HELLO" :external-model "" :latest-ext-model ""}
  3. Next, the user leaves the field, triggering on-blur event, which reset!s external-model to internal-model, and dispatches a re-frame event, that will eventually update appdb, so: {:internal-model "HELLO" :external-model "HELLO" :latest-ext-model ""}
  4. That reset! in the previous step triggers input component update, and this condition here is now false, since external-model says "HELLO", but latest-ext-model is still "". So the component resets itself back to the actual external state: {:internal-model "" :external-model "" :latest-ext-model ""}
  5. Just about when everything above has happenned, that dispatched re-frame event is handled, and appdb is updated, triggering yet another update. At this moment, we have {:internal-model "" :external-model "" :latest-ext-model "HELLO"}, and the same condition is false again, which results in another round of resets and updates, finally settling on {:internal-model "HELLO" :external-model "HELLO" :latest-ext-model "HELLO"}

Transition from 3 to 4 to 5 causes that oscillation we see.

Omitting external-model resets I pointed out seems to work just fine - in step 3, we avoid the component update, dispatch a command to update the outside wold, and once the world updates, it will let us know by a subscription change. Only then the component updates its internal state {:internal-model "HELLO" :external-model "" :latest-ext-model "HELLO"} to {:internal-model "HELLO" :external-model "HELLO" :latest-ext-model "HELLO"}. There is still some oscillation involved, but it does not cause a visible flicker this issue is about.

I may be missing something though, so before cooking up a pull request, I'd welcome some feedback -- would removing them resets break something I don't see? I'm rudely pinging @Gregg8, since he seems to be the last one to touch the code in question (although it's been 6 years).

@prook
Copy link

prook commented Nov 18, 2020

Upon further inspection, the resets are there for a reason. Hm...

Edit: The reasons do not hold, imho, see the mention below.

@brjann
Copy link
Author

brjann commented Nov 24, 2020

Great that someone else is bothered by this! :-)

Have you been able to fix this bug while preserving the bugfix in #187?

If not, I have been thinking about this and my thought is that there may be an underlying problem, namely when does the input change from showing the (edited) internal value to the (persisted) external value from app-db?

  • It can't do it immediately on blur, because then we see this blinking behavior.
  • Rather, it has to wait until the onblur function call, presumably an event dispatch, is finished (very soon)
  • But how does it now when it is finished? It can't just check if the external value has changed. Because it might not change if the user made an illegal edit and the event dispatch changes it back to its old value. If that would happen, the edited value would become "stuck" and actually not represent the app db value.

Does that make any sense? Or is there a reliable way for the input to detect when "very soon" has happened?

@prook
Copy link

prook commented Nov 24, 2020

Well, I think there was no bug to fix in #187 in re-com, the problem was in the example code itself. It was twisted and wrong, and I've explained it there and sketched out a more fitting solution.

I've implemented a text input component that uses external/internal model mechanism, it works exactly like the re-com one, minus the resets. It works just fine, it updates itself whenever the external model changes (given the model actually changes), and is flicker-free when sending changes to the outer world via on-change.

@brjann
Copy link
Author

brjann commented Nov 24, 2020

Wow, great! Are you able to share the source for that component?

@prook
Copy link

prook commented Nov 24, 2020

Just copy and paste the re-com one, and delete those three resets I've pointed out in my first comment here. :)

@superstructor superstructor self-assigned this Dec 21, 2020
@superstructor
Copy link
Contributor

Thanks @brjann for the bug report and @prook for the subsequent investigation.

I've tested the proposed change locally with a few different options; e.g. :change-on-blur? both false and true. It appears to work fine.

I agree its a reasonable trade-off since the example in #187 can be written in the way suggested by #187 (comment) vs it is harder to work around the issue described here without a change to the component itself.

@brjann
Copy link
Author

brjann commented Dec 28, 2020

It looks like this fix leads to the problem that I predicted above, that if the change is rejected by the DB, the text in the input still does not change. So if I change the text to some illegal value and the dispatched event simply reverts the value back to its old value (i.e., does not update app-db), the value does not change in the text field.

I've updated my demo of the bug to reflect this https://github.com/brjann/re-frame-test

@superstructor superstructor reopened this Dec 28, 2020
@brjann
Copy link
Author

brjann commented Dec 28, 2020

Thanks for reopening @superstructor!

I have an idea on how it could be solved, but it feels very hackish. After the on-blur function is called, a "dummy event" is dispatched with the only purpose of resetting the subscription to the db / external value. The idea is that the dummy event will be handled after the event that is supposedly dispatched in the on-blur function. So then it should be safe to rely on the db / external value.

@prook
Copy link

prook commented Jan 5, 2021

if I change the text to some illegal value and the dispatched event simply reverts the value back to its old value (i.e., does not update app-db), the value does not change in the text field.

But why should it change? To what should it change? To the original app-db value, throwing the user input away? That would be bad, wouldn't it? When the input is invalid, the app should ask the user to fix it, not toss it away and make them start over. :)

@brjann
Copy link
Author

brjann commented Jan 5, 2021

Hm, this is a tricky situation. You are right that if the value is reverted, the app should inform the user that something is wrong and not necessarily throw the value away. Is there no conceivable situation where the app should just "silently" revert the value?

@prook
Copy link

prook commented Jan 5, 2021

At the risk of sounding arrogant, I'm afraid this discussion has little to do with the original issue. However, the snippet in #187 (comment) should provide an idea how to trigger a reset/re-render of a component using signal other than model change.

@brjann
Copy link
Author

brjann commented Jan 5, 2021

If the input no longer reflecting the value of the subscription can be considered an unwanted effect of the fix, is not the discussion relevant?

@brjann
Copy link
Author

brjann commented Jan 5, 2021

Regardless, I can accept that the edited value doesn't change if the edit is reverted, since the app should handle that situation. So I'm fine with closing the issue again @superstructor. Thanks for the fix @prook!

@superstructor
Copy link
Contributor

Thanks @brjann and @prook for your discussion and input on this issue.

Myself and @Gregg8 were not quite ready to give up so I re-opened (again) and we found a solution @brjann

First, let us define the problems.

Problem 1: "Invalid Value Displayed"

If the user types a value that is subsequently modified in :on-change to the prior value of :model, such as validation or filtering, then :model is reset! to the same value as it was prior then the value the user typed (not the filtered value of :model after reset!) will remain displayed to the user as no change is detected. To fix this we force an update via (reset! external-model @internal-model). This was the original bug fixed in #187 and was fixed prior to my revert of that in 27fd1b3

Problem 2: "Flicker Between Values"

The fix to Problem 1 causes this problem. If there is a delay in processing on-change before reset! of :model, such as if on-change is asynchronous, the displayed value will 'flicker' between the prior value of :model before the user had typed anything and the eventual reset! of :model to a new value.

The root cause here is essentially we are forcing a render too early before :model has the new value. We also cannot possibly know how long to wait until the :model reaches a 'steady state'.

Solution

  1. We maintain the fix from How does input-text handle keyboard events in the :on-change function? #187 as the default behavior. Problem 1 is unacceptable, its clearly a bug, so this fix remains the default.

  2. We provide an 'escape hatch' for developers to optionally solve Problem 2 (while maintaining the fix for Problem 1) by 'signalling' when :model has reached a steady state via a 2-arity variant of on-change. E.g.

:on-change (fn [v done]
                      (js/setTimeout (fn []
                                                  (reset! model v)
                                                  (done))
                       2000))

This means that (reset! external-model @internal-model) (fix for Problem 1) will not be called until after the reset! of :model.

Input text detects the number of arguments received by on-change so you only need to call done if the function is 2-arity. This works for anonymous fns and all other forms of fns.

superstructor added a commit that referenced this issue Jan 21, 2021
@superstructor superstructor added this to the 2.11.0 milestone Jan 21, 2021
superstructor added a commit that referenced this issue Jan 21, 2021
@day8 day8 locked as resolved and limited conversation to collaborators Jan 23, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

4 participants