diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 00e49ba..0d4a290 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "fable": { - "version": "4.9.0", + "version": "4.7.0", "commands": [ "fable" ] diff --git a/src/ElmishStore.Example/ElmishStore.Example.fsproj b/src/ElmishStore.Example/ElmishStore.Example.fsproj index 7efec8c..47d5555 100644 --- a/src/ElmishStore.Example/ElmishStore.Example.fsproj +++ b/src/ElmishStore.Example/ElmishStore.Example.fsproj @@ -1,4 +1,4 @@ - + net8.0 @@ -31,8 +31,9 @@ + - \ No newline at end of file + diff --git a/src/ElmishStore.Example/ModelStore.fs b/src/ElmishStore.Example/ModelStore.fs index 5929837..87698a1 100644 --- a/src/ElmishStore.Example/ModelStore.fs +++ b/src/ElmishStore.Example/ModelStore.fs @@ -11,19 +11,35 @@ open Elmish.Debug #endif -let store = - Program.mkProgram init update (fun _ _ -> ()) - #if DEBUG - |> Program.withConsoleTrace - |> Program.withDebugger - #endif - |> ElmishStore.createStore "main" +// One additional step (comparing with the previous module-based API). +// However, creating the Host on the global level is essentially the same as using the module-based API. +let storesHost = ElmishStoresHost() +let store = + let program = + Program.mkProgram init update (fun _ _ -> ()) + #if DEBUG + |> Program.withConsoleTrace + |> Program.withDebugger + #endif + storesHost.create "main" program + +(*// Previous API: [] let useSelector (selector: Model -> 'a) = React.useElmishStore (store, selector) [] let useSelectorMemoized (memoizedSelector: Model -> 'a) = - React.useElmishStoreMemoized (store, memoizedSelector) + React.useElmishStoreMemoized (store, memoizedSelector) +*) + +// Instead of defining separate custom hooks for a specific Store, +// just create all the Store-related hooks packed in an object. +let storeApi = StoreApi.getElmishStoreApi store + +// These helpers should be inline so that Fable doesn't generate unnecessary extra-functions around the hooks. +// However, these helpers aren't really needed, as the hooks could be called directly from the `storeApi` value. +let inline useSelector selector = storeApi.useSelector selector +let inline useSelectorMemoized selector = storeApi.useSelectorMemoized selector let dispatch = store.Dispatch \ No newline at end of file diff --git a/src/ElmishStore/ElmishStore.fs b/src/ElmishStore/ElmishStore.fs index 2abd430..3000cdc 100644 --- a/src/ElmishStore/ElmishStore.fs +++ b/src/ElmishStore/ElmishStore.fs @@ -6,134 +6,277 @@ open Fable.Core open ElmishStore open System.Collections.Generic -type ElmishStore<'model, 'msg> = { - GetModel: unit -> 'model - Dispatch: 'msg -> unit - Subscribe: UseSyncExternalStoreSubscribe +type ElmishStore<'Model, 'Msg> = { + GetModel: unit -> 'Model + Dispatch: 'Msg -> unit + Subscribe: UseSyncExternalStoreSubscribe } -type private StoreState<'arg, 'model, 'msg> = { - Store: ElmishStore<'model, 'msg> - SetTermination: bool -> unit +type private StoreState<'Arg, 'Model, 'Msg> = { + Store: ElmishStore<'Model, 'Msg> + SetTermination: bool -> unit } -module ElmishStore = +type internal StoreKey<'T when 'T : equality> = 'T - let mutable private stores: Dictionary = Dictionary() +type IElmishProgramBasedStoreInitializer<'StoreKey when StoreKey<'StoreKey>> = + abstract CreateStore : + program: Program * + storeKey: 'StoreKey -> + ElmishStore<'Model, 'Msg> + abstract CreateStoreWithArg : + program: Program<'Arg, 'Model, 'Msg, unit> * + storeKey: 'StoreKey * + arg: 'Arg -> + ElmishStore<'Model, 'Msg> - let private initiate - uniqueName - (arg: 'arg) - (program: Program<'arg, 'model, 'msg, unit>) - (getState: unit -> 'model option) - = +type IElmishFunctionsBasedStoreInitializer<'StoreKey when StoreKey<'StoreKey>> = + abstract CreateStoreWithFunctions : + init: (unit -> 'Model * Cmd<'Msg>) * + update: ('Msg -> 'Model -> 'Model * Cmd<'Msg>) * + storeKey: 'StoreKey -> + ElmishStore<'Model, 'Msg> + abstract CreateStoreWithFunctionsAndArg : + init: ('Arg -> 'Model * Cmd<'Msg>) * + update: ('Msg -> 'Model -> 'Model * Cmd<'Msg>) * + storeKey: 'StoreKey * + arg: 'Arg -> + ElmishStore<'Model, 'Msg> - let mutable state = getState () - let mutable finalDispatch = None - let mutable shouldTerminate = false +type IElmishStoreInitializer<'StoreKey when StoreKey<'StoreKey>> = + inherit IElmishFunctionsBasedStoreInitializer<'StoreKey> + inherit IElmishProgramBasedStoreInitializer<'StoreKey> - let setTermination should = shouldTerminate <- should +type IElmishStoreAccessor<'StoreKey when StoreKey<'StoreKey>> = + abstract GetStore<'Model, 'Msg when StoreKey<'StoreKey>> : + storeKey: 'StoreKey -> ElmishStore<'Model, 'Msg> + abstract TryGetStore<'Model, 'Msg when StoreKey<'StoreKey>> : + storeKey: 'StoreKey -> ElmishStore<'Model, 'Msg> option - let dispatch msg = - match finalDispatch with - | Some finalDispatch -> finalDispatch msg - | None -> failwith "You're using initial dispatch. That shouldn't happen." +type IElmishStoresHost<'StoreKey when StoreKey<'StoreKey>> = + inherit IElmishStoreInitializer<'StoreKey> + inherit IElmishStoreAccessor<'StoreKey> - let subscribers = ResizeArray unit>() - let subscribe callback = - subscribers.Add(callback) - fun () -> subscribers.Remove(callback) |> ignore +type ElmishStoresHost<'StoreKey when StoreKey<'StoreKey>>() = - let mapSetState setState model dispatch = - setState model dispatch - let oldModel = state - state <- Some model - finalDispatch <- Some dispatch - // Skip re-renders if model hasn't changed - if not (obj.ReferenceEquals(model, oldModel)) then - subscribers |> Seq.iter (fun callback -> callback ()) + let mutable stores: Dictionary<'StoreKey, obj> = Dictionary<'StoreKey, obj>() - let mapInit userInit arg = - if state.IsSome then state.Value, Cmd.none else userInit arg + let initiate + storeKey + (arg: 'Arg) + (program: Program<'Arg, 'Model, 'Msg, unit>) + (getState: unit -> 'Model option) + = + let mutable state = getState () + let mutable finalDispatch = None + let mutable shouldTerminate = false - let mapTermination (predicate, terminate) = - let pred msg = predicate msg || shouldTerminate - pred, terminate + let setTermination should = shouldTerminate <- should - program - |> Program.map mapInit id id mapSetState id mapTermination - |> Program.runWith arg + let dispatch msg = + match finalDispatch with + | Some finalDispatch -> finalDispatch msg + | None -> failwith "You're using initial dispatch. That shouldn't happen." - let getState () = - match state with - | Some state -> state - | None -> failwith "State is not initialized. That shouldn't happen." + let subscribers = ResizeArray unit>() - let store = { - GetModel = getState - Dispatch = dispatch - Subscribe = UseSyncExternalStoreSubscribe subscribe - } + let subscribe callback = + subscribers.Add(callback) + fun () -> subscribers.Remove(callback) |> ignore - let storeState = { - Store = store - SetTermination = setTermination - } + let mapSetState setState model dispatch = + setState model dispatch + let oldModel = state + state <- Some model + finalDispatch <- Some dispatch + // Skip re-renders if model hasn't changed + if not (obj.ReferenceEquals(model, oldModel)) then + subscribers |> Seq.iter (fun callback -> callback ()) - stores[uniqueName] <- box storeState - store + let mapInit userInit arg = + if state.IsSome then state.Value, Cmd.none else userInit arg - let createStoreWith uniqueName (arg: 'arg) (program: Program<'arg, 'model, 'msg, unit>) = + let mapTermination (predicate, terminate) = + let pred msg = predicate msg || shouldTerminate + pred, terminate - let getState = - if stores.ContainsKey(uniqueName) then - let storeState = stores[uniqueName] |> unbox> - storeState.SetTermination true - (fun () -> Some(storeState.Store.GetModel())) - else - (fun () -> None) + program + |> Program.map mapInit id id mapSetState id mapTermination + |> Program.runWith arg - initiate uniqueName arg program getState + let getState () = + match state with + | Some state -> state + | None -> failwith "State is not initialized. That shouldn't happen." + + let store = { + GetModel = getState + Dispatch = dispatch + Subscribe = UseSyncExternalStoreSubscribe subscribe + } + + let storeState = { + Store = store + SetTermination = setTermination + } + + stores[storeKey] <- box storeState + store + + let createStoreWith storeKey (arg: 'Arg) (program: Program<'Arg, 'Model, 'Msg, unit>) = + + let getState = + if stores.ContainsKey(storeKey) then + let storeState = stores[storeKey] |> unbox> + storeState.SetTermination true + (fun () -> Some(storeState.Store.GetModel())) + else + (fun () -> None) + + initiate storeKey arg program getState + + let createStore storeKey program : ElmishStore<'Model, 'Msg> = + createStoreWith storeKey () program + + interface IElmishStoresHost<'StoreKey> with + + member this.CreateStore(program: Program, storeKey: StoreKey<'StoreKey>) = + createStore storeKey program + + member this.CreateStoreWithArg(program: Program<'Arg, 'Model, 'Msg, unit>, storeKey, arg) = + createStoreWith storeKey arg program + + member this.CreateStoreWithFunctions( + init: unit -> 'Model * Cmd<'Msg>, + update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, + storeKey + ) = + let program = Program.mkProgram init update (fun _ _ -> ()) + (this :> IElmishStoresHost<'StoreKey>).CreateStore(program, storeKey) + + member this.CreateStoreWithFunctionsAndArg( + init: 'Arg -> 'Model * Cmd<'Msg>, + update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, + storeKey, + arg: 'Arg + ) = + let program = Program.mkProgram init update (fun _ _ -> ()) + (this :> IElmishStoresHost<'StoreKey>).CreateStoreWithArg(program, storeKey, arg) + + member this.GetStore<'Model, 'Msg>(storeKey) = + try + let storeState = + stores[storeKey] |> unbox> + storeState.Store + with + | :? KeyNotFoundException as e -> + failwithf + $"Elmish Store with key %A{storeKey} hasn't been found. Store has to be created before it can be accessed." + + member this.TryGetStore<'Model, 'Msg> storeKey = + match stores.TryGetValue storeKey with + | true, s -> + let storeState = unbox> s + Some storeState.Store + | false, _ -> None + + // The following functions have to be inlined because JS doesn't support methods overloading. + + member inline this.CreateStore(program: Program, storeKey) = + (this :> IElmishStoresHost<'StoreKey>).CreateStore(program, storeKey) + + member inline this.CreateStore(program: Program<'Arg, 'Model, 'Msg, unit>, storeKey, arg) = + (this :> IElmishStoresHost<'StoreKey>).CreateStoreWithArg(program, storeKey, arg) + + member inline this.CreateStore( + init: unit -> 'Model * Cmd<'Msg>, + update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, + storeKey + ) = + (this :> IElmishStoresHost<'StoreKey>).CreateStoreWithFunctions(init, update, storeKey) + + member inline this.CreateStore( + init: 'Arg -> 'Model * Cmd<'Msg>, + update: 'Msg -> 'Model -> 'Model * Cmd<'Msg>, + storeKey, + arg: 'Arg + ) = + let program = Program.mkProgram init update (fun _ _ -> ()) + (this :> IElmishStoresHost<'StoreKey>).CreateStoreWithArg(program, storeKey, arg) + + member inline this.GetStore storeKey = + (this :> IElmishStoreAccessor<'StoreKey>).GetStore storeKey + + member inline this.TryGetStore storeKey = + (this :> IElmishStoreAccessor<'StoreKey>).TryGetStore storeKey - let inline createStore uniqueName program : ElmishStore<'model, 'msg> = - createStoreWith uniqueName () program [] type ElmishStore = - static member Create - ( - program: Program<'arg, 'model, 'msg, unit>, - arg: 'arg, - uniqueName - ) : ElmishStore<'model, 'msg> = - ElmishStore.createStoreWith uniqueName arg program - - static member inline Create(program: Program, uniqueName) = - ElmishStore.Create(program, (), uniqueName) - - static member inline Create - ( - init: 'arg -> 'model * Cmd<'msg>, - update: 'msg -> 'model -> 'model * Cmd<'msg>, - arg: 'arg, - uniqueName - ) = - ElmishStore.Create((Program.mkProgram init update (fun _ _ -> ())), arg, uniqueName) - - static member inline Create - ( - init: unit -> 'model * Cmd<'msg>, - update: 'msg -> 'model -> 'model * Cmd<'msg>, - uniqueName - ) = - ElmishStore.Create(Program.mkProgram init update (fun _ _ -> ()), uniqueName) - - static member inline Create - ( - init: 'model * Cmd<'msg>, - update: 'msg -> 'model -> 'model * Cmd<'msg>, - uniqueName - ) = - ElmishStore.Create(Program.mkProgram (fun () -> init) update (fun _ _ -> ()), uniqueName) + static member inline create + (host: IElmishStoresHost<'StoreKey>) + storeKey + (program: Program) + = + host.CreateStore(program, storeKey) + + static member inline createWithArg + (host: IElmishStoresHost<'StoreKey>) + storeKey + arg + (program: Program<'Arg, 'Model, 'Msg, unit>) + = + host.CreateStoreWithArg(program, storeKey, arg) + + static member inline createWithFunctions + (host: IElmishStoresHost<'StoreKey>) + storeKey + (init: unit -> 'Model * Cmd<'Msg>) + update + = + host.CreateStoreWithFunctions(init, update, storeKey) + + static member inline createWithFunctionsAndArg + (host: IElmishStoresHost<'StoreKey>) + storeKey + arg + (init: 'Arg -> 'Model * Cmd<'Msg>) + update + = + host.CreateStoreWithFunctionsAndArg(init, update, storeKey, arg) + + +[] +module Extensions = + + type IElmishStoresHost<'StoreKey when StoreKey<'StoreKey>> with + + member inline this.create + storeKey + (program: Program) + = + this.CreateStore(program, storeKey) + + member inline this.createWithArg + storeKey + arg + (program: Program<'Arg, 'Model, 'Msg, unit>) + = + this.CreateStoreWithArg(program, storeKey, arg) + + member inline this.createWithFunctions + storeKey + (init: unit -> 'Model * Cmd<'Msg>) + update + = + this.CreateStoreWithFunctions(init, update, storeKey) + + member inline this.createWithFunctionsAndArg + storeKey + arg + (init: 'Arg -> 'Model * Cmd<'Msg>) + update + = + this.CreateStoreWithFunctionsAndArg(init, update, storeKey, arg) diff --git a/src/ElmishStore/ElmishStore.fsproj b/src/ElmishStore/ElmishStore.fsproj index 19c0958..10a0539 100644 --- a/src/ElmishStore/ElmishStore.fsproj +++ b/src/ElmishStore/ElmishStore.fsproj @@ -1,35 +1,42 @@ - + - - Elmish.Store - A library that merges Elmish and React, providing an external store with efficient, selective component rendering capabilities. - fsharp;fable;react;elmish - Łukasz Krzywizna - SelectView Data Solutions - 0.1.0 - net8.0 - readme.md - https://github.com/SelectViewData/elmish-store - git - MIT - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + Elmish.Store + A library that merges Elmish and React, providing an external store with efficient, selective component rendering capabilities. + fsharp;fable;react;elmish + Łukasz Krzywizna + SelectView Data Solutions + 0.3.0-beta.1 + net6.0 + readme.md + https://github.com/SelectViewData/elmish-store + git + MIT + + + fable-javascript + library + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ElmishStore/Extensions.fs b/src/ElmishStore/Extensions.fs new file mode 100644 index 0000000..e24c5b5 --- /dev/null +++ b/src/ElmishStore/Extensions.fs @@ -0,0 +1,23 @@ +namespace ElmishStore + +open Fable.Core +open Feliz +open ElmishStore + +[] +type React = + + /// Provides a current snapshot of the store's state selected by the selector function. + /// NOTE: Selector returning value needs to be referentially stable. + static member inline useElmishStore(store, selector: 'model -> 'a) = + Hooks.useElmishStore store selector + + /// Provides a current snapshot of the store's state selected by the selector function. + /// The result of the selector function is memoized and compared with isEqual function. + static member inline useElmishStoreMemoized(store, selector: 'model -> 'a, isEqual) = + Hooks.useElmishStoreMemoizedWithCustomEquality store selector isEqual + + /// Provides a current snapshot of the store's state selected by the selector function. + /// The result of the selector function is memoized and compared with structural equality. + static member inline useElmishStoreMemoized(store, selector: 'model -> 'a) = + Hooks.useElmishStoreMemoized store selector \ No newline at end of file diff --git a/src/ElmishStore/Hooks.fs b/src/ElmishStore/Hooks.fs index f904da6..fa4aab5 100644 --- a/src/ElmishStore/Hooks.fs +++ b/src/ElmishStore/Hooks.fs @@ -1,49 +1,47 @@ -namespace ElmishStore +module ElmishStore.Hooks open Fable.Core open Feliz open ElmishStore -[] -type React = - /// Provides a current snapshot of the store's state selected by the selector function. - /// NOTE: Selector returning value needs to be referentially stable. - [] - static member useElmishStore(store, selector: 'model -> 'a) = +/// Provides a current snapshot of the store's state selected by the selector function. +/// NOTE: Selector returning value needs to be referentially stable. +[] +let useElmishStore store (selector: 'model -> 'a) = ReactBindings.useSyncExternalStore ( - store.Subscribe, - React.useCallback ( - (fun () -> store.GetModel() |> selector), - [| box store; box selector |] - ) + store.Subscribe, + React.useCallback ( + (fun () -> store.GetModel() |> selector), + [| box store; box selector |] + ) ) - /// Provides a current snapshot of the store's state selected by the selector function. - /// The result of the selector function is memoized and compared with isEqual function. - [] - static member useElmishStoreMemoized(store, selector: 'model -> 'a, isEqual) = +/// Provides a current snapshot of the store's state selected by the selector function. +/// The result of the selector function is memoized and compared with isEqual function. +[] +let useElmishStoreMemoizedWithCustomEquality store (selector: 'model -> 'a) isEqual = ReactBindings.useSyncExternalStoreWithSelector ( - store.Subscribe, - React.useCallback( - (fun () -> store.GetModel()), - [| box store; box selector |] - ), - selector, - isEqual + store.Subscribe, + React.useCallback( + (fun () -> store.GetModel()), + [| box store; box selector |] + ), + selector, + isEqual ) - /// Provides a current snapshot of the store's state selected by the selector function. - /// The result of the selector function is memoized and compared with structural equality. - [] - static member useElmishStoreMemoized(store, selector: 'model -> 'a) = +/// Provides a current snapshot of the store's state selected by the selector function. +/// The result of the selector function is memoized and compared with structural equality. +[] +let useElmishStoreMemoized store (selector: 'model -> 'a) = ReactBindings.useSyncExternalStoreWithSelector ( - store.Subscribe, - React.useCallback( - (fun () -> store.GetModel()), - [| box store; box selector |] - ), - selector, - (=) + store.Subscribe, + React.useCallback( + (fun () -> store.GetModel()), + [| box store; box selector |] + ), + selector, + (=) ) diff --git a/src/ElmishStore/StoreApi.fs b/src/ElmishStore/StoreApi.fs new file mode 100644 index 0000000..ac64c96 --- /dev/null +++ b/src/ElmishStore/StoreApi.fs @@ -0,0 +1,44 @@ +module ElmishStore.StoreApi + +open Feliz + +type IElmishStoreHooks<'Model> = + /// Provides a current snapshot of the store's state selected by the selector function. + /// NOTE: Selector returning value needs to be referentially stable. + abstract useSelector : selector: ('Model -> 'T) -> 'T + /// Provides a current snapshot of the store's state selected by the selector function. + /// The result of the selector function is memoized and compared with structural equality. + abstract useSelectorMemoized<'T when 'T : equality> : selector: ('Model -> 'T) -> 'T + /// Provides a current snapshot of the store's state selected by the selector function. + /// The result of the selector function is memoized and compared with isEqual function. + abstract useSelectorMemoizedWithCustomEquality : selector: ('Model -> 'T) -> isEqual: ('T -> 'T -> bool) -> 'T + +type IElmishStoreApi<'Model, 'Msg> = + inherit IElmishStoreHooks<'Model> + + abstract Store : ElmishStore<'Model, 'Msg> + + +let getElmishStoreApi (store: ElmishStore<'Model, 'Msg>) = + { new IElmishStoreApi<'Model, 'Msg> with + member _.Store = store + + member _.useSelector(selector: 'Model -> 'T): 'T = + Hooks.useElmishStore store selector + + member _.useSelectorMemoized<'T when 'T : equality> (selector: 'Model -> 'T) = + Hooks.useElmishStoreMemoized store selector + + member _.useSelectorMemoizedWithCustomEquality<'T> + (selector: 'Model -> 'T) + (isEqual: 'T -> 'T -> bool): 'T + = + Hooks.useElmishStoreMemoizedWithCustomEquality store selector isEqual + } + + +[] +let useElmishStoreApi (store: ElmishStore<'Model, 'Msg>) = + React.useMemo (fun () -> + getElmishStoreApi store + , [| store |]) diff --git a/src/ElmishStore/UseSyncExternalStore.fs b/src/ElmishStore/UseSyncExternalStore.fs index 2322eec..d375758 100644 --- a/src/ElmishStore/UseSyncExternalStore.fs +++ b/src/ElmishStore/UseSyncExternalStore.fs @@ -27,7 +27,6 @@ type internal ReactBindings = ) : 'a = jsNative - [] static member inline useSyncExternalStoreWithSelector ( subscribe: UseSyncExternalStoreSubscribe,