html
html copied to clipboard
Html.Lazy always recomputes inner functions
The virtual DOM will always recompute any function that's inside a let
, even if it's wrapped inside Html.Lazy.lazy
.
Here is the SSCCE:
https://github.com/showell/elm-start/commits/html-lazy-is-broken-for-let-functions
There are two commits to note here:
-
The last commit shows how it should work. Run this without building index.html. Instead, just use the index.html that is checked in. (If you look at the commit, you can see my proposed changes to index.html to avoid the bug, and there's some more detail.)
-
The second to last commit is the repro. Or, really, you can actually just rebuild the last commit. Either way, if you don't use my modified index.html, you can type around in the textarea and see it recomputing
lotsOfFreds
over and over again.
The example in the repro is a full program, but you can get the gist here:
66 view : Model -> Browser.Document Msg
67 view model =
68 let
69 lotsOfFreds : String -> Html Msg
70 lotsOfFreds s =
71 let
72 -- We should only see this once, but it happens every time
73 -- you type in the textarea
74 _ =
75 Debug.log "actually calling lotsOfFreds" ""
76 in
77 Html.pre [] [ Html.text (String.join "\n" (List.repeat 100000 s)) ]
78
79 body =
80 [ Html.textarea
81 [ onInput DontActuallyUpdateTheModel ]
82 [ Html.text "type in here to repro bug (and open debugger)" ]
83 , Html.Lazy.lazy lotsOfFreds model.fred
84 ]
85 in
86 { title = model.title
87 , body = body
88 }
The problem happens deep in the virtual DOM implementation. The virtual DOM is comparing two different copies of lotsOfFreds
.
3045 // Now we know that both nodes are the same $.
3046 switch (yType)
3047 {
3048 case 5:
3049 var xRefs = x.l;
3050 var yRefs = y.l;
3051 var i = xRefs.length;
3052 var same = i === yRefs.length;
3053 while (same && i--)
3054 {
3055 same = xRefs[i] === yRefs[i];
3056 }
3057 if (same)
3058 {
3059 y.k = x.k;
3060 return;
3061 }
3062 y.k = y.m();
3063 var subPatches = [];
3064 _VirtualDom_diffHelp(x.k, y.k, subPatches, 0);
3065 subPatches.length > 0 && _VirtualDom_pushPatch(patches, 1, index, subPatches);
3066 return;
I think this is technically a compiler bug, by the way. The code in Html.Lazy (or, more precisely,_VirtualDom_diffHelp
) is doing the right thing to make sure that the two functions in the thunk are, in fact, the same. The only reason the two functions aren't the same here is due to how the JS is emitted (and due to the fact that JS creates new copies of the function each time for some reason).
I doubt this is browser-specific behavior, but I tested this only on FireFox.
Also, just to make things even more complex here, if the inner view helper did close on any Elm variables besides the arguments (it doesn't here, but it could in other scenarios), then the Elm compiler would have to emit the inner function the way it does now. In that case there's no way that I can think of to emit code that is friendly to Html.Lazy.lazy. In other words Html.Lazy would always have to punt on optimizations due to seeing "different" functions (even though it's the same JS code). I think in most of these scenarios the relevant model data would be changing anyway, but sometimes it will be valid code that just uses maybe one mostly non-changing field from the model. So it's possible that the solution here is to just prevent folks from applying Html.Lazy.lazy to any function in the let
.
I opened https://github.com/elm/compiler/issues/2020 in hopes that the compiler could be changed here to support Html.Lazy
better. I am somewhat resigned to the fact, after talking on Slack, that folks just consider this a known limitation. If that's the case, then maybe there's a path to make the documentation even more clear here. Another person suggested trying to solve this first with something like elm-analyse
.
As you can see, equality here is by reference, not by value. Anything in the let (a function, a list, a record) will not be equal by reference to its value the last incarnation of the function.
The solution is to move functions and any non-primitive value to top-level function/value, or store it in the model and make sure it passes unscathed to lazy
.
I'm willing to commission somebody to implement a version of lazy
that works with records. This would be an invaluable addition for me. It would ignore the record and compare references of all the members of the record.
Many of the views in my apps take big responsibilities, and consequently, take big records as configurations. It makes things waaay more readable if the arguments are "named" this way.