madvas / cljs-react-material-ui

Clojurescript library for using material-ui.com
Eclipse Public License 1.0
205 stars 32 forks source link

Caret moves to the end when editing a TextArea in reagent #17

Open euccastro opened 7 years ago

euccastro commented 7 years ago

When I render this component:

(defn simple-text-field [text]
  (let [text-state (r/atom text)]
    (fn []
      [rui/text-field
       {:id "example"
        :value @text-state
        :on-change (fn [e] (reset! text-state (.. e -target -value)))}])))

It seems to work well until I place the caret in the middle of the text and type again. Invariably, the caret jumps to the end after the insertion. The root cause seems to be that reagent re-renders components asynchronously. See http://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465

Deraen commented 6 years ago

The fix in Reagent 0.8-alpha can be used like this: https://github.com/Deraen/problems/blob/reagent-material-ui-text-field/src/hello_world/core.cljs#L11-L15 (not sure why those options have to be provided, as these "defaults" probably work usually.)

madvas commented 6 years ago

Great, thanks for this example @Deraen!

Deraen commented 6 years ago

After further testing, I noticed the code didn't update the input value correctly. This is now fixed, by selecting the real DOM input node from the TextField Div node. But this solution still has some problems, for cases where TextField has some logic based on if the value is set (hintText hidden, floatingLabel). Seems like hintText and floatingLabel have to be also updated manually on :on-update function, because the :value property is not sent to TextField component.

https://github.com/Deraen/problems/blob/reagent-material-ui-text-field/src/hello_world/core.cljs#L11-L25

radhikalism commented 6 years ago

@Deraen In my earlier draft I dealt with the value-based logic differently. See: https://github.com/madvas/cljs-react-material-ui/issues/17#issuecomment-268807607 for the full snippet. I can't test Reagent 0.8-alpha right now but it looks like it should still work.

My approach was to update hasValue and isClean states on the component, which influences how hint text and labels behave. This is executed at the correct moment via the :on-write hook on next which is invoked inside on-update:

(next ... {:on-write (fn [new-value]      
                       ;; `blank?` is effectively the same conditional as Material-UI uses
                       ;; to update its `hasValue` and `isClean` properties, which are
                       ;; required for correct rendering of hint text etc.
                       (if (clojure.string/blank? new-value)
                         (.setState component #js {:hasValue false :isClean false})
                         (.setState component #js {:hasValue true :isClean false})))
Deraen commented 6 years ago

@arbscht Calling setState on component doesn't work (if the component is the last param on on-update) because that is the NativeWrapper Reagent component, not React-native component. Or am I doing something wrong?

Deraen commented 6 years ago

@arbscht Do you remember how this should work? My current understanding is that it might be possible to get access to the real component by passing make-element result to input-render-setup / on-update at syntheric-input-spec: https://github.com/reagent-project/reagent/blob/master/src/reagent/impl/template.cljs#L292

That would make it possible to call setState (or other methods provided by TextField).

metametadata commented 6 years ago

@Deraen @arbscht I did a PoC of the solution I pitched for previously: https://github.com/metametadata/problems/blob/919abde1997dafef26e1dc8388bb58645ac6f79b/src/hello_world/core.cljs#L45

Tested in Edge, Chrome, Firefox.

It doesn't require patching Reagent and taking in account MaterialUI implementation details (i.e. :floating-label-text and :hint-text don't break after applying a fix). So I'd vote for removing synthetic-input from Reagent altogether.

radhikalism commented 6 years ago

@Deraen Upon closer examination, I think you're right that setState on component isn't correct. However, it seems to accidentally work because updating state on the NativeWrapper forces a re-render. So it should work if we simply call force-update during on-write?

radhikalism commented 6 years ago

@metametadata I like your solution, it's easier to understand. I used a similar approach as a workaround to this problem in a project. However, it had some tradeoffs:

metametadata commented 6 years ago

@arbscht great that we're on the same page.

I should say at this stage my main concern is to revert the MR about synthethic-input hooks because we know that solution can be external to Reagent, even though the provided PoC is not bulletproof. I would be interested in publishing a universal "fixer" lib myself but I don't have enough free time at the moment to accomplish it 🙂

Deraen commented 6 years ago

Reagent can't detect wrapped inputs.

I fear wrapping the components, like in @metametadata's example, is not going to be bulletproof no matter how much it is improved. There are just too many corner cases and differences between components. Only fixing TextField would require much more code than the example, and this code wouldn't work for different input components.

The reason for the problem is Reagent implementation, so it makes sense that the Reagent has the fix. I'll test my idea for fixing synthetic input.

metametadata commented 6 years ago

Reagent can't detect wrapped inputs.

Personally I don't care much about Reagent not being able to detect the wrapped inputs and warn users. Because incorrect rendering of inputs in async React wrappers is a common problem and non-beginners know about it. Beginners will bump into it themselves pretty quickly.

I fear wrapping the components, like in @metametadata's example, is not going to be bulletproof no matter how much it is improved. There are just too many corner cases and differences between components.

But we can say the same thing about the solutions based on new hooks. The main selling point of my solution (wrapping and re-rendering the component at correct time) is that it doesn't care about the internal stucture of the wrapped input and doesn't require any changes in Reagent source.

Only fixing TextField would require much more code than the example, and this code wouldn't work for different input components.

But isn't it already fully fixed in my example? As you can see, wrapping MaterialUI's TextField requires no special code, :floating-label-text and :hint-text work as before fixing.

fixed-async-input wrapper is more or less universal, I used the similar code to fix the react- bootstrap components in my project without any additional code to account for corner cases. It is also used in reagent-toolbox. The provided wrapper should work as long as original-component behaves as a typical input.

And even if there are corner cases then they can still be dealt with without any hooks exposed by Reagent lib itself.

The reason for the problem is Reagent implementation, so it makes sense that the Reagent has the fix.

Yes, the root reason of the problem is async rendering. The solution can be a part of Reagent project of course. But I don't think that the hooks are better because: 1) they bloat the core of the lib, 2) if I understand everything correctly, using them to fix TextField requires knowledge of the internal structure of the component in order to not break :floating-label-text and :hint-text.

Tl/dr: let's not touch Reagent core because:

1) There's a sane Reagent-agnostic solution: wrap a component and re-render it synchronously when needed. 2) The universal wrapper from my example already works for 90% (?) of broken components out of the box. It can be refined and put into a separate lib or be part of Reagent org repo (but this is not very important as long as users know where to find it).

radhikalism commented 6 years ago

@Deraen Here's what I've got working, based on your finding about needing to reference the inner component rather than the NativeWrapper:

In reagent template.cljs:

diff --git a/src/reagent/impl/template.cljs b/src/reagent/impl/template.cljs
index dc04813..a3169cf 100644
--- a/src/reagent/impl/template.cljs
+++ b/src/reagent/impl/template.cljs
@@ -254,6 +254,7 @@
    :reagent-render
    (fn [on-update on-change argv comp jsprops first-child]
      (let [this comp/*current-component*]
+       (oset jsprops "ref" (fn [inner] (oset this "innerComponent" inner)))
        (input-render-setup this jsprops {:synthetic-on-update on-update
                                          :synthetic-on-change on-change})
        (make-element argv comp jsprops first-child)))})

Usage:

(def text-field
  (r/adapt-react-class
   (.-TextField js/MaterialUI)
   {:synthetic-input
    {:on-update (fn [input-node-set-value node rendered-value dom-value this]
                  ;; node is the element rendered by TextField, i.e. div.
                  ;; Update input dom node value
                  (let [node (aget (.getElementsByTagName node "input") 0)]
                    (input-node-set-value node rendered-value dom-value this
                                          {:on-write (fn [new-value]
                                                       (let [inner-component (aget this "innerComponent")]
                                                         (if (clojure.string/blank? new-value)
                                                           (.setState inner-component #js {:hasValue false :isClean false})
                                                           (.setState inner-component #js {:hasValue true :isClean false}))))})))
     :on-change (fn [on-change e]
                  (on-change e (.. e -target -value)))}}))

What do you think of using refs for this purpose?

radhikalism commented 6 years ago

@metametadata On further examination, I would still like a simpler solution (and the hooks code could certainly be improved) but I'm not sure that the local-value atom workaround is preferable.

Personally I don't care much about Reagent not being able to detect the wrapped inputs and warn users.

doesn't require any changes in Reagent source.

Yes, the root reason of the problem is async rendering.

[the hooks] bloat the core of the lib,

Tl/dr: let's not touch Reagent core

Async rendering is the root problem, but a complicating factor is that Reagent makes brittle assumptions about the shape of input components. Whatever solution we choose for controlling async rendering, we still ought to clean up Reagent core's buggy assumptions and partially failing code paths. Whether we warn users or silently abort or add documentation or something else, I don't know. But we can't revert to how it was and just build more workaround layers upon a broken foundation. So we'll have to touch Reagent either way.

I would be interested in publishing a universal "fixer" lib

The universal wrapper from my example already works 90% (?) of broken components out of the box. It can be refined and put into a separate lib or be part of Reagent (but this is not very important as long as users know where to find it).

Actually I think this is important for users. Having to use a "fixer" library just to make text fields work is kind of a bad smell. Also, users may not easily know when to use Reagent normally and when to switch to this "fixer" API or pattern. It would be better to make Reagent work in the general case even if it adds some complexity via discoverable optional parameters at the use site.

The main selling point of my solution (re-rendering the component at correct time) is that it doesn't care about the internal stucture of the wrapped input

if I understand everything correctly, using them to fix TextField requires knowledge of the internal structure of the component in order to not break :floating-label-text and :hint-text.

In the hooks-based solution, all requirements for special knowledge are clearly separated.

Agree, out of sync values can be a problem. I haven't encountered anything like this situation yet though so can't tell how to go forward about it.

I have encountered this use case, which is what led me to the approach involving hooks in Reagent in the first place. Filtering user input for validation or triggering help etc. turned out to be tricky when generically wrapping on-change on the input widget. As mentioned, there may be ways to workaround this but AFAIK it involves breaking the contract/signature of the typical on-change callback (which end users are exposed to). Whereas the hooks-based approach only changes the adapt-react-class API that is more rarely used. And adding that kind of support will make the external-state option increasingly complex too.

metametadata commented 6 years ago

Whatever solution we choose for controlling async rendering, we still ought to clean up Reagent core's buggy assumptions and partially failing code paths. Whether we warn users or silently abort or add documentation or something else, I don't know. But we can't revert to how it was and just build more workaround layers upon a broken foundation. So we'll have to touch Reagent either way.

I'm not sure I understand what are the partially failing code paths and what is currently broken in the foundation. Reagent only promises to render "raw" :inputs correctly and it seems to work fine at the moment. If there's some cleaning up needed in that part of codebase then it should be a separate issue. But it's not an argument for adding new public API.

Having to use a "fixer" library just to make text fields work is kind of a bad smell. Also, users may not easily know when to use Reagent normally and when to switch to this "fixer" API or pattern. It would be better to make Reagent work in the general case even if it adds some complexity via discoverable optional parameters at the use site.

The bad smell will stay anyways as the problem is inherent to async rendering. I.e. I can as well say that using hooks to make text fields work is a bad smell :) It will still have to be explained when to use hooks and how. And anyways not many people read docs that thoroughly until they see bugs in their apps. And if they do read then we can just put a link to "fixer" lib or any other non-hook solution in the docs for them. So IMHO adding optional public API to give users a hint about potential problem is a weak argument for hooks.

The integrating UI library or user code (in this case cljs-react-material-ui) needs to know a little about the widgets that it is supplying to Reagent via adapt-react-class, and encapsulate it using optional callbacks. I think this is a reasonable expectation.

I think that's the worst part of the hook-based solution. As a user of the thrid-party lib I don't want ever to depend on its implementation details. In the next version they decide to rename isClean to something else in MaterialUI.js and the CLJS apps which use this flag in hooks will break. It's just too much to test and understand. Why users would spend time on testing and figuring out the internals of MaterialUI's text fields if a universal "fixer wrapper" just always works out of the box without dependency on original component implementation?

Filtering user input for validation or triggering help etc. turned out to be tricky when generically wrapping on-change on the input widget. As mentioned, there may be ways to workaround this but AFAIK it involves breaking the contract/signature of the typical on-change callback (which end users are exposed to).

OK, it's hard to reason about it for me without looking at some code. I did have some validation/filtering in custom inputs but maybe my use-case was different. Can you maybe provide a simple use case so that I could think how wrapper fix could be accomodated for such scenario? I will try to create an example demo.

But let's imagine it will require changing the contract of on-change callback. Then I guess I would still prefer such "non-standard-API" wrapper to hook-based solution which preserves the callback API but depends on internals of the original components. I use react-bootstrap JS lib in my project directly without any CLJS wrapper libs (well, I use cljsjs version but it doesn't count as wrapper) and would like to upgrade to new versions without re-testing all my fixed inputs.

radhikalism commented 6 years ago

Reagent only promises to render "raw" :inputs correctly and it seems to work fine at the moment.

It's not clear to me this is the case. Maybe I missed something in the docs, but the behaviour seems to be undefined/quirky rather than well-defined as only supporting "raw" inputs. This bug in c-r-m-ui arose in the first place because it tried to use adapt-react-class with the expectation that Reagent would support complex TextFields. So I don't think it's obvious. For this reason, I suggested that if Reagent is going to commit to only supporting "raw" inputs, it should explicitly assert/warn/fail/document as such.

The mess in Reagent is that certain code paths make an effort to support complex input components by attempting to render, set state, trigger events, etc. Some of these behaviours succeed and others do not. It can be misleading and confusing since a basic use of a complex input field seems to work at first, and then certain features don't, but there is no explanation why. I don't have time to walk through all the code that tries to adapt complex inputs only to fail slowly. If you dig into reagent-project/reagent#282 you can see the different code paths described (which I'm suggesting should either execute correctly, or not execute at all).

The bad smell will stay anyways as the problem is inherent to async rendering. I.e. I can as well say that using hooks to make text fields work is a bad smell :)

Yes, I agree it's unpleasant and can be improved. But the question for me is which option is worse.

It will still have to be explained when to use hooks and how.

There is a key difference though:

And anyways not many people read docs that thoroughly until they see bugs in their apps.

I think this is a good point and we should probably add a hint or assertion that the adapted class is not functioning correctly along the code paths I mentioned above. But I think it's a nice-to-have if there are documented hooks provided, and more important if the user is supposed to rely on an external workaround or pattern.

OK, it's hard to reason about it for me without looking at some code. I did have some validation/filtering in custom inputs but maybe my use-case was different. Can you maybe provide a simple use case so that I could think how wrapper fix could be accomodated for such scenario? I will try to create an example demo.

In on-change just make it conditional whether text-state gets updated or not. e.g. try to enforce a length limit such as 8 chars -- (when (<= len 8) (swap! text-state ...)). Without any further workarounds, the PoC will update local-value eagerly but text-state may or may not remain the same.

I use react-bootstrap JS lib in my project directly without any CLJS wrapper libs (well, I use cljsjs version but it doesn't count as wrapper) and would like to upgrade to new versions without re-testing all my fixed inputs.

One thing I've realized in this discussion is that in the universe where hooks are the better option, it shows that certain aspects of Material-UI's internals to do with DOM structure as well as hasValue or isClean is an interesting concern for its users such as c-r-m-ui. I think a case could be made that Material-UI should expose those necessary attributes with a stable public API (if not already, I haven't checked). A PR there would be a good idea to settle that risk, if there isn't a cleaner way to communicate with the original component.

Regardless of that point, I think we still need to consider which is the worse option between the two before us if upstream did nothing. One option is to depend on particular supported combinations of Material-UI+c-r-m-ui in order to use Reagent's hooks. The other option is to depend on a particular supported combination of the "fixer" library (or the local-value pattern used directly) plus Reagent (since its behaviour is buggy, undefined or quirky). At best these are equally bad or good options, I don't think it's convincing either way.

This is a pretty complex issue because of integration across multiple projects and stuff falling through the cracks, so there are lots of combinations for solutions. I don't know if adding hooks is really the best answer but I'm increasingly sure an external workaround wrapper which will alter the contract of an important callback (just for this one weird upstream bug or limitation)... is not obviously a better one.

metametadata commented 6 years ago

This bug in c-r-m-ui arose in the first place because it tried to use adapt-react-class with the expectation that Reagent would support complex TextFields. So I don't think it's obvious. I suggested that if Reagent is going to commit to only supporting "raw" inputs, it should explicitly assert/warn/fail/document as such.

+1. I had to read issues in Reagent and other projects to get that Reagent won't try to fix complex inputs but only its "own"/"raw" inputs. Specifically, this is the issue from Reagent: https://github.com/reagent-project/reagent/issues/79.

The mess in Reagent is that certain code paths make an effort to support complex input components by attempting to render, set state, trigger events, etc.

Now I understand, thanks.

In fact, if the actual user is already depending on a glue library like c-r-m-ui or similar, the override can be defined there for free.

So in this case c-r-m-u-i will have to depend on the way TextField is implemented in MaterialUI.js. It's bad because no code should depend on implementation details of some other code. To me it's a very pretty strong argument against the "hooks" solution. "Hooks" are dangerous because the resulting code will be error-prone by design. On the other hand, "wrapper" solution is always safe by design. Introducing "hooks" in Reagent would contribute to spreading bad habits amongst programmers for the sake of supporting masking/filtering/etc. in inputs.

The only way to avoid this that I can think of is to only support the 80% case, which is probably acceptable for one-off hacks as a user, but defeats the purpose of a general UI library toolkit. ... e.g. try to enforce a length limit such as 8 chars -- (when (<= len 8) (swap! text-state ...)). Without any further workarounds, the PoC will update local-value eagerly but text-state may or may not remain the same.

It looks like React itself doesn't support the filtering/masking scenario you'd like to have, see https://github.com/facebook/react/issues/955:

"this is not a bug because React cannot guess the cursor position if you manipulate the input value in some way"

Thus it's an overambitious task to make Reagent fix inputs and complex inputs to behave differently from what React does by default. I'm totally fine if Reagent wouldn't help with this problem at all and (even more so) I'm fine if wrapper libs such as c-r-m-u-i wouldn't try to solve it either. The PoC "wrapper" pattern strives only to make the complex controlled input behave in the same as the usual React controlled input, i.e. prevent cursor jumps in a normal scenario where user sees the results of the key presses, without any masking, filtering, etc in place.

... Material-UI's internals to do with DOM structure as well as hasValue or isClean is an interesting concern for its users such as c-r-m-ui. I think a case could be made that Material-UI should expose those necessary attributes with a stable public API (if not already, I haven't checked). A PR there would be a good idea to settle that risk, if there isn't a cleaner way to communicate with the original component.

Sounds unlikely that the authors of MaterialUI or any other complex inputs would ever bother with providing such public API only because Reagent decided to fix the problem not solved in React itself :)

TL/DR:

metametadata commented 6 years ago

Actually it looks like Om provides a "wrapper" solution officially:

https://github.com/omcljs/om/blob/7ab33e8dee1133cf031df93a08787531a2f1985d/src/main/om/dom.cljs#L20

radhikalism commented 6 years ago

+1. I had to read issues in Reagent and other projects to get that Reagent won't try to fix complex inputs but only its "own"/"raw" inputs. Specifically, this is the issue from Reagent: reagent-project/reagent#79.

There are couple of important nuances in the discussion around reagent-project/reagent#79 and reagent-project/reagent#126 and our current topic:

It looks like React itself doesn't support the filtering/masking scenario you'd like to have, see facebook/react#955:

I'm not sure this holds, given that Reagent actually does make an effort to manage caret positions because of 79 and 126.

Also, quoting from 126:

[This patch] is for situations where the programmer is using that on-change for transform or validation purposes. For example, if the programmer wishes to enforce a certain pattern, like "99.9". If the user enters an "a" char, then the programmer may wish to reject it immediately. So on-change provides a general transformation and verification mechanism useful in cases where you don't want to wait for on-blur or form submission.

So I figure this scenario is intended to be supported after all. I don't think it is overambitious to make the behaviour pluggable so Reagent's existing caret-managing code paths can successfully complete executing with respect to regular HTML input etc, which happen to appear in the component's DOM as a child of other container elements.

Sounds unlikely that the authors of MaterialUI or any other complex inputs would ever bother with providing such public API

The particular implementation details which are interesting are:

only because Reagent decided to fix the problem not solved in React itself :)

To be clear, it is a problem that Reagent has decided to fix already, since 79 and 126, including apparently the filtering/validation use case (if I understand the issue log correctly). The hooks extension is not about expanding the scope of this behaviour to custom HTML tags or diverging any further from original React. It is to allow already-supported HTML components to be correctly located within any arbitrary wrapping of a third-party component, and to callback to the original component in order to sync/reset using a common UI pattern, using the code paths that Reagent is already providing for caret management.

Actually it looks like Om provides a "wrapper" solution officially: https://github.com/omcljs/om/blob/7ab33e8dee1133cf031df93a08787531a2f1985d/src/main/om/dom.cljs#L20

That's interesting. I need to review that code more closely when I have time, but so far it looks to me like Om would have the same limitations as the wrapper solution here, which diverges from the direction Reagent has gone towards since 79 and 126.

metametadata commented 6 years ago

Got it. So Reagent tries to fix cursor jumps when one applies what I called a "naive" filtering/masking pattern and it cannot do the same with adapted components (which I erroneously called "complex inputs") unless "hooks" API is exposed.

Deraen commented 6 years ago

I implemented change to use ref to get reference to the real component which can be used by on-update function: https://github.com/reagent-project/reagent/pull/351

Deraen commented 6 years ago

Some new developments on https://github.com/reagent-project/reagent/pull/351. The fix I found won't work with MaterialUI 1.0-beta.

vharmain commented 6 years ago

In case someone is looking for a simple fix to caret problem with MaterialUI 1.0 and re-frame, providing following InputProps to TextField component seems to do the trick:

{:InputProps
  {:inputComponent
    (r/reactify-component
      (fn [props]
        [:input (dissoc props :inputRef)]))}}

For some reason replacing the default implementation with [:input ...] makes reagent recognize it as normal input and apply the "caret positioning fix".

Tested with [cljsjs/material-ui "1.0.0-beta.40-0"] and [reagent "0.8.1"].

Hope this helps someone.

Deraen commented 6 years ago

@vharmain Nice! "For some reason", the reason being that in this case the input element is created through Reagent logic which will wrap the component in Reagent input component with the fixes. Reagent doesn't have any control if the component is created directly using React. This seems like very good solution to this problem, no hacks required anywhere, just use of proper option in Material-UI Input component: https://material-ui.com/api/input/

vharmain commented 6 years ago

Cool, that makes sense. Thanks for the explanation @Deraen.

Deraen commented 6 years ago

This is now documented in Reagent repo, with example project. Some additional logic is required for :select and :multiline TextFields, and autosize textarea can't be fixed currently.

https://github.com/reagent-project/reagent/blob/master/doc/examples/material-ui.md https://github.com/reagent-project/reagent/blob/master/examples/material-ui/src/example/core.cljs

p-himik commented 5 years ago

I've just stumbled upon this issue after trying to switch from re-com to Material UI. One of the proposed fixes I've seen on Clojurians Slack is to call reagent/flush in the :onChange handler. And it appears to be working. It's a big discussion and a bit hard to follow given the number of the links and the amount of knowledge of all of the internals. Could you please help me understand why reagent/flush has never been even mentioned? Is it somehow a fundamentally flawed solution?

An update: the solution with reagent/flush works only in tandem with storing an internal model, like it's done here: https://github.com/Day8/re-com/blob/master/src/re_com/misc.cljs#L87-L89