Closed Shmew closed 4 years ago
Hi Cody,
I have been thinking about this and I some mixed feelings about it. Here is why:
Cmd
prefix + Router module) | "Some" :: "Begin" :: "Path" :: rest ->
?)Let's shrink this down to two main issues: explicit elmish Cmds and Isolate props for router from router functions.
If we go the breaking change route (no pun intended :wink:) then I propose to use Cmd.navigate
instead of Cmd.Router.navigate
and have Router.navigate
stay here but returns unit
like your initial proposal. Along with this change, we can include the fix for Isolate props for router from router functions
What do you think?
Convert class component to function component (if it ain't broken, don't fix it. The component implementation is already so small it doesn't really matter whether to use function components vs classes)
I was thinking of this more along the lines of maintenance: keeping things more up-to-date. I believe the class component will work with concurrent mode, but this way we do know for sure. One thing is I'm pretty sure that the class component will not properly clean up the event handlers, or if it does work like that it would potentially remove listeners that are outside of this package.
Breaking change
I think a breaking change is worth it in the long run. It makes things easier to use from outside an elmish standpoint, as well as make it more clear what isn't pure. Removing the extra module is easy enough to do. Personally I like the extra name-spacing to avoid any potential conflicts with other libraries, but not enough to die on a hill for it lol.
Add prefix active pattern like what we use in the Feliz docs (isn't this basically | "Some" :: "Begin" :: "Path" :: rest ->?)
... learn something new every day, I thought it was only usable once as head::tail 😅.
If does look like the behavior is a fair bit different in terms of the generated code. I'm not sure how expensive list tail calls are in javascript, performance probably isn't ever going to be an issue with this kind of function. The active pattern does allow you to compose paths or pass in a value rather than explicit list destructuring. In addition it also lets you explicitly have a case for when it doesn't match that exact path. Up to you on this one.
One thing is I'm pretty sure that the class component will not properly clean up the event handlers
The cleanup code will be the same actually whether it is in executed when a hook is disposed or when the component unmounts, I am keeping as is until someone comes up with an issue :)
I think a breaking change is worth it in the long run. It makes things easier to use from outside an elmish standpoint, as well as make it more clear what isn't pure. Removing the extra module is easy enough to do. Personally I like the extra name-spacing to avoid any potential conflicts with other libraries, but not enough to die on a hill for it lol.
It is always a balance, here I am assuming that someone probably won't have collisions on Cmd.navigate
because people tend to use a single library for routing.
learn something new every day, I thought it was only usable once as head::tail 😅 If does look like the behavior is a fair bit different in terms of the generated code. I'm not sure how expensive list tail calls are in javascript, performance probably isn't ever going to be an issue with this kind of function. The active pattern does allow you to compose paths or pass in a value rather than explicit list destructuring. In addition it also lets you explicitly have a case for when it doesn't match that exact path. Up to you on this one.
Yeah I would rather not add patterns that look like something which is already there in the language
I was mostly referring to the fact that since it doesn't provide the function in the removeEventListener
. I can confirm this does not work as intended. This has probably never come up because the router should be at the top level, but it doesn't necessarily have to. With a function component it's very easy to stabilize the event handlers, here's an example where I've done this before. Maybe I should go ahead with creating a custom hook (something like useEventListener
) for this pattern for the community to use.
Another thing I wanted to discuss is #16.
We could use a context to expose the router for React, and pass a Router type for Elmish. The registration of the router would then have an additional required parameter to register the router in the model: Router option -> Msg
, then the commands would require a Router option
. This makes the behavior consistent and much safer, Elmish users then can't shoot themselves in the foot or run into the issue @Dzoukr had. I've used this pattern in a couple libraries such as Fable.SignalR and Feliz.UseWorker, I've found it's successful in preventing illegal states. Additionally we could do the same with the React-only side of things by exposing a React.useRouter
hook which would call the context and stabilize the object/functions enabling the same benefits.
This is also a good reason to re-implement the router as a function component, as we can use some of the optimizations currently available via hooks to ensure good performance and unnecessary re-rendering.
I was mostly referring to the fact that since it doesn't provide the function in the removeEventListener. I can confirm this does not work as intended. This has probably never come up because the router should be at the top level, but it doesn't necessarily have to. With a function component it's very easy to stabilize the event handlers, here's an example where I've done this before. Maybe I should go ahead with creating a custom hook (something like useEventListener) for this pattern for the community to use.
Alright, fair enough 😄 function component it is
We could use a context to expose the router for React, and pass a Router type for Elmish. The registration of the router would then have an additional required parameter to register the router in the model: Router option -> Msg, then the commands would require a Router option. This makes the behavior consistent and much safer, Elmish users then can't shoot themselves in the foot or run into the issue @Dzoukr had. I've used this pattern in a couple libraries such as Fable.SignalR and Feliz.UseWorker, I've found it's successful in preventing illegal states.
I understand what you mean, but I think it makes the library more complicated than it should be. Making things simple and convenient is why I built this thing. However, to makes things EVEN simpler, I would love to have that React.useRouter
hook 😍 which takes path mode as input? 🤔
I understand what you mean, but I think it makes the library more complicated than it should be. Making things simple and convenient is why I built this thing.
Yeah I totally get that.
If we don't go that route these are the options as I see it:
For the React side of things I think the context would work very well, as we would then simply not expose the Router type except from that context. It would still need a function component so we can create the context producer and they can configure it. The hook would simply be React.useRouter()
and it would return the Router
type that exposes those functions that are available now in this branch, but provides the safety users would expect.
Anyways those are my ideas, just tell me what you want and that's what I'll do!
It's less about what I personally want and more about trying to strike a balance. It is always good to re-evaluate things I never thought about twice, for example using a Router
type or using React context. That said, I still believe that keeping things in this level of simplicity is the way to go, even if it is potentially unsafe. Using a context or using a dedicated type is not straightforward to understand from beginners point of view (especially if they haven't used React context before). Ideally when explaining routing to beginners, I want to say: "Routing is about the URL segments which are a bunch of strings that can change when the URL in the address bar changes." Done! No context, no special types that have to be passed around.
Following this concept, I think adding React.useRouter
will be a huge win because of how it simplifies things where the router component itself wouldn't be needed anymore. I am thinking it could have this API
let currentUrl : string list = React.useRouter() // hash-based routing
let currentUrl = React.useRouter(HashMode.Path) // (or something similar for) path-based routing
About Cmd.Router.navigate
vs. Cmd.navigate
: in my mind, whenever a library makes something "verbose", it is a way to discourage its use for certain times and only when the users really know what they are doing. That is why in a way I prefer Cmd.navigate
because then we are not favoring the use Router.navigate
(React-only) over the Elmish version. I hope that kinda made sense hahah. Sorry about the prolonged discussions :smile:
Taking the router completely out of the react tree is an interesting idea.
In fact, I believe it also enables us to make it totally safe with that change for Elmish, as they wouldn't even need to create anything. We would just add a Cmd.configRouter
with a signature of (RouterOptions -> RouterOptions) -> Cmd<'Msg>
, a method with optional parameters (although it's not super idiomatic for Cmds), or the list style like we have now.
It's a bit awkward with React like this though, as they would need to use an effect to configure it at the root of their application, and then be forced to pass it around. At which point it would probably be more intuitive to use a component with context.
So it would look something like this:
SupervisorAgent
RouterAgent
System.IDisposable
to clean up event handlers.This means if someone wants to use specific settings for their router it's their responsibility to set it in their init
function, or before they send any messages, but also gives them the freedom to change things later.
Would look something like this:
type Model = ...
type Msg =
| UrlChanged of string list
| ...
let init =
someModel, Cmd.configRouter UrlChanged (fun o -> { o with HasMode = HashMode.Path })
// or
someModel, Cmd.configRouter(UrlChanged, HashMode.Path)
// or
someModel, Cmd.configRouter [
router.onUrlChanged UrlChanged
router.pathMode
]
let update msg state =
| ...
state, Cmd.navigate ...
Router component
Router hook
Router
that is essentially the same as the current Router
.let myChild = React.functionComponent(fun () ->
let router = React.useRouter()
Html.button [
prop.text "Click me!"
prop.onClick <| fun _ -> router.navigate(...)
])
let render = React.functionComponent(fun () ->
let page,setPage = React.useState []
React.router [
router.onUrlChanged setPage
router.hashMode
router.children [
myChild()
]
])
This is technically a little bit more verbose, but it's very idiomatic to React, and is safe.
The reward for this is that we can add some goodies such as localized url observation. For example when working on a page that certain actions do not navigate, but instead modify the url to represent a persistent state of the application (like the Fable repl automatically changing the url instead of having to click a button to make the link sharable).
For example, in the myChild
component we could do this:
let somePathFunc (segments: string list) : string = ...
let myChild = React.functionComponent(fun () ->
let router = React.useRouter()
let text,setText = React.useState ""
router.useObservation(somePathFunc >> setText)
Html.div [
Html.text text
Html.button [
prop.text "Click me!"
prop.onClick <| fun _ -> router.navigate(...)
]
])
Sorry about the prolonged discussions 😄
Nonsense! It's totally worth it to make it as good as possible while still being intuitive to use.
After our discussion on Slack I think I've got everything setup. I also found some additional points where code could be optimized so I've made those changes. I also inlined the rest of the Router
type as they're just minor wrappers around other functions we're already calling, so it will help reduce bundle size.
I have been trying out this implementation on my machine using the demo inside the repo and the router seems to be broken 😞 neither changing the URL via anchor tags nor commands triggers onUrlChange
unless I do a full refresh
Looks like I put too much trust in the tests, I didn't look too much at the app page since it looked so simple, saw the url change and figured it was good. I'll figure out what's going on
Oh lol, I forgot to update the paket dependencies after fixing a bug with window listeners in the useListener library. A paket update fixes it and then it all works fine.
I guess it is time to write better tests with RTL and asserting stuff after changing URLs etc.
Will look into it soonish
Introduces a few changes for maintainability and easier use of api:
Cmd
s, eliminates the need forRouter.execute