Any disadvantage in always using Html.lazy?

The svelte/Elm virtual DOM topic got me thinking… this has probably been asked before, but would there be any disadvantage if Elm removed the explicit Html.lazy functions and instead the compiler automatically always inserted them everywhere?

I believe Html.lazy does a comparison by object reference which should be inexpensive timewise.

Are there situations where Html.lazy could incorrectly ignore a model change that should cause an update to the VDOM? Seems unlikely in a side-effect free language.

6 Likes

Sorry this answer doesn’t really address your question, just adds an observation.

I don’t have a good handle on how exactly Html.lazy works, but I think it doesn’t integrate quite so well with the use of extensible records to restrict argument types.

As I understand it, the idea is to not use nested records, but instead have a flatter Model and use extensible record types to constrain the types of functions. The example Richard Feldman gives in his ‘Scaling Elm Apps’ talk (https://www.youtube.com/watch?v=DoA4Txr4GUs) is an Address type. Which you might write something like:

type alias Address = { street : String, city : String, country : String }
alterAddress : String -> Address -> Address

and then have that on your Model type as

type alias Model = { address : Address, ... other fields }

But you then have to do nested record update in your update function, and his point is that you can get the same effect if you do:

type alias Address r = { r | street : String, city : String, country : String }
alterAddress : String -> Address r -> Address r

and then have that on your Model type as

type alias Model = { street : String, city : String, country : String , ... other fields }

Then you do not need to do nested record update since you can just pass the model into alterAddress and the types work out and the function is just as restricted as before, profit.

One great thing about this approach, is that if you find a viewBlah function, needs something more from the model, you can just add it to the extensible record type, and you do not need to change anything else in your code, you don’t need to change any call sites (assuming they are passing the larger record), and you don’t even need to update the parameters line of the function definition (just the type).

If I understand Html.lazy correctly though, this would kind of break something like:

viewAddress : Address -> Html Msg
...

view : Model -> Html Msg
view model =
         div []
               [ ....
               , Html.lazy viewAddress model.address
               , ...
               ]

Which would work in the original fashion, in that the function viewAddress would only be re-invoked if the actual address field of the model has changed. But if you use the above extensible record version then you do:

viewAddress : Address r -> Html Msg
...

view : Model -> Html Msg
view model =
         div []
               [ ....
               , Html.lazy viewAddress model
               , ...
               ]

Now viewAddress is invoked whenever anything in the model is changed, which is presumably in response to most messages.

A similar question would be when you update the model with the same field. Sometimes you might do something like the following in your update function:

    SelectAddress addressId ->
        let
             newAddress =
                   Dict.get addressId model.allAddresses
                      |> Maybe.withDefault model.selectedAddress
        in
        ( { model | selectedAddress = newAddress }, Cmd.none )

So in the case that the address is not found the model is actually left unchanged, but I believe that it will fool Html.lazy because you will actually create a new Model record which just happens to have the same fields as before. I doubt this is a bit issue.

Can you please explain with some code what do you mean by “insert them everywhere”?

It is not clear to me how this would actually work. In the current form, Html.lazy takes some data and a view over that data and computes the view only if the data changes. It is not clear to me how this would work without it being explicit short of memoizing every function call in the language.

Sure, initial idea short on detail, but I guess if you have some code with a signature that matches with:

someView : a -> Html msg

(and all the variants with more args:

someView : a -> b -> c -> ... -> Html msg

)

the compiler would automatically recognise these type signatures and generate instead:

someView : a -> Html msg
someView model =
    let
        someViewOriginal = ... -- The code you wrote
    in
        Html.lazy someViewOriginal

That would be applied by the compiler as a source level optimisation against the parsed AST.

The actual transformation if pursuing this is that it would take your old view functions view: Model -> Html Msg and convert them to say oldView: Model -> Html Msg so that it could introduce the new view function:

view : Model -> Html Msg
view model =
    Html.lazy oldView model

This isn’t actually interesting at the top level since I think the Elm runtime already won’t re-render the view if the model hasn’t changed, but consider applying it anywhere a separate view function had been created. Or more aggressively, consider auto-inferring dependencies in the view code and introducing lazy nodes automatically for Html generating expressions.

The question of whether this pays off comes down to whether it saves enough on render and diff logic (including memory allocation) to make up for the overhead of introducing all of those extra lazy nodes in the DOM tree. That depends on how stable the nodes are. If the parameters for a node change frequently, then that node and all of its parents are a lose to make lazy. If the parameters are relatively stable, it’s a win.

While I doubt — at least in the absence of profiling information or a lot of program analysis — that the compiler could detect which nodes have relatively stable parameters, this does point to one optimization that the compiler could do: if a subtree in a view expression is constant, then it should be broken out to a module level expression generating a lazy node. The lazy part is to help with initialization time. If something is really simple, it could obviously just be built directly at initialization without the need for lazy.

Mark

1 Like

This is what I was trying to suggest in my OP. Have the compiler auto insert Html.lazy(N) everywhere that it is possible to do so.

Side note: Common mistakes that I see people make when using Html.lazy:

• Partially binding the parameters to the view function passed to Html.lazy generally as a way to get around limits on the number of parameters. For example:

Html.lazy4 (viewFn arg0 arg1) arg2 arg3 arg4 arg5

The partial function application creates a new function on each invocation of the code and hence this function will not match the function from the previous render and the lazy will just introduce virtual DOM overhead.

• Constructing records to deal with the above case:

Html.lazy recordBasedViewFn
    { arg0 = arg0
    , arg1 = arg1
    , arg2 = arg2
    , arg3 = arg3
    , arg4 = arg4
    , arg5 = arg5
    }

Now we are generating a new record on every render and hence will fail the identity test used by lazy virtual DOM nodes.

• Related to the preceding: If you use a Model/ViewData approach in which the model is converted to view data which is then used to render — an approach also known, I believe, as “selectors” — then you have the same issue assuming that the view data is built on each render since the view datas from one render to the next will fail an identity match.

Mark

2 Likes

Are you sure it is failing the identity match? I can not reproduce the behaviour of constructing a new record.

---- Elm 0.19.0 ----------------------------------------------------------------
Read <https://elm-lang.org/0.19.0/repl> to learn more: exit, help, imports, etc.
--------------------------------------------------------------------------------
> a = {x = 3}
{ x = 3 } : { x : number }
> b = {x = 3}
{ x = 3 } : { x : number }
> x = a == b
True : Bool

Lazy relies on reference equality on the JS side (===), so even though Elm shows them as structurally equal they will be considered unequal on the JS side.

For some types, like strings and ints, they are compared by value on the JS side and work nicely with the lazy functions.

Here’s the part in virtual-dom where it has the logic

1 Like

Makes sense, as the reference check is just comparing two numbers, so the lazy check never grows in cost as the data structure gets more complex.

Partly why I ask if it would make sense to just do it all the time anyway - the CPU branch prediction may work out well on this kind of check, that seems likely to be biased to go one way or the other most of the time.

You also have to wonder if a full structural equality check would be so bad here either? The assumption is that building the real DOM is much more expensive than the work the code does to build the virtual one, and that kind of implies that processing the data structure is also much cheaper than building the real DOM. I guess there would be cases where it works out really bad.

It definitely seems worth exploring! Maybe lazy should be a feature you have to opt out of rather than opt into.

I got burned by this one recently. I tried to use a lambda as the first parameter in Html.Lazy.lazy to circumvent some problems I created that broke my lazy rendering. But a lambda means a new function is created every render. I didnt know that Html.Lazy checks even for reference equality of the function thats passed into it; but it does.

You don’t need lazy in order to avoid updating the real DOM, that’s what the VDOM diffing is for. With lazy you just skip recomputing your function and doing that VDOM diff.

Structural equality checking would be much more intuitive and beginner friendly compared to the current reference equality, and because of that I suspect there’s a good reason why Evan didn’t use it? Even though it’s usually very fast for immutable data structures, I’m guessing it was still too slow to run on every update?

When you put it like that, I can see there may be more work involved in a full structural comparison versus the code update. For example, if the code just added 1 item to a list - that will be less work than the full structural comparison of 2 lists. So yeah, I guess a quick reference check might well be the best option.

Correct me if I am wrong here, but what I take away from this is that the pattern outlined in the following post is pretty useless as this will result in the creation of a new ViewModel with every render?

Html.lazy works by memoization, meaning that you’re using less CPU power at the cost of more memory usage. It’s also worth nothing that while reference equality is very cheap, it’s still something that is being done in addition to running the view function in the case where the arguments fails the equality test.

For view functions which are likely passed different arguments each time they’re called, you’re just wasting memory and adding overhead.

For view functions which are likely to be given the same argument every time they’re called, but are cheap to run, you’re using more memory for little benefit.

Html.lazy is an optimization and like all optimization, it involves trade offs.

Lazy should only be used when it’s known to have a good effect, and that is pretty hard for a compiler to know.

7 Likes

Does it actually memoize all calls to view or just the most recent one?

Suppose I had: type alias Model = Int, and view was called with the models 1 then 2 then 3 and then back to 1 again. On that last call, would the VDom for the first view call with the argument 1 be recovered from memory?

As far as I understood it this is not the case; Html.lazy just remembers the most recent view call and model, and compares with the current model to see if the VDom needs to be rebuilt. So in the example above, the most recent model was 3 and the current is 1, so the VDom would be re-built. Building it is only going to be skipped if it is called twice in a row with the same argument, for example 1 and then immediately after 1 again.

Never actually read the code to confirm this though…

It defeats Html.lazy, you are correct. I would not say the pattern is completely useless as a result of that though as many applications get perfectly good performance without Html.lazy.

No, you’re right. It only stores the latest call. But this still adds up if you’re, say, applying lazy to every element in a List.