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

Use Elmish.Program in useElmish #414

Merged
merged 4 commits into from
Nov 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Feliz.UseElmish/Feliz.UseElmish.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="4.7.2" />
<PackageReference Include="Fable.Promise" Version="2.0.0" />
<PackageReference Include="Fable.Elmish" Version="3.0.6" />
</ItemGroup>
</Project>
167 changes: 70 additions & 97 deletions Feliz.UseElmish/UseElmish.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,117 +2,90 @@ namespace Feliz.UseElmish

open Feliz
open Elmish
open Fable.Core

[<Struct>]
type internal RingState<'item> =
| Writable of wx:'item array * ix:int
| ReadWritable of rw:'item array * wix:int * rix:int

type internal RingBuffer<'item>(size) =
let doubleSize ix (items: 'item array) =
seq { yield! items |> Seq.skip ix
yield! items |> Seq.take ix
for _ in 0..items.Length do
yield Unchecked.defaultof<'item> }
|> Array.ofSeq

let mutable state : 'item RingState =
Writable (Array.zeroCreate (max size 10), 0)

member _.Pop() =
match state with
| ReadWritable (items, wix, rix) ->
let rix' = (rix + 1) % items.Length
match rix' = wix with
| true ->
state <- Writable(items, wix)
| _ ->
state <- ReadWritable(items, wix, rix')
Some items.[rix]
| _ ->
None

member _.Push (item:'item) =
match state with
| Writable (items, ix) ->
items.[ix] <- item
let wix = (ix + 1) % items.Length
state <- ReadWritable(items, wix, ix)
| ReadWritable (items, wix, rix) ->
items.[wix] <- item
let wix' = (wix + 1) % items.Length
match wix' = rix with
| true ->
state <- ReadWritable(items |> doubleSize rix, items.Length, 0)
| _ ->
state <- ReadWritable(items, wix', rix)

[<AutoOpen>]
module UseElmishExtensions =
let inline internal getDisposable (record: 'State) =
match box record with
| :? System.IDisposable as disposable -> Some disposable
| _ -> None
type private ElmishObservable<'Model, 'Msg>() =
let mutable hasDisposedOnce = false
let mutable state: 'Model option = None
let mutable listener: ('Model -> unit) option = None
let mutable dispatcher: ('Msg -> unit) option = None

member _.Value = state
member _.HasDisposedOnce = hasDisposedOnce

member _.SetState (model: 'Model) (dispatch: 'Msg -> unit) =
state <- Some model
dispatcher <- Some dispatch
match listener with
| None -> ()
| Some listener -> listener model

member _.Dispatch(msg) =
match dispatcher with
| None -> () // Error?
| Some dispatch -> dispatch msg

member _.Subscribe(f) =
match listener with
| Some _ -> ()
| None -> listener <- Some f

/// Disposes state (and dispatcher) but keeps subscription
member _.DisposeState() =
match state with
| Some state ->
match box state with
| :? System.IDisposable as disp -> disp.Dispose()
| _ -> ()
| _ -> ()
dispatcher <- None
state <- None
hasDisposedOnce <- true

let private runProgram (program: unit -> Program<'Arg, 'Model, 'Msg, unit>) (arg: 'Arg) (obs: ElmishObservable<'Model, 'Msg>) () =
program()
|> Program.withSetState obs.SetState
|> Program.runWith arg

match obs.Value with
| None -> failwith "Elmish program has not initialized"
| Some v -> v

let disposeState (state: obj) =
match box state with
| :? System.IDisposable as disp -> disp.Dispose()
| _ -> ()

type React with
[<Hook>]
static member useElmish<'State,'Msg> (init: 'State * Cmd<'Msg>, update: 'Msg -> 'State -> 'State * Cmd<'Msg>, dependencies: obj[]) =
let state = React.useRef(fst init)
let ring = React.useRef(RingBuffer(10))
let childState, setChildState = React.useState(fst init)
let token = React.useCancellationToken()
let setChildState () =
JS.setTimeout(fun () ->
if not token.current.IsCancellationRequested then
setChildState state.current
) 0 |> ignore

let rec dispatch (msg: 'Msg) =
promise {
let mutable nextMsg = Some msg

while nextMsg.IsSome && not (token.current.IsCancellationRequested) do
let msg = nextMsg.Value
let (state', cmd') = update msg state.current
cmd' |> List.iter (fun sub -> sub dispatch)
nextMsg <- ring.current.Pop()
state.current <- state'
setChildState()
}
|> Promise.start

let dispatch = React.useCallbackRef(dispatch)
static member useElmish(program: unit -> Program<'Arg, 'Model, 'Msg, unit>, arg: 'Arg, ?dependencies: obj array) =
// Don't use useMemo here because React doesn't guarantee it won't recreate it again
let obs, _ = React.useState(fun () -> ElmishObservable<'Model, 'Msg>())

React.useEffect((fun () ->
React.createDisposable(fun () ->
getDisposable state.current
|> Option.iter (fun o -> o.Dispose())
)
), dependencies)
let state, setState = React.useState(runProgram program arg obs)

React.useEffect((fun () ->
state.current <- fst init
setChildState()

snd init
|> List.iter (fun sub -> sub dispatch)
), dependencies)
if obs.HasDisposedOnce then
runProgram program arg obs () |> setState
React.createDisposable(obs.DisposeState)
), defaultArg dependencies [||])

React.useEffect(fun () -> ring.current.Pop() |> Option.iter dispatch)

(childState, dispatch)
obs.Subscribe(setState)
state, obs.Dispatch

[<Hook>]
static member inline useElmish<'State,'Msg> (init: 'State * Cmd<'Msg>, update: 'Msg -> 'State -> 'State * Cmd<'Msg>) =
React.useElmish(init, update, [||])
static member useElmish(program: unit -> Program<unit, 'Model, 'Msg, unit>, ?dependencies: obj array) =
React.useElmish(program, (), ?dependencies=dependencies)

[<Hook>]
static member useElmish<'State,'Msg> (init: unit -> 'State * Cmd<'Msg>, update: 'Msg -> 'State -> 'State * Cmd<'Msg>, dependencies: obj[]) =
let init = React.useMemo(init, dependencies)
static member useElmish(init: 'Arg -> 'Model * Cmd<'Msg>, update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, arg: 'Arg, ?dependencies: obj array) =
React.useElmish((fun () -> Program.mkProgram init update (fun _ _ -> ())), arg, ?dependencies=dependencies)

React.useElmish(init, update, dependencies)
[<Hook>]
static member useElmish(init: unit -> 'Model * Cmd<'Msg>, update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, ?dependencies: obj array) =
React.useElmish((fun () -> Program.mkProgram init update (fun _ _ -> ())), ?dependencies=dependencies)

[<Hook>]
static member inline useElmish<'State,'Msg> (init: unit -> 'State * Cmd<'Msg>, update: 'Msg -> 'State -> 'State * Cmd<'Msg>) =
React.useElmish(init, update, [||])
static member useElmish(init: 'Model * Cmd<'Msg>, update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, ?dependencies: obj array) =
React.useElmish((fun () -> Program.mkProgram (fun () -> init) update (fun _ _ -> ())), ?dependencies=dependencies)
1 change: 1 addition & 0 deletions docs/paket.references
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Fable.React
Fable.SimpleHttp
FSharp.Core
Zanaptak.TypedCssClasses
Fable.Promise
1 change: 1 addition & 0 deletions paket.dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ group Main
nuget Fable.Mocha
nuget Fable.React
nuget Fable.SimpleHttp
nuget Fable.Promise >= 3.1
nuget FSharp.Core ~> 4.7.2
nuget Zanaptak.TypedCssClasses

Expand Down
3 changes: 3 additions & 0 deletions paket.lock
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ NUGET
Fable.Mocha (2.9.1)
Fable.Core (>= 3.0) - restriction: >= netstandard2.0
FSharp.Core (>= 4.7) - restriction: >= netstandard2.0
Fable.Promise (3.1)
Fable.Core (>= 3.1.5) - restriction: >= netstandard2.0
FSharp.Core (>= 4.7.2) - restriction: >= netstandard2.0
Fable.React (7.4)
Fable.Browser.Dom (>= 2.0.1) - restriction: >= netstandard2.0
Fable.Core (>= 3.1.5) - restriction: >= netstandard2.0
Expand Down
69 changes: 66 additions & 3 deletions tests/Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -561,27 +561,49 @@ module UseElmish =

type Msg =
| Increment
| IncrementAgain

let init = 0, Cmd.none

let update msg state =
match msg with
| Increment -> state + 1, Cmd.none
| IncrementAgain -> state + 1, Cmd.ofMsg Increment

let render = React.functionComponent(fun () ->
let state,dispatch = React.useElmish(init, update, [||])
let render = React.functionComponent(fun (props: {| subtitle: string |}) ->
let state, dispatch = React.useElmish(init, update, [|box props.subtitle|])

Html.div [
Html.h1 [
prop.testId "count"
prop.text state
]

Html.h2 props.subtitle

Html.button [
prop.text "Increment"
prop.onClick (fun _ -> dispatch Increment)
prop.testId "increment"
]

Html.button [
prop.text "Increment again"
prop.onClick (fun _ -> dispatch IncrementAgain)
prop.testId "increment-again"
]

])

let wrapper = React.functionComponent(fun () ->
let count, setCount = React.useState 0
Html.div [
Html.button [
prop.text "Increment wrapper"
prop.onClick (fun _ -> count + 1 |> setCount)
prop.testId "increment-wrapper"
]
render {| subtitle = if count < 2 then "foo" else "bar" |}
])

let felizTests = testList "Feliz Tests" [
Expand Down Expand Up @@ -1101,7 +1123,7 @@ let felizTests = testList "Feliz Tests" [
}

testReactAsync "useElmish works" <| async {
let render = RTL.render(UseElmish.render())
let render = RTL.render(UseElmish.render {| subtitle = "foo" |})

Expect.equal (render.getByTestId("count").innerText) "0" "Should be initial state"

Expand All @@ -1112,6 +1134,47 @@ let felizTests = testList "Feliz Tests" [
Expect.equal (render.getByTestId("count").innerText) "1" "Should have been incremented"
|> Async.AwaitPromise
}

// See https://github.com/fable-compiler/fable-promise/issues/24#issuecomment-934328900
testReactAsync "useElmish works with commands" <| async {
let render = RTL.render(UseElmish.render {| subtitle = "foo" |})

Expect.equal (render.getByTestId("count").innerText) "0" "Should be initial state"

render.getByTestId("increment-again").click()

do!
RTL.waitFor <| fun () ->
Expect.equal (render.getByTestId("count").innerText) "2" "Should have been incremented twice"
|> Async.AwaitPromise
}

testReactAsync "useElmish works with dependencies" <| async {
let render = RTL.render(UseElmish.wrapper())

Expect.equal (render.getByTestId("count").innerText) "0" "Should be initial state"

render.getByTestId("increment").click()

do!
RTL.waitFor <| fun () ->
Expect.equal (render.getByTestId("count").innerText) "1" "Should have been incremented"
|> Async.AwaitPromise

render.getByTestId("increment-wrapper").click()

do!
RTL.waitFor <| fun () ->
Expect.equal (render.getByTestId("count").innerText) "1" "State should be same because dependency hasn't changed"
|> Async.AwaitPromise

render.getByTestId("increment-wrapper").click()

do!
RTL.waitFor <| fun () ->
Expect.equal (render.getByTestId("count").innerText) "0" "State should have been reset because dependency has changed"
|> Async.AwaitPromise
}
]

[<EntryPoint>]
Expand Down
1 change: 1 addition & 0 deletions tests/paket.references
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Fable.Browser.Dom
Fable.Mocha
FSharp.Core
Fable.Promise