-
Notifications
You must be signed in to change notification settings - Fork 1
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
Proposal: Mailbox Revamp #7
Comments
Yeah this just looks flat-out incredible. I really hope I'm not missing something, because I've read this a couple times and just love it. 😻 |
I believe this is a more unwrapped version of the definition of So as always, you gotta show code examples!!!!!!!! So here is the basic change to the Elm Architecture pattern: -- current way
view : Address Action -> Model -> Html
-- proposed way
view : (Action -> Message) -> Model -> Html Is that simpler? Maybe experienced users would want to wrap things up as an With the effect routing stuff, you get this: update : (Action -> Message) -> Action -> Model -> (Model, Effects) I'll play with this idea as I continue exploring with the-social-network. Anyway, seems like it has potential, nice proposal! |
Some Dreamwriter code cleaned up and rewritten in this style: (before), (after)
Even as an advanced user who knows what |
Thanks guys. Maybe it's a matter of style, but I typically don't parametrize my update or view over the address/dispatcher. I'd just have the update : Action -> Model -> Model
view : Model -> Html -- or Element
reaction : {- Action -> -} Model -> Effects If you choose to parameterize, that's not a big problem. The type signature for But let's look at the call site. In my Socket.io chat app, written before this proposal, I unwrapp the address immediately: stateMB : Signal.Mailbox (State -> State)
stateMB = Signal.mailbox identity
sendState : (State -> State) -> Signal.Message
sendState = Signal.message stateMB.address Notice that submitMB = Signal.mailbox ()
submitButton = Graphics.Input.button (Signal.message submitMB.address ()) "Submit" With the proposal in place, the parenthesized expression becomes Let's look at a standard text field: --reminder:
Field.field : Style -> (Content -> Message) -> String -> Content -> Element
type alias State = {content : Field.Content, submit : String}
field : Signal Element
field =
let base = Field.field Field.defaultStyle
(\fc -> Signal.message stateMB.address (State fc "")) ""
in Signal.map (.content>>base) stateMB.signal The second argument to (\fc -> stateMB.dispatch (State fc "")) If (stateMB.dispatch>>State) For checkboxes, one might have type alias ParamID = Int
type Action = Checked ParamID Bool | NoOp | ...
actions = mailbox NoOp
-- later
myCheckbox2 = Input.checkbox (actions.dispatch>>(Checked 2)) That is, by carefully choosing the order of arguments to tags, and by treating tags as curried functions, you can make it really pretty. (And I don't think that's a particularly advanced language feature; it's similar to record constructors.) |
👍 LGTM |
One more code example, this time from the SocketIO library itself: connected : Address Bool -> Socket -> Task x ()
connected : (Bool -> Message) -> Socket -> Task x () I think it's clearer from the second signature that the function will obtain a boolean using the socket, and then send it as part of the task. |
I don't really have a strong opinion on this one because I got really used to the ideas of addresses and I like the concision in the type annotation it offers. When it comes to container components (whose type annotations can get super hairy at times), the change would look as follows: view : (ChildContext -> (childAction -> Message) -> childState -> Html)
-> Context -> (Action childAction -> Message) -> State childState -> Html as opposed to view : (ChildContext -> Address childAction -> childState -> Html)
-> Context -> Address (Action childAction) -> State childState -> Html The really weird part about this is that, in the proposed version, view takes a function which itself takes a function. This could very easily get weird from an API design perspective. (by the way, I have way uglier type signatures for container components if you're interested). |
You could always add you own type alias. Better still might be to rework things. I would partially apply view's first argument, rearranging its arguments if necessary, and use function composition so it already has the dispatcher in its closure. So you might have render : ChildContext -> (childAction -> Message) -> childState -> Html
view : (childState -> Html) -> Context -> State childState -> Html Where |
Ok, fair enough. But then how do you deal with actions that are not necessarily destined to the children? Consider the following example type alias State childState =
{ children : List childState
, ... -- some bells and whistles here
}
type Action childAction
= ChildAction Int childAction
| Resize (Int, Int)
| ... -- other container specific actions
update : (childAction -> childState -> childState)
-> Action childAction -> State childState -> State childState
view : (ChildContext -> Address childAction -> childState -> Html)
-> Context -> Address (Action childAction) -> State childState -> Html What I'm trying understand is how do you get both the children and the parent to send events (of type |
Whenever you used to do newAddress = Signal.forwardTo f mailbox.address you can now do newDispatch = f>>mailbox.dispatch For example - the makeChild : childState -> (childAction -> Message) -> Child
actions : Signal.Mailbox (Action childAction)
actions = Signal.mailbox NoOp
myChild = makeChild someState ((ChildAction 4)>>actions.dispatch) This means that the children don't track their own identifiers; they are instead placed into the closure of the child's dispatch function by the parent. |
Thanks for this! So much more clarity than the original thread. Part of this conversation would be great feedback on Evan's new component architecture with effects. I'm especially curious about @mgold's dispatcher-less function signatures. I'm planning to fork and and experiment with doing that in |
Go for it! Implementation shouldn't be too hard, we're basically exposing an opaque type. |
Do you have examples of your dispatcher-less signatures somewhere? Does it okay nice with nested components? |
Mmmm... i see. So, for example, one way I could do things for the children, is, when I do the let
viewN index child = -- the view function that gets called on the children
let
childDispatch =
ChildAction index >> mailbox.dispatch -- continue to use the index as an identifier
...
in
...
in
...
( List.indexedMap viewN state.children ) -- somewhere we call List.indexedMap Is that correct? |
Hopefully on topic: I don't understand |
Hassan: Yes, that looks like it would work. Texas: A message is not an arbitrary effect but an effect that will, when run, produce a value on a signal. Which value is determined by the argument to |
I have implemented the change in a branch here. You should be able to drop in the new Elm file (and delete the cache) and play around with this. Still open to suggestions on names and docs; discuss here or send a PR. |
I think you can just do your proposal as a library on top of the existing stuff. No need to modify core. |
I understand his proposal is to modify Core? Motivated by questions about unfamiliarity of |
Yes, the proposal is to modify core... in the thread you started you can see that the confusion that Address and Mailbox have caused. Richard has attested that he's walked through it with newcomers and they've found it confusing. This API makes things more transparent, and drops two functions, one of which is one of the least intuitive currently in core ( That being said, as a prototype it may work better as a separate module than as a monekypatch. However, I was also seeking feedback on the docs. The current docs are pretty unhelpful and the examples are just wrong in a few places ( |
I updated my Socket.io chat client to use dispatchers defined in a separate file. I renamed the module to Signal on import, to better simulate what a change to core would look like. I stopped short of changing the socket.io code itself; this was all Graphics.Input. Turns out it's not possible to do as a 3rd party library because I can't implement |
So what are we blocking on here? Examples? Not time yet? A PR? Something else? |
Time and additional exploration. I am doing some work that led to an alternate way to achieve the same goals. I am not ready to share that work yet, but it needs to be finished before we can make decisions here. |
Okay, please ping back when you're ready. |
@mgold said "Turns out it's not possible to do as a 3rd party library because I can't implement send : Message -> Task x () using the current implementation which takes an address." I think I must have been unconsciously remembering this when I submitted this pull request: https://github.com/elm-lang/core/pull/356 ... which would create the function:
Now, the new function I am proposing there would become irrelevant if the more profound proposal here is implemented in core. However, I think that accepting my pull request would make it possible to implement this proposal as a 3rd party library. So, it might be a useful transitional step. Since the new function in that pull request would be a smaller change to core than the proposal here. |
@mgold said: "A message is not an arbitrary effect but an effect that will, when run, produce a value on a signal. Which value is determined by the argument to dispatch (or Signal.message); the signal is the one associated with the dispatch function (or address). So I can create a message for a UI button to send whenever it's clicked, and it can send that message multiple times, and the type system knows that it's not allowing a button to do anything more than make an event on a signal. Which explains the task underneath: messages are tasks from an implementation standpoint, but not from a safety standpoint." This is a clear and useful explanation, but for me it raises other questions. If clicking a button sends an http request directly, then you can locally see what the button will do without reading any other part of the program, and you know the button isn't going to do anything else. If it sends a message to some arbitrary signal, to determine the effect of the button click, I need to figure out which signal it is and find all usages of that signal to figure out what effects it might trigger. If it's harder to determine the effects, is it really a safety guarantee? So it seems like sending a message to a signal might be useful as a form of architectural decoupling, but putting an effect object directly on a button might be simpler to understand, particularly for a beginner who is already familiar with event handlers in regular HTML pages. The risk is that, though I don't think it technically violates purity (an effect is a value), it looks suspiciously unlike functional programming. But I'm not really a functional programmer; I just dabble from time to time. Perhaps someone can explain the safety guarantee better? |
It seems like if the goal is to restrict what a view can actually do, view types such as Html shouldn't be able to send arbitrary messages, because it's analogous to declaring a function of return type Object. We could use a parameterized type to narrow it down, perhaps something like: {- An Html view whose event handlers all send values of type a -} onClick: a -> Attribute a div: List Attribute a -> List Html a -> Html a {- Plug the view into a particular signal -} |
@rgrempel Yes, it sounds like adding that feature would allow this proposal to be implemented as a third party library. But that will complicate things further, by adding another function to the standard library. (Notice that this proposal reduces the size of the standard library!) If one wanted to test the proposal before it was accepted, one could do that by either patching core with your PR and writing the 3rd party lib, or patching core directly, as I've done. @skybrian Yes, it's possible to "inline" things somewhat even today, using an address of a function type. I explain this approach in the Transformations section of this document. The problem is that, in a sufficiently complicated program, you want an explicit action type and a centralized step function. What if the button needs to behave differently based on state (e.g. not resend a form submission)? What if you want multiple buttons to do the same thing, which you want to describe only once? What if you want to make a game where keys can be rebound, so you can't assume the same key always means the same action? The problem with your second solution is that it restricts all event handlers to sending values of the same type to the same address. With messages--which include both the value sent and a reference to the mailbox to send it to--you don't have that restriction. Polymorphism is not the answer to every problem. |
@mgold I think these are valid design choices depending on what you want to do. I'm comfortable programming with languages that allow unrestricted effects in a view's event handlers. I also think the traditional Elm architecture (with a single model and action type) is a pretty good way to go. re: "The problem with your second solution is that it restricts all event handlers to sending values of the same type to the same address." Yes, that's a feature. It would be a way of intentionally restricting designs to the traditional Elm architecture. You can still can add whatever alternatives you like to your action type, including a Transformation function as you describe. You could also derive whatever signals you like from the top-level view's signal. But it would prevent triggering an effect when there' s no action declared for it, and ensure that all view-triggered events pass through the main signal. So I think the high-level design question for Elm is what level of safety the language should require for view-constructing functions. It seems like a odd middle ground to have a fairly loose restriction (views can contain any message, but not any task) if it doesn't place any restriction on effects. If it's okay to allow unrestricted effects, why not have all event-sending views accept a task parameter, since it would simplify understanding? On the other hand, if the goal is to improve safety by forbidding any actions that aren't declared in a view-constructing function's type signature, it seems like we need another type constraint to actually accomplish it? Just declaring, "it returns a view, which can trigger any event" adds little safety. |
Aside from not being able fire off arbitrary tasks (directly), a Message also ensures that a UI element will always send the same action value for an event. (The value can be based on the UI's state, like a checkbox, but a button can only send one message.) This makes it much easier to handle these action values, and decide what if any effect to take, in one place. The philosophy is that UI inputs shouldn't be particularly intelligent (which makes them very safe), as anything they report happened has to be judged by the step function, which has access to the current state. Yes, views can send messages to different mailboxes, even though it's usually best to have one action type and send it to one mailbox. I don't think it's worth "intentionally restricting designs to the traditional Elm architecture" as sometimes you need to do something a little odd. Rather, the idea is to make views simple and easy to understand, so when you write the step function, you're working with the abstracted action types rather than button presses. This is getting really far afield from the original proposal, which is a comparably small change from the status quo compared to your proposed |
I don't think I'm explaining myself all that well, but I'll try one more time and then let it drop. If I can choose the mailbox when I create a view, there's no difference in power between embedding a message into the view versus embedding a task into the view, because I can convert the first into the second. For example, see [1]. In a library, I can change mysteryButton1 to mysteryButton2, ship a new version of the library, and the type doesn't change. Anyone who uses the modified mysteryButton in their view is none the wiser; it went from doing nothing to logging clicks under the covers and the caller's step function never saw the event. Even though the logClick task is not technically embedded in the button, from the caller's perspective, the button acts exactly as if it were. So there's no real architectural restriction at all, just some extra steps you have to take when creating the button to make it do a task. If sandboxing views is a non-goal and the status quo is fine (which is a valid choice), It seems like we could stop worrying about illusionary safety and simplify even further? Perhaps ports and mailboxes could be merged somehow? Why couldn't a mailbox execute a task? mysteryButton3 = Html.button [ But that's also a different proposal. |
It's a somewhat contrived example in that |
Aha, the restriction on port creation is the piece I was missing. Thank you for your help, and sorry to be a bother. |
I am closing this repo down. This idea makes sense. I don't want to keep messing with this API again and again and again. Especially when it's the same group of people who are involved in the naming each time. We are on iteration 3 now, and each time it felt like we really had it this time. I think this is something to consider in the "changes to core" issue though. @mgold, can you move the key idea to a gist? It's fine if it's super short. I get the idea. Once you do that, chat me or email me and I'll add it to the thread about potential additions / changes to core. |
Okay, I've made the gist and will email you the link. Thanks to everyone who commented. |
One more "outsider" voice -- yes, please. Anything that consolidates the terminology around task, effect, address, mailbox, port, and message is an improvement. |
This proposal attempts to codify the ideas from this thread. This would be a breaking change to core, but no change to the language itself.
Motivation
As is clear from reading the thread, "mailbox" is has failed as a metaphor. This part of the language is, empirically, one of the hardest for newcomers to understand. A few other issues with the API:
Address
type is completely opaque, and hard to gain intuition about. However, it's really the only thing tying the API together.message
to create aMessage
, andsend
to create aTask
, but neither of these seem to fit very well.forwardTo : Address b -> (a -> b) -> Address a
is really confusing. It took me several tries to get my head around it. The average JS or Ruby dev is not comfortable with the phrase "contravariant functor".Proposed Change
I'm not 100% on the names yet; please focus on the types.
Let's start by what's not in the API. There's no explicit
message
function aside fromdispatch
itself. This encourages people to use messages, which are safer (they can't perform arbitrary actions e.g. Http). AndforwardTo
drops out entirely, replaced by dirt standard function composition.Finally,
send
takes aMessage
. Currently,send
andmessage
are bothAddress a -> a -> ...
, implying parity. Now, it's clearly extra work to use a task. This implies (correctly) that a message is the primary type at play here. The exposed function plays very well with Graphics.Input, every function in which takes either aMessage
or an(a -> Message)
.Next Steps
This proposal had support from both newcomers and regulars on the thread, though you're welcome to oppose here. As mentioned above, I think the biggest remaining hurdle is to find the right names. I think implementation will be fairly straightforward, although it's important to get the docs right.
The text was updated successfully, but these errors were encountered: