How to avoid custom elements reconstructed when containing array is sorted


#1

Hi All,

I have some stateful custom elements in an array. These custom elements can be sorted based off some criteria. I’m noticing that when I sort the array some of the custom elements are removed from the page and then added back in. This means the lifecycle functions are invoked for the items that were moved(bind, attached, etc) and I lose the current state of these elements.

I understand that it may be necessary to remove and insert fragments, but is it possible to keep the state of these custom elements when sort occurs? I’d rather not store the state in another object, I don’t really have any need to persist or share this state, I just don’t want to lose it when the user is interacting with a sort button.


#2

Please share your html code and js file, so we will get more idea.


#3

I created a an example gist here:

My example has a list of cards representing a persons name and age. The cards can be sorted by age or name. Each card also has the most amazing feature that allows you to optionally make the card red. This state is stored on the card in the “styleString” property.

If you make each card red and then do a sort, you will notice some cards will become white. I think this happens when a card needs to be moved up in the array. In these cases it appears that the card custom element is removed and it is replaced with a new custom element, thus the “styleString” state is lost on the card.

I don’t really care if the item is temporarily removed and added from the DOM (I think that is probably necessary), but I’d like to keep an instance of the same object, rather than get a new element when doing sorting like this. Is that possible?


#4

I am afraid you need to push the inner state to upper level in order to retain them across sort, the easiest way is to attach those state to the item model object.

I am not 100% sure about how Aurelia repeater reuses DOM. From my limited understanding, repeater does reuse the existing DOM (they might be detached and attached again, but not destroyed when sorting). The problem is the bound model is not necessarily kept. Repeater might retain the DOM element at that position but bind to a different model (you will see the full lifecycle callbacks as if it’s a new DOM element, but the element is actually reused from view cache).

In future, Aurelia repeater might be improved for efficiency on sorting, but we don’t know.

No matter how Aurelia repeater deals with sorting, your business logic should not rely on the implementation detail of Aurelia repeater. It is too fragile doing so. I recommend to do little bit more work to control the state by yourself.


#5

That’s interesting, do you know where I can get more information on how the Aurelia repeater and/or view slots work?

I am familiar with flux/redux/mobx/aurelia store, so storing application state isn’t my problem. In my case i’m using a plugin, installed via npm package, that contains a custom element that is interactive and maintains it’s own state (privately). I’d like to be able to sort while keeping this element on the page. It’s a big jaring losing the current state of this element, but i really have no interest in storing it’s state.


#6

That’s interesting, do you know where I can get more information on how the Aurelia repeater and/or view slots work?

Read the source code I would suggest. That’s what I do when I want to understand. And I found digging into source code is surprisingly the least time consuming way to understand the internals. Furthermore, reading source code also helped me to judge the quality of a lib/framework.

You don’t have to do much to push the state up. For instance if your person object has an unique id, you can do this:

<card 
  repeat.for="person of people"
  model.bind="person"
  style-string.two-way="styles[person.id]"></card>
export class App {
  styles = {};
  // ...

#7

A teammate of mine discovered a workaround for this. Instead of using the array directly in our repeat.for block we created a computed property that returned a sorted array and bound the repeat.for to that. For whatever reason this allowed us to sort without losing state. Essentially something like this:

export class App {    
  currentSort = "";
  people = [
    {
      name: "Bob",
      age: 33
    },
    {
      name: "Steve",
      age: 22
    },
    {
      name: "John",
      age: 45
    }
  ]


  setCurrentSort(option) {
    this.currentSort = option;
  }

  @computedFrom('currentSort', 'people')
  get sortedPeople() {
    const people =  this.people.slice();
    return people.sort((cardA, cardB) => {
			if (cardA[this.currentSort] < cardB[this.currentSort]) {
				return -1;
			} else if (cardA[this.currentSort] > cardB[this.currentSort]) {
				return 1;
			}
			return 0;
		})
  }


}

Another key here was to also create a shallow copy of the array using “slice” in the computed (so we didn’t modify the original array while sorting).

The only html changes were to repeat on “sortedPeople” rather than “people” and a change for how the sort click works (just sets the currentSort property now)

I don’t know why a computed property that returns an array behaves differently than just an array but it was certainly a useful workaround for our situation, hopefully this helps someone else.


#8

The difference is now it uses ComputedObservation, not CollectionObservation.

When computed property changes (because dependent currentSort changed), repeater re-renders against a brand new array, instead of walks through array changes (delete/insert).

This runs code path of ArrayRepeatStrategy's instanceChanged(...) instead of instanceMutated(...).

This can be approved with following patch which always use instanceChanged to re-render.

import {ArrayRepeatStrategy} from 'aurelia-templating-resources';
ArrayRepeatStrategy.prototype.instanceMutated = function (repeat, array, splices) {
  return this.instanceChanged(repeat, array);
}

Append this patch to your main.ts, you will see now your original code works without computed property trick.

But I am not clear why instanceMutated is not as good as instanceChanged in terms of the efficiency of reusing views.

From what I read, my guess is in instanceChanged, it processes the array in one shot, there is no viewsToRemove, so all views are retained without going through full lifecycle callbacks. In instanceMutated, it processes splices (changes) in a chain of promises, means it removes view (delete item) then adds view (insert item) which exercises full lifecycle callbacks on some views.

@fkleuver is it possible to merge two splices (remove and add) into one new type splice (move)?

cc @bigopon


#9

I’ve always tended to do sorting wherever possible via a value converter:

repeat.for="person of people | sortByName"

I’ve got a generic value converter somewhere which lets you pass the sort property and direction.

EDIT
Here it is, although it copes the array so I’m guessing any component state is going to get lost?

export class SortValueConverter {
  public toView(
    array: any[],
    propertyName: string,
    ascending: boolean = true
  ) {
    const factor = ascending ? 1 : -1;
    return !array || array.length === 0
      ? []
      : array.slice(0).sort((a, b) => {
          return (a[propertyName] - b[propertyName]) * factor;
        });
  }
}

and its used like:

repeat.for="person of people | sort:'name':false"