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

Improve code documentation for MVU/Elm newbies #1067

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

h0lg
Copy link

@h0lg h0lg commented Feb 11, 2024

I'm currently wrapping my head around Elm, MVU and Fabulous, had a look at the changes in pre-release 2.5 #1065 6de97c0 and tried to update the code documentation in a helpful way by connecting types to Elm or MVU concepts. This is an attempt to make Fabulous a bit more approachable for newbies like me who may or may not have done their homework learning about MVU and Elm.

Please review my changes carefully to make sure I'm not confusing things and correct me if I did!
You'll find some questions concerning opaque concepts like CmdMsg vs. Cmd<'msg> as well, which I mentioned in #927 .

@h0lg h0lg marked this pull request as draft February 11, 2024 19:32
@h0lg h0lg force-pushed the improve-doco branch 2 times, most recently from 08d8400 to 29aa1f5 Compare March 2, 2024 11:03
src/Fabulous/Cmd.fs Outdated Show resolved Hide resolved
src/Fabulous/Cmd.fs Outdated Show resolved Hide resolved
src/Fabulous/Cmd.fs Outdated Show resolved Hide resolved
@@ -3,12 +3,19 @@ namespace Fabulous
open System
open System.Diagnostics

(*TODO Is either of these a program in the Elm sense? If so, where's the view in this one?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally we had a single Program type containing the view function like in typical Elm. In Fabulous 3, I had to split the Program type in 2 types to enable support of MVU components because the view function is defined later.

let init = ...
let update = ...
let view = ...

let program =
    Program.stateful init update // Program<'arg, 'model, 'msg>
    |> Program.withView view // Program<'arg, 'model, 'msg, 'view>
let init = ...
let update = ...
let program = Program.stateful init update

let view =
    Component(program) {
        // the actual view function here
    }

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would be better to rename the two Program types to avoid the confusion?

Or are these rather abstractions of or pre-cursors to an Elm Program?
AFAIU in the Elm architecture a "program" manages the application's (or component's) state, actions, and view rendering.
Please help me as a MVU/Elm newbie understand these types. *)
//TODO what's the 'arg?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'arg is a value you can pass to initialize a program. This 'arg will be received by the init function.

module App =
    let init (isAdmin: bool) =
        { Model.Name = if isAdmin then "Admin" else "User" }
    
    let program = 
        Program.stateful init update
        |> Program.withView view

type MauiProgram =
    static member CreateMauiApp() =
        let isAdmin = true

        MauiApp
            .CreateBuilder()
            .UseFabulousApp(App.program, isAdmin)

@@ -19,10 +26,11 @@ type Program<'arg, 'model, 'msg> =
ExceptionHandler: exn -> bool
}

//TODO how is this different to the above? What's a 'marker? what's the 'arg?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'marker here is the type of the view. This is used by Fabulous.MauiControls and Fabulous.Avalonia to strongly-type the view being returned.

For example, a ListView only accepts "cells" (anything inheriting from Cell type like TextCell, ImageCell and so on). You don't want a page in a ListView. In Fabulous, this is represented by the marker interfaces: IFabTextCell, IFabImageCell which both inherit from IFabCell. IFabPage doesn't inherit from IFabCell making it impossible to be used inside a ListView.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'marker in Program type is mainly used by components to signal that this component will return that specific view type and can only be used in a specific way.

We also use this 'marker type when running an application. UseFabulousApp will require 'marker to be IFabApplication because Maui/Avalonia requires an Application type and not some random Label for example.

@@ -58,23 +72,44 @@ module Program =
Logger = ProgramDefaults.defaultLogger()
ExceptionHandler = ProgramDefaults.defaultExceptionHandler }

//TODO when would I use this one? How does it compare to the other stateful* builders?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stateful is the simplest type of program where a state is used. You send a message, the state is updated.

statefulWithCmd adds the concept of side effects on top of the state via Cmds

statefulWithCmdMsg is the same than statefulWithCmd except it adds a layer of discriminated union to be easier to unit test. You can read more about it on https://zaid-ajaj.github.io/the-elmish-book/#/chapters/scaling/intent (Intent is just a different naming for the same concept)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For 'arg, please see explanation above

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimLariviere I've read about Intent and understand that it's a pattern used in component programs for cleanly separating messages to the component from messages to the parent program. It's used to avoid leaking the decision into the parent program about which component messages to intercept, inspect or forward. The parent only needs to worry about handling the intents and can blindly pass component messages on to the component's update function.

The diff of fabulous-dev/Fabulous.Avalonia#224, which switches the examples between between using statefulWithCmdMsg and statefulWithCmd, looks like what I've tried to describe here.

I'm failing to see the connection between Intent and CmdMsg - other than there's command mapping and batching involved. Can you help me connect the dots?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h0lg True, sorry about the confusion. Intent is indeed another concept we called ExternalMsg in Fabulous to enable child -> parent communication. Not the same thing at all than CmdMsg.

Your understanding of CmdMsg is correct.

When side-effects are required, init and update can return a Cmd<'msg> along the updated Model. This Cmd<'msg> is essentially a list of functions to be executed by Fabulous as side-effects, which might dispatch new messages.

The nice thing with MVU is that it's very easy to unit test since the init and update functions are pure and will always return an updated state, so you can check that a specific message with a specific state will return the expected updated state.

But unit testing the returned Cmd<'msg> is extremely difficult as you would need to execute it yourself. What if that Cmd<'msg> is making a network call or database update?

It's easier to separate the "intention" (not Intent, that's where I got confused earlier) of side-effects with the actual implementation as it can just check against the "intention" (the CmdMsg) in the unit tests.

type Msg = | Clicked

let doNetworkCallCmd () =
    Cmd.OfAsync...

let update msg model =
    match msg with
    | Clicked -> { model with Clicked = true }, doNetworkCallCmd()

let ``When user clicks the button, do the network call``() =
    let initialState = { Clicked = false }
    let expectedState = { Clicked = true }

    let actualState, actualCmd = update Clicked initialState

    Assert.Equals(actualState, expectedState)
    // TODO: How do you test that "actualCmd" is doing the right thing without actually doing the network call?
type Msg = | Clicked

type CmdMsg = | DoNetworkCall

let doNetworkCallCmd () =
    Cmd.OfAsync...

let mapCmdMsgToMsg cmdMsg =
    match cmdMsg with
    | DoNetworkCall -> doNetworkCallCmd()

let update msg model =
    match msg with
    | Clicked -> { model with Clicked = true }, [ DoNetworkCall ]

let ``When user clicks the button, do the network call``() =
    let initialState = { Clicked = false }
    let expectedState = { Clicked = true }
    let expectedCmdMsg = DoNetworkCall

    let actualState, actualCmdMsg = update Clicked initialState

    Assert.Equals(actualState, expectedState)
    Assert.Equals(actualCmdMsg, expectedCmdMsg) // Way easier to unit test

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimLariviere I think I understand now - and thank you for helping me!

statefulWithCmdMsg allows you to refactor side-effects out of your update function, making it pure and easy to unit-test without executing the side-effects.

You do so by defining an extra discriminated union for messages that trigger side-effects (in your example called CmdMsg) and then return those messages from your update function instead of executing the side effects in place.

An additional function (in your example called mapCmdMsgToMsg) maps each of those messages back to the side-effect.

If I understand correctly, I suggest that besides updating the code doco (which I'll try), your example would be a good addition to the doco as a topic called Unit testing the update function or something similar.

To

  • make this concept more accessible to newbies by getting closer to known ELM/MVU concepts,
  • avoid confusion between CmdMsg, Cmd<'msg> and ExternalMsg and
  • capture the variant of Program this creates,

I suggest the following renaming:

CmdMsg -> SideEffect (in examples - of course we can call it whatever we like)
mapCmdMsgToMsg -> mapSideEffectToMsg or simply mapSideEffect
statefulWithCmdMsg -> statefulWithSideEffects or statefulWithIsolatedSideEffects

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimLariviere I have updated the doco on the stateful* builders according to my new found understanding - thanks again for clarifying!

let statefulWithCmdMsg (init: 'arg -> 'model * 'cmdMsg list) (update: 'msg -> 'model -> 'model * 'cmdMsg list) (mapCmd: 'cmdMsg -> Cmd<'msg>) =
let mapCmds cmdMsgs = cmdMsgs |> List.map mapCmd |> Cmd.batch
define (fun arg -> let m, c = init arg in m, mapCmds c) (fun msg model -> let m, c = update msg model in m, mapCmds c)

(*TODO Subscriptions will be started or stopped automatically to match.
- I don't understand what that means. What (other?) Subscriptions - or - to match what?*)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are missing a word here. I guess it should be "... to match the lifecycle of the program".
Subscriptions are now disposed when the program exits, for example when a component is removed from the UI tree.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the doco changing the wording.

let withSubscription (subscribe: 'model -> Sub<'msg>) (program: Program<'arg, 'model, 'msg>) = { program with Subscribe = subscribe }

//TODO In what scenario would I want to use this?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just like Cmd.map, mapSubscription converts child subscriptions to dispatch a parent type.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimLariviere Isn't that what Sub.map is for?

@h0lg h0lg force-pushed the improve-doco branch 2 times, most recently from 8e82001 to 5f03004 Compare May 1, 2024 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants