TimLariviere / Fabulous-new

Fabulous v2 - Work in progress
https://timothelariviere.com/Fabulous-new/
Other
41 stars 3 forks source link

Implementation of View.memo (dependsOn in Fab v1) #36

Closed twop closed 2 years ago

twop commented 2 years ago

Fixes https://github.com/TimLariviere/Fabulous-new/issues/18

Intro

This adds support for View.memo that can be used to optimize rerenders

Here is a minimal usage example (from tests)

Stack() {
    View.memo
        model.someValue
        (fun value -> VerySlowLabel(string value))
}

Lambda won't be called unless model.someValue changes, thus skipping time to render VerySlowLabel and diffing it

How it works

Memo Attribute

View.memo produces a new WidgetBuilder that captures all needed info in a Attribute

Here is the type definition of the attribute value

type internal MemoData =
    {
        /// Captures data that memoization depends on
        KeyData: obj

        // comparer that remembers KeyType internally
        KeyComparer: obj -> obj -> bool

        /// wrapped untyped lambda that users provide
        CreateWidget: obj -> Widget

        /// Captures type hash of data that memoization depends on
        KeyTypeHash: int

        /// Captures type hash of the marker memoized function produces
        MarkerTypeHash: int
    }

Then, to avoid adding any other new properties via .prop syntax we wrap the marker type in a new type, like so

type Memoized<'t> = { phantom: 't }

Note that marker types are needed just for type safety and intellisense, there is no actual instance of Memoized ever created.

View creation

Then we need to be able to create the underlying control (like XF.Page), how do we do that?

Well, there is a trick I used to achieve that:

We register a new WidgetDefinition that redirects creation to the underlying widget, of course, we need to create it first, like so

let private widgetDefinition: WidgetDefinition =
    {
        Key = MemoWidgetKey
        Name = "Memo"
        CreateView =
            fun (widget, context, parentNode) ->

                  // access the memo data stored in the memo widget
                let memoData = getMemoData widget

                // call the function for the first time to create underlying widget
                let memoizedWidget = memoData.CreateWidget memoData.KeyData

                // get the underlying widget definition
                let memoizedDef =
                    WidgetDefinitionStore.get memoizedWidget.Key

                // create the view and ViewNode using it
                let struct (node, view) =
                    memoizedDef.CreateView(memoizedWidget, context, parentNode)

                // store widget that was used to produce this node
                // to pass it to reconciler later on
                node.PropertyBag.Add(MemoWidgetKey, memoizedWidget)
                struct (node, view)
    }

Updating the view

Ok, now how to handle updates?

  1. We need to be able to compare stored MemoData (prev vs cur).

Easy enough:

let private compareAttributes (prev: MemoData, next: MemoData) : ScalarAttributeComparison =
    match (prev.KeyTypeHash = next.KeyTypeHash, prev.MarkerTypeHash = next.MarkerTypeHash) with
    | (true, true) ->
        match next.KeyComparer next.KeyData prev.KeyData with
        | true -> ScalarAttributeComparison.Identical
        | false -> ScalarAttributeComparison.Different null
    | _ -> ScalarAttributeComparison.Different null

So we check if captured types are the same and if they are if the actual keys (the first argument to View.memo) are equal.

  1. Then we need to figure out if we can reuse the ViewNode instance while diffing, that is, if the underlying widget type changed
let canReuseView (prevWidget: Widget) (currWidget: Widget) =
    let prevKey = prevWidget.Key

    if not(prevKey = currWidget.Key) then
        false
    else if (prevKey = Memo.MemoWidgetKey) then
        // even if the WidgetKey is the same, e.g. Memo.MemoWidgetKey
        // it doesn't mean that underlying widget is of the same type
        Memo.canReuseMemoizedViewNode prevWidget currWidget
    else
        true

// in Memo.fs
let internal canReuseMemoizedViewNode prev next =
    // check the captured type, Example `IButton` marker
    (getMemoData prev).MarkerTypeHash = (getMemoData next).MarkerTypeHash
  1. Ok, given that the underlying type is the same and compareAttributes returned ScalarAttributeComparison.Different what do we do next?
let private updateNode (data: MemoData voption, node: IViewNode) : unit =
    match data with
    | ValueSome memoData ->
        let memoizedWidget = memoData.CreateWidget memoData.KeyData
        let propBag = node.PropertyBag

        // remember that we added widget to propBag?
        // that is exactly why we did that, to pass to diffing
        let prevWidget =
            match propBag.TryGetValue MemoWidgetKey with
            | true, value -> ValueSome(unbox<Widget> value)
            | _ -> ValueNone

        // update stored widget for future diffing
        propBag.[MemoWidgetKey] <- memoizedWidget

        // invoke reconciler manually  
        Reconciler.update node.TreeContext.CanReuseView prevWidget memoizedWidget node

    // this should actually never happen, MemoWidget should always have MemoAttribute
    | ValueNone -> () 

Caveats/Notes

  1. To support Memoized<'marker> we need to add one more extension method per collection type, shouldn’t be too bad. Plus it is done either by us (maintainers) or by advanced users of Fabulous. Thus, I feel ok about that.
// copied from tests
[<Extension>]
static member inline Yield<'msg, 'marker, 'itemMarker when 'itemMarker :> IMarker>
    (
        _: CollectionBuilder<'msg, 'marker, IMarker>,
        x: WidgetBuilder<'msg, Memo.Memoized<'itemMarker>> // <----
    ) : Content<'msg> =
    { Widgets = [ x.Compile() ] }
  1. I decided not to capture type of the passed lambda to View.memo, but maybe we should, just to be extra safe. Then we need to add the check into compareAttributes function
  2. ONLY memoize large sub trees, the entire memo machinery is quite expensive, relatively speaking, Thus make sure that you memoize only expensive view functions. Example: A collection with several children with large number of attributes. You can apply this rule number of widgets + number of attributes > 15 (number are aggregate across entire sub tree)
twop commented 2 years ago

Note that it deviates from dependsOn, the most usages I saw don't take advantage of first argument being retyped model. So I think it makes sense to follow Elm here and React (including using the word memo).

I don't have a super strong opinion on that. For compatibility reasons we can either offer the old signature alongside memo, or even have just dependsOn.

TimLariviere commented 2 years ago

I'm closing/reopening the PR to trigger the new CI

TimLariviere commented 2 years ago

For the formatting, I'll add Fantomas in another PR