Fable.React - editing an Input field moves cursor to end of text

1.2k Views Asked by At

I'm working with the Elmish.Bridge flavor of the SAFE stack.

At the top level of the view, I create an input field like this:

Input.input [ Input.Value(model.FieldValue); Input.OnChange(fun e -> dispatch (EditFieldValue(e.Value))) ]

When I edit that field's value by typing in the middle of it, the model is updated as expected, but the cursor also moves to the end of the input's text.

My model is several layers deep and is completely made of serializable types (primitives, strings, collections, records, and unions).

I tried reproducing this in a toy app (where the model is much less complicated), but it works as expected there - the cursor maintains position.

Is there any way to determine why the cursor is moving under these circumstances?

6

There are 6 best solutions below

3
On BEST ANSWER

Using the DefaultValue instead of Value should fix this.

This works with Fable.React and Fable.Elmish. Using DefaultValue will not initiate an update of the component / DOM in the react lifecyle and you should therefore not get the cursor changes.

From the link below: react uncontrolled components

In the React rendering lifecycle, the value attribute on form elements will override the value in the DOM. With an uncontrolled component, you often want React to specify the initial value, but leave subsequent updates uncontrolled. To handle this case, you can specify a defaultValue attribute instead of value.

0
On

A solution to keep this from happening is to augment your onChange function as follows.

let onChangeWithCursorAdjustment (myOnChangeFunction: Browser.Types.Event -> unit) (event: Browser.Types.Event) =
    let target = event.target :?> Browser.Types.HTMLInputElement
    let prevBeginPos = target.selectionStart
    let prevEndPos = target.selectionEnd

    myOnChangeFunction event

    Browser.Dom.window.requestAnimationFrame (fun _ -> 
        target.selectionStart <- prevBeginPos
        target.selectionEnd <- prevEndPos
    ) |> ignore

And use it like this (for an text input field):

input [] [
    Type "text"
    Value someChangingValue
    OnChange (onChangeWithCursorAdjustment myOnChangeFunction)
]

This will stop the cursor jumping to the end of the input field.

1
On

I had a similar issue, and it turned out to be because React was deleting my DOM object and recreating it. (That in turn was caused by the fact that my React element, though identical in both code branches, had different elements as parents in the two different branches.)

If React does not delete/recreate your DOM object, your cursor should behave normally. If you want to figure out what is causing your DOM to be updated/created, adding some printfns inside of a Hooks.useEffectDisposable call. Add one printfn inside the effect, which will tell you when the element is being mounted, and another inside of the IDisposable to tell you when it's being unmounted.

Using an uncontrolled component can work in certain circumstances, but it will prevent your text from updating if something else changes in the model, e.g. after someone hits an "OK" button you can't clear the textbox. It's better to just get your head around the React lifecycle and fix whatever issue is causing React to re-render your DOM.

0
On

at first, my english is not good. please understand it.

i experienced the problem too, so i tried to find a workaround.

i just guess that the reason may be time interval between dom event and react.js's re-rendering, but i cant make sure that.

a key point about the workaround is to set/get cursor position manually in componentDidUpdate lifecycle.

below are example code.

i hope that it would be helpful.

module NotWorks =

    type MsgX = | InputX of string
                | InputX2 of string

    let subjectX , observableX = FSharp.Control.AsyncRx.subject<MsgX>()

    type ModelX = {
        input : string
        input2 : string
    }

    let updateX (modelX : ModelX) (msgX : MsgX) : ModelX =
        match msgX with
        | MsgX.InputX(s) -> { modelX with input = (s) }
        | MsgX.InputX2(s) -> { modelX with input2 = (s) }

    type ComponentX(modelX : ModelX) as this =
        inherit Fable.React.Component<ModelX, unit>(modelX) with
        override _.render() : ReactElement =
            div []
                [
                    textarea
                        [
                            OnChange this.OnChange
                            Value this.props.input
                            Rows 10
                            Cols 40
                        ]
                        []
                    textarea
                        [
                            OnChange this.OnChange2
                            Value this.props.input2
                            Rows 10
                            Cols 40
                        ]
                        []
                ]

        override _.componentDidUpdate(_ : ModelX, _ : unit) : unit =
            ()
        member _.OnChange(e : Browser.Types.Event) : unit =
            let target = e.target :?> Browser.Types.HTMLTextAreaElement
            async {
                // in here, no interval. but the problem can appeared sometimes.
                do! subjectX.OnNextAsync(MsgX.InputX(target.value))
            } |> Async.Start

        member _.OnChange2(e : Browser.Types.Event) : unit =
            let target = e.target :?> Browser.Types.HTMLTextAreaElement
            let x = target.value
            async {
                do! Async.Sleep(1) // this makes interval. the problem appears always.
                do! subjectX.OnNextAsync(MsgX.InputX2(x))
            } |> Async.Start

    let viewX (modelX : ModelX) (_ : Dispatch<MsgX>) : ReactElement =
        Fable.React.Helpers.ofType<ComponentX, ModelX, unit>(modelX) []

    let componentX =
        Fable.Reaction.Reaction.StreamView
            {input = ""; input2 = ""}
            viewX
            updateX
            (fun _ o ->
                o
                |> FSharp.Control.AsyncRx.merge observableX
                |> Fable.Reaction.AsyncRx.tag "x"
            )


module Works =

    type MsgX = | InputX of string * int * int

    let subjectX , observableX = FSharp.Control.AsyncRx.subject<MsgX>()

    type ModelX = {
        input : string * int * int
    }

    let updateX (modelX : ModelX) (msgX : MsgX) : ModelX =
        match msgX with
        | MsgX.InputX(s, start, ``end``) -> { modelX with input = (s, start, ``end``) }

    type ComponentX(modelX : ModelX) as this =
        inherit Fable.React.Component<ModelX, unit>(modelX) with
        // we need a ref to get/set cursor position.
        let mutable refTextArea : Option<Browser.Types.HTMLTextAreaElement> = None
        override _.render() : ReactElement =
            let s, _, _ = this.props.input
            textarea
                [
                    Props.Ref(fun e -> refTextArea <- Some(e :?> Browser.Types.HTMLTextAreaElement))
                    OnChange this.OnChange
                    Value s
                    Rows 10
                    Cols 40
                ]
                []
        override _.componentDidUpdate(_ : ModelX, _ : unit) : unit =
            // set cursor position manually using saved model data.
            refTextArea
            |> Option.iter (fun elem ->
                let _, start, ``end`` = this.props.input // must use current model data but not previous model data.
                elem.selectionStart <- start;
                elem.selectionEnd <- ``end``
            )
        member _.OnChange(e : Browser.Types.Event) : unit =
            let target = e.target :?> Browser.Types.HTMLTextAreaElement
            // save cursor position.
            let x = target.value
            let start = target.selectionStart
            let ``end``= target.selectionEnd
            async {
                do! subjectX.OnNextAsync(MsgX.InputX(x, start, ``end``))
            } |> Async.Start

    let viewX (modelX : ModelX) (_ : Dispatch<MsgX>) : ReactElement =
        Fable.React.Helpers.ofType<ComponentX, ModelX, unit>(modelX) []

    let componentX =
        Fable.Reaction.Reaction.StreamView
            {input = "", 0, 0}
            viewX
            updateX
            (fun _ o ->
                o
                |> FSharp.Control.AsyncRx.merge observableX
                |> Fable.Reaction.AsyncRx.tag "x"
            )
0
On

i missed something that i had to say.

if there are one more textarea tags, above workaround causes focus changing always.

so there should be some logic to prevent this.

    module Works =

    type MsgX = | InputX of string * int * int
                | InputX2 of string
                | ChangeTime

    let subjectX , observableX = FSharp.Control.AsyncRx.subject<MsgX>()

    type ModelX = {
        // use timestamp to determine whether there should be setting cursor position or not.
        time : int
        input : int * string * int * int
        input2 : string
    }

    let updateX (modelX : ModelX) (msgX : MsgX) : ModelX =
        match msgX with
        | MsgX.InputX(s, start, ``end``) ->
            { modelX with
                time = modelX.time + 1;
                input = (modelX.time + 1, s, start, ``end``)
            }
        | ChangeTime -> { modelX with time = modelX.time + 1 }
        | MsgX.InputX2 s -> { modelX with input2 = s }

    type ComponentX(modelX : ModelX) as this =
        inherit Fable.React.Component<ModelX, unit>(modelX) with
        let mutable refTextArea : Option<Browser.Types.HTMLTextAreaElement> = None
        override _.render() : ReactElement =
            let _, s, _, _ = this.props.input
            div []
                [
                    textarea
                        [
                            Props.Ref(fun e -> refTextArea <- Some(e :?> Browser.Types.HTMLTextAreaElement))
                            OnChange this.OnChange
                            OnBlur this.OnBlur
                            Value s
                            Rows 10
                            Cols 40
                        ]
                        []
                    textarea
                        [
                            OnChange this.OnChange2
                            Value this.props.input2
                        ]
                        []
                ]
        override _.componentDidUpdate(_ : ModelX, _ : unit) : unit =
            refTextArea
            |> Option.filter (fun _ ->
                // determine whether there should be setting cursor position or not.
                let time, _, _, _ = this.props.input
                time = this.props.time
            )
            |> Option.iter (fun elem ->
                let _, _, start, ``end`` = this.props.input
                elem.selectionStart <- start;
                elem.selectionEnd <- ``end``
            )
        member _.OnChange(e : Browser.Types.Event) : unit =
            let target = e.target :?> Browser.Types.HTMLTextAreaElement
            let x = target.value
            let start = target.selectionStart
            let ``end``= target.selectionEnd
            async {
                do! subjectX.OnNextAsync(MsgX.InputX(x, start, ``end``))
            } |> Async.Start
        member _.OnChange2(e : Browser.Types.Event) : unit =
            subjectX.OnNextAsync(MsgX.InputX2 e.Value) |> Async.Start
        member _.OnBlur(e : Browser.Types.Event) : unit =
            subjectX.OnNextAsync(MsgX.ChangeTime) |> Async.Start

    let viewX (modelX : ModelX) (_ : Dispatch<MsgX>) : ReactElement =
        Fable.React.Helpers.ofType<ComponentX, ModelX, unit>(modelX) []

    let componentX =
        Fable.Reaction.Reaction.StreamView
            {time = 1; input = 0, "", 0, 0; input2 = ""}
            viewX
            updateX
            (fun _ o ->
                o
                |> FSharp.Control.AsyncRx.merge observableX
                |> Fable.Reaction.AsyncRx.tag "x"
            )
    mountById "elmish-app" (ofFunction componentX () [])

the key point is to use timestamp.

of course, i admit that this workaround is quite complex and ugly.

just reference it please.

0
On

Use Fable.React.Helpers.valueOrDefault instead of DefaultValue or Value:

/// `Ref` callback that sets the value of an input textbox after DOM element is created.
// Can be used instead of `DefaultValue` and `Value` props to override input box value.
let inline valueOrDefault value =
        Ref <| (fun e -> if e |> isNull |> not && !!e?value <> !!value then e?value <- !!value)

Maximillian points out in his answer that it's because React is recreating the DOM object. In my attempt to figure out how to use hooks to determine why, I ended up learning about React Hooks and about how to use them from Fable, but I never figured out how to use them to figure out what's causing the DOM object to be deleted.