elm-spa-example

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

1post · 30d
Nov 30 posts
  1. DEV · · @rinn7e

    Pure in-app navigation implemented in Elm (URL become a thin wrapper instead) --- title: Pure in-app navigation implemented in Elm (URL become a thin wrapper instead) published: true description: The model owns where the app is, and one wrapper around update keeps the URL in sync tags: elm, functional, routing cover_image: direct_url_to_image.jpg Use a ratio of 100:42 for best results. --- In the usual Elm app the URL is the source of truth. To go somewhere, update writes the URL, and the app only moves when the URL comes back as UrlChanged. This works, but after a while I found three problems with it, so I tried the other way around: the model owns where the app is, and the URL just follows it. I am writing it down in case I forget, or in case someone else finds it useful. !The usual way: every move goes through the URL !The thin URL wrapper: the URL follows the model You can see the sample code here: rinn7e/elm-navigation-project It has the same small blog three times (a list of posts, a post, an about page, a login page, and a page to write a new post that only signed-in users can open): - elm-blog: the usual way, as in elm-spa-example - elm-blog-url-wrapper: the thin URL wrapper - elm-blog-url-wrapper-screen-naming: the same as the second one, with other names The test account is test / testtest. Requirement This post aims for those who are already familiar with Elm and Browser.application. Every tool is a local dev dependency (Elm, elm-format, Vite with vite-plugin-elm, Tailwind v4), so in each app folder this is enough: npm install npm run dev The usual way Until the section on opaque types, the snippets show the apps as they were at this commit. That section adds opaque types to all three apps. Here are the branches of update in elm-blog that write the URL: update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case ( msg, model.page ) of ( LinkClicked urlRequest, _ ) -> case urlRequest of Browser.Internal url -> ( model, Nav.pushUrl model.key (Url.toString url) ) Browser.External href -> ( model, Nav.load href ) ( UrlChanged url, _ ) -> changeRouteTo (Route.fromUrl url) model ( ClickedLogout, _ ) -> ( { model | user = Nothing } , Cmd.batch [ storeUser Nothing, Route.replaceUrl model.key Route.Home ] ) ( GotLoginMsg subMsg, Login login ) -> case Login.update subMsg login of ( _, Just user ) -> ( { model | user = Just user } , Cmd.batch [ storeUser (Just user), Route.replaceUrl model.key Route.Home ] ) -- ... And the guard lives in changeRouteTo, where the URL comes back: Just Route.NewPost -> if model.user == Nothing then ( model, Route.replaceUrl model.key Route.Login ) else ( { model | page = NewPost NewPost.init }, Cmd.none ) The problems with this are: 1. Navigation is spread over update. Every branch that wants to move the app has to write the URL itself. A new branch that forgets it leaves the URL showing something other than the screen. 2. The echo. Everything the app writes comes back as UrlChanged, so one move is two updates: one that writes the URL, and one that really changes the page. I wrote about this before in Solving Elm Router "Double Update" Problem, where I needed an isInternal flag to ignore it. With a guard it is even more: a guest clicking "Write" is three messages and two URL writes (LinkClicked, push /new, UrlChanged /new, replace /login, UrlChanged /login). 3. The sync. Between the write and the echo, the model and the URL do not agree. Right after a login, model.user is set but the page is still the login page until UrlChanged arrives. The idea The URL is not state. It shows where the app is, the same way the view shows what the app holds, so it can be written from the model too: - The model owns where the app is (the destination). update never touches the URL. - One wrapper around the root update writes the URL after every step, from the destination read from the model. A change of destination pushes an entry, the same destination writes nothing. - The URL is never the source of truth: it is only parsed into a move at boot, on back / forward, and when a link is clicked (Elm hands links to us as URLs). The guard runs on every move, and when it redirects, the URL is corrected with a replace. - Only one module (Route) knows how a destination is spelled as a URL. The thin URL wrapper The Route module and the pages do not change at all. Everything below is in Main.elm of elm-blog-url-wrapper. 1. The destination is read, not stored The page in the model already says where the app is, so the destination is not stored a second time. It is read from the page: destinationOf : Model -> Maybe Route destinationOf model = case model.page of NotFound -> Nothing Home -> Just Route.Home Post slug -> Just (Route.Post slug) About -> Just Route.About Login _ -> Just Route.Login NewPost _ -> Just Route.NewPost In a bigger app each part of the model keeps its own piece (the screen shown, an open dialog, a tab) and destinationOf puts them together. 2. Navigating is a model change A move goes through the guard, and the destination shown already is dropped. It returns a Model, there is no Cmd: navigate : Maybe Route -> Model -> Model navigate maybeRoute model = let destination = guard model maybeRoute in if destination == destinationOf model then model else openPage destination model guard : Model -> Maybe Route -> Maybe Route guard model maybeRoute = case maybeRoute of Just Route.Login -> if model.user /= Nothing then Just Route.Home else maybeRoute Just Route.NewPost -> if model.user == Nothing then Just Route.Login else maybeRoute _ -> maybeRoute openPage is the old changeRouteTo without the guard and without the Cmd. So the branches of update only change the model. A login sets the user and navigates home, and nothing in update knows about the URL: ( LinkClicked urlRequest, _ ) -> case urlRequest of Browser.Internal url -> ( navigate (Route.fromUrl url) model, Cmd.none ) Browser.External href -> ( model, Nav.load href ) ( UrlChanged url, _ ) -> ( navigate (Route.fromUrl url) model, Cmd.none ) ( ClickedLogout, _ ) -> ( navigate (Just Route.Home) { model | user = Nothing } , storeUser Nothing ) ( GotLoginMsg subMsg, Login login ) -> case Login.update subMsg login of ( _, Just user ) -> ( navigate (Just Route.Home) { model | user = Just user } , storeUser (Just user) ) -- ... Links stay a tags with an href (open in a new tab still works). Elm sends every plain click on them to onUrlRequest, so LinkClicked is just one more move: it opens the page, it does not push the URL. 3. The wrapper This is the only place that writes the URL: updateWithUrl : Msg -> Model -> ( Model, Cmd Msg ) updateWithUrl msg model = let ( newModel, cmd ) = update msg model in ( newModel, Cmd.batch [ urlCmd msg model newModel, cmd ] ) urlCmd : Msg -> Model -> Model -> Cmd Msg urlCmd msg model newModel = case ( msg, destinationOf newModel ) of ( _, Nothing ) -> -- The not found page writes no URL: no link of the app leads to it Cmd.none ( UrlChanged url, Just route ) -> if Route.fromUrl url == Just route then Cmd.none else Route.replaceUrl newModel.key route ( _, Just route ) -> if destinationOf model == Just route then Cmd.none else if guard newModel (destinationOf model) /= destinationOf model then -- The guard would redirect the destination before Route.replaceUrl newModel.key route else Route.pushUrl newModel.key route And it replaces update in the program: main : Program (Maybe String) Model Msg main = Browser.application { init = init , view = view , update = updateWithUrl , subscriptions = \_ -> Sub.none , onUrlRequest = LinkClicked , onUrlChange = UrlChanged } A back or forward of the browser (UrlChanged) has its entry already, so it is only corrected when the guard redirected it. Every other step pushes a new destination, with one exception: when the guard would now redirect the destination you left, its entry is replaced. After a login the login page would only redirect home, so back goes to the page before the login. The same after logging out on /new. Apart from telling a back / forward from the rest, the wrapper decides from the state (the destination before and after the step), not from which branch of update moved the app. So it cannot be forgotten. The echo is still there in Elm (Nav.pushUrl and Nav.replaceUrl send UrlChanged), but now it is harmless: it is the destination shown, so navigate drops it and the wrapper writes nothing. No isInternal flag is needed. 4. Boot At boot the URL is read once. The page is opened through the guard right away, and the URL is corrected with a replace only when the guard redirected it: init : Maybe String -> Url -> Nav.Key -> ( Model, Cmd Msg ) init user url key = let urlDestination = Route.fromUrl url bootModel = { key = key , user = user , posts = BlogPost.all , page = NotFound } model = openPage (guard bootModel urlDestination) bootModel in ( model, bootUrlCmd urlDestination model ) bootUrlCmd compares urlDestination with destinationOf model and calls Route.replaceUrl when they differ. What changed In src, only Main.elm changed from elm-blog. You can check with: git diff --no-index elm-blog/src elm-blog-url-wrapper/src - update never writes the URL: each branch only changes the model. - The guard runs on every move, before the page opens: at boot, on back / forward, and inside the app. - A guest clicking "Write" is two messages (LinkClicked, and the echo of /login, which is dropped) and one URL write. /new is never written. - Logging out from a page the guard still allows (/about) now pushes /, so back returns to that page. In elm-blog it replaced the entry. - The model and the URL agree after every step. The not found page is the exception: it writes no URL, which is fine because no link of the app leads to it. Opaque types: make impossible updates impossible This part works with either way of navigating, so I added it to all three apps. There is one hole left in both. Nothing stops a branch of update from writing the page directly: { model | page = NewPost NewPost.init } That compiles, and the guard is skipped for that step. In elm-blog a guest now sees the new post page. In the wrapper apps the URL follows to /new, and only the echo of it happens to go back through the guard: that is luck, not design. The guard runs only if every move remembers to call it. So I gave the guard and the page their own modules, with opaque types: the module exposes the type but not its constructor, so only that module can build a value with it. module Guard exposing (Guarded, guard, route) type Guarded = Guarded (Maybe Route) guard : Maybe String -> Maybe Route -> Guarded -- ... module CurrentPage exposing (CurrentPage, Page(..), open, page, setLogin, setNewPost) type CurrentPage = CurrentPage Page open : Guarded -> CurrentPage The model now holds a CurrentPage, and the only way to make one is open, which takes a Guarded, which only guard makes. The forms of the login and new post pages still change in place, but only through setLogin and setNewPost, which keep the page shown. In elm-blog, changeRouteTo opens the page through them: changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg ) changeRouteTo maybeRoute model = let guarded = Guard.guard model.user maybeRoute in case Guard.route guarded of Just route -> if Just route /= maybeRoute then ( model, Route.replaceUrl model.key route ) else ( { model | page = CurrentPage.open guarded }, Cmd.none ) Nothing -> ( { model | page = CurrentPage.open guarded }, Cmd.none ) The wrapper apps use the same two modules, and their navigate becomes: navigate : Maybe Route -> Model -> Model navigate maybeRoute model = let destination = Guard.guard model.user maybeRoute in if Guard.route destination == destinationOf model then model else { model | page = CurrentPage.open destination } And the write from above no longer compiles: -- TYPE MISMATCH ----------------------------------------- WritePageDirectly.elm I cannot update the `page` field like this: 13| { model | page = NewPost NewPost.init } ^^^^^^^^^^^^^^^^^^^^ This `NewPost` call produces: Page But it should be: CurrentPage The same for CurrentPage.open (Just Route.NewPost) (a route is not a Guarded) and for Guard.Guarded (Just Route.NewPost) (the constructor is not exposed). In each app, npm run check:opaque compiles these cases from compile-fail/ and checks that each one fails. This is "make impossible states impossible", but for updates: the compiler stops a write that skips the owner of the value. Elm needs no tricks for it. There are no casts, and a record update needs a value of the type of the field, which only the owner module can make. One honest limit: any module can still import Guard.guard and CurrentPage.open, because they are exposed, and guard trusts the user it is given. The types make sure every page went through them, but who calls them, and with what, is still decided by how the modules are laid out. Conclusion That is it. The URL becomes a thin adapter at the edges: written from the model after every step, and parsed into a move only when the browser hands one to us. If the naming makes it harder to follow, it is because Route and Page are names from the URL-first way. In elm-blog-url-wrapper-screen-naming I changed the names only: Page is Screen (what the app shows) and Route is Destination (where the app is): type Screen = NotFound | Home | Post String | About | Login Login.Model | NewPost NewPost.Model destinationOf : Model -> Maybe Destination destinationOf model = case CurrentScreen.screen model.screen of NotFound -> Nothing Home -> Just Destination.Home -- ... With these names the model holds the current screen, and the destination is read from it. I hope to hear from others if they reach the same conclusion. Feel free to ask me anything as well. Thanks for reading!

    1 reaction

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

README badges

Embed a badge in your README or website.

Live mentions · last 30 days
rtfeldman/elm-spa-example mentions in the last 30 days