dmjio / miso

:ramen: A tasty Haskell front-end framework
https://haskell-miso.org
BSD 3-Clause "New" or "Revised" License
2.2k stars 139 forks source link

Proper hot code reloading #749

Open georgefst opened 1 month ago

georgefst commented 1 month ago

The sample app README mentions "hot reload". Perhaps these terms aren't all that standardised but my experience from the JavaScript ecosystem is that what we have in the examples, via jsaddle-warp's debug function, where the browser auto-refreshes but state is reset, is called "live" reloading. Whereas "hot" reloading tends to mean actually swapping out code without restarting the program, as associated with languages like Lisp, Erlang and Smalltalk.

As far as I can tell, Miso doesn't support this?

georgefst commented 1 month ago

Of course, many solutions to this problem are pretty hacky and unsound. I don't think any of the JS frameworks or bundlers implementing hot reloading (often referred to as "HMR" - hot module reloading, presumably since modules are the smallest unit replaced) have a great story around what happens when interfaces change in incompatible ways.

But Miso is potentially in a great position to handle this in a principled way, since everything is immutable and the state is a first class entity. It even has a type, so we can detect when changes are incompatible. All we need to do is serialise the state on each update (or on exit, if we can reliably intercept that) and attempt to de-serialise upon reloading*. Come to think of it, this still isn't really true hot reloading, since the code is fully replaced, but it's much simpler and the end result is largely the same.

A hacky working prototype, probably with race conditions, is as simple as:

startAppWithSavedState :: (Eq model, Read model, Show model) => Miso.App model action -> JSM ()
startAppWithSavedState app = do
    loadedInitialState <- liftIO $ doesFileExist filePath >>= \case
        False -> pure Nothing
        True -> readMaybe <$> readFile filePath
    startApp
        app
            { model = fromMaybe app.model loadedInitialState
            , update = \case
                Nothing -> pure -- no-op
                Just a -> \m -> do
                    m' <- first Just $ app.update a m
                    m' <# do
                        liftIO $ writeFile filePath $ show m'
                        pure Nothing
            , subs = mapSub Just <$> app.subs
            , view = fmap Just . app.view
            , initialAction = Just app.initialAction
            }
  where
    filePath = "miso-state"

Various potential improvements:

* I'm hardly the first person to notice this. This Elm article talking about how much easier the whole problem is in a functional setting is over a decade old. Unfortunately, it's also presumably out-of-date since it mentions FRP. I don't know what support for hot reloading Elm has today.

dmjio commented 1 month ago

Hi,

The nomenclature used around "hot reloading" vs. "live reloading" in the docs might need improvement. According to your description miso has live reloading via jsaddle yes. The code is altered, browser refreshed, and the state gets reset every time.

I like your idea of persisting the state to disk on all changes for jsaddle users. I typically don't use jsaddle, so I haven't experimented too much with this approach. With the JS-backend you can save the state of the miso application to local storage on every event. Since jsaddle for development keeps the state on the server your example above would be the equivalent.

Something like you're describing should exist, like a "Phoenix Live view", etc. would be nice. I'd prefer to dump the state as JSON for it to be readable, but in theory people can do whatever they want.

You could try using inotify to detect file changes and then use ghc-hotswap to recompile / reload modules incrementally. This way the jsaddle websocket connection could be preserved as the code evolves. Instead of a browser refresh there might be a way to notify the frontend to perform a redraw with the new state read from disk. I'd have to check if the jsaddle protocol is extensible.

That's how I'd try to go about doing it.

georgefst commented 1 month ago

With the JS-backend you can save the state of the miso application to local storage on every event. Since jsaddle for development keeps the state on the server your example above would be the equivalent.

That's a great point. Would you accept a PR which added a cleaned-up version of startAppWithSavedState based around the local storage API?

You could try using inotify to detect file changes and then use ghc-hotswap to recompile / reload modules incrementally. This way the jsaddle websocket connection could be preserved as the code evolves. Instead of a browser refresh there might be a way to notify the frontend to perform a redraw with the new state read from disk. I'd have to check if the jsaddle protocol is extensible.

Something based around genuine hot-swapping would be amazing to have. But I suspect it has its own caveats (e.g. I don't know how it handles data of a type whose definition has changed), and I suspect it's a lot more work anyway (the library repo is archived, for a start, which isn't a great sign). Given that I already have a feedback cycle well under a second with the example code above, I wouldn't personally be motivated to work on integrating ghc-hotswap.

dmjio commented 1 month ago

Sure, there should be some prior art on local storage for reference as well

georgefst commented 3 weeks ago

jsaddle for development keeps the state on the server

I've realised I'm not actually sure what you mean by this. AFAICT using local storage via JSaddle does the same thing in both environments.

dmjio commented 3 weeks ago

My understanding is that w/ jsaddle the update function gets executed on the server, the client has an interpreter that modifies the DOM via a websocket protocol. Yes localStorage API stays the same. You could serialize the model to disk on the server, or to localStorage w/ jsaddle. It'd probably be better to do it on the server to avoid round trips.

I thought you were going to mimic the localStorage API on the server and save the serialized model to disk on each call to update