0

I am really confused, I really thought that selectors would not run if all of his parents returned the same result.

At a time, there is 1-250 clusterMarker selector active, each with a different prop, cluster. Its execution is rather expensive. I made sure, that it needs to be reevaluated only if the result of any of its arguments changes.

Simplified example:

const offerState = createFeatureSelector<OfferState>('offer');
const currentCarrier = createSelector(offerState, state => state.currentCarrier);
const currentContext = createSelector(offerState, state => state.currentContext);
const currentPeriod = createSelector(offerState, state => state.currentPeriod);
const filteredCarriers = createSelector(offerState, state => state.filteredCarriers);
const wanted = createSelector(offerState, state => state.wanted);

const filteredCarriersSet = createSelector(
    filteredCarriers,
    carriers => new Set(carriers),
);

/**
 * Only fire if the changes to state affect this context.
 * `undefined` => `state.currentCarrier`
 * `state.currentCarrier` => `undefined`
 */
const currentCarrierInCluster = createSelector(
    currentCarrier,
    currentContext,
    (
        currentCarrier: Carrier | null,
        currentContext: AbstractMapClusterContext<Carrier> | null,
        { cluster }: { cluster: AbstractMapClusterContext<Carrier> }
    ) => currentContext == cluster ? currentCarrier : undefined,
);

export const clusterMarker = createSelector(
    filteredCarriersSet,
    currentCarrierInCluster,
    currentPeriod,
    wanted,
    (
        filteredSet,
        currentCarrier,
        currentPeriod,
        wanted,
        { cluster }: { cluster: AbstractMapClusterContext<Carrier> }
    ) => {
        // ... code ...
    },
);

Is there a part of the documentation about setting memorization options I missed? What can I do, to make this more performant?

Response to answer:

Code:

export const clusterMarkerSelectorFactory = () =>  createSelector(
    filteredCarriersSet,
    currentCarrierInCluster,
    currentPeriod,
    wanted,
    (
        filteredSet,
        currentCarrier,
        currentPeriod,
        wanted,
        { cluster }: { cluster: AbstractMapClusterContext<Carrier> }
    ) => {
        // ... code ...
    },
);

class Component {
    constructor(
        private store$: Store<OfferState>,
    ) { }

    readonly state$ = this.cluster$.pipe(
        switchMap(cluster => this.store$.select(clusterMarkerSelectorFactory(), { cluster })),
    );
}

This will still retrigger for every one of them.

Akxe
  • 9,694
  • 3
  • 36
  • 71
  • I have a suspicion what the problem is, but I am struggling to fully understand your problem and code. What does the 2nd line of `currentCarrierInCluster` do? Where do the different `cluster`s come from? Are you seeing the `...code...` section of `clusterMarker` execute every time with no memoization? Are you expecting it to execute only once per `cluster`? – wlf Jan 03 '21 at 20:18
  • I updated to the latest version. The `currentCarrierInCluster` is to be recalculated often, the `currentCarrier` and `currentContext` changes often. This selector is created so that it only fires new value when an interesting change happens to the passed `context`. – Akxe Jan 04 '21 at 11:40
  • 1
    si the problems is with running `clusterMaker`? aslo in your `currentCarrierInCluster ` where is `{cluster}` variable coming from? – Ivan Satsiuk Jan 04 '21 at 12:57
  • @IvanSatsiuk Yes, the problem is that `clusterMaker` runs too often. The `cluster` prop of `currentCarrierInCluster` selector is given by the parent selector calling it. In this case, the `clusterMarker` will pass its `props`, the `{ cluster }` to `currentCarrierInCluster`. – Akxe Jan 04 '21 at 14:46

2 Answers2

2

Memoization only remembers the single last calculated value when all inputs are the same.

In your example, currentCarrierInCluster is called with different a cluster parameter (though it is not clear how from your syntax). This renders the memoization ineffective.

Memoization works by comparing all inputs to their equivalent values the last time the selector was invoked. If they are the same, the cached value is returned, if they are different a new value is calculated via the selector's projection function.

In your case the cluster input is different, hence the projection function runs again.

You can address this by wrapping the selector inside a factory function to create different instances of the selector for each cluster input value:

const currentCarrierInCluster = (cluster: ClusterType) => createSelector(
    currentCarrier,
    currentContext,
    (
        currentCarrier: Carrier | null,
        currentContext: AbstractMapClusterContext<Carrier> | null,
        { cluster }: { cluster: AbstractMapClusterContext<Carrier> }
    ) => currentContext == cluster ? currentCarrier : undefined,
);

which can be called like:

cluster$ = this.store.select(currentCarrierInCluster(cluster));

References
https://ngrx.io/guide/store/selectors
https://stackoverflow.com/a/50375970/1188074

Further Implementation Info

NgRx Selector source code is here

Refer to the call to isArgumentsChanged on line 102 which determines if the input values have changed and, therefore whether to return the memoized result. It effectively calls the following method on each argument:

export function isEqualCheck(a: any, b: any): boolean {
  return a === b;
}

This can be overridden but would only be done in exceptional cases,

Changes required to your selector

// Note = after clusterMarker rather than after =>
export const clusterMarker = (_cluster: AbstractMapClusterContext<Carrier>) => createSelector(
    filteredCarriersSet,
    currentCarrierInCluster,
    currentPeriod,
    wanted,
    (
        filteredSet,
        currentCarrier,
        currentPeriod,
        wanted,
        /* Remove this and use _cluster instead
       { cluster }: { cluster: AbstractMapClusterContext<Carrier> } */
    ) => {
        // ... code ... 
    },
);

Call like:

this.store$.select(clusterMarker(cluster))
wlf
  • 3,086
  • 1
  • 19
  • 29
  • I tried it, but it gave me an error, see my question for more details – Akxe Jan 06 '21 at 07:13
  • Initial suggested changes added to my answer above, if you can't get that working let me know what the issue is. – wlf Jan 06 '21 at 21:55
  • @Akxe I've been looking at this further and the solution needs to be tweaked a bit, I will try and update tonight. – wlf Jan 07 '21 at 09:19
  • I got to it again, and figure out how the syntax is supposed to work. But it still did not solve my problem. It still gets re-triggered for every one of them :/ – Akxe Jan 07 '21 at 14:37
  • It is because of the fact, that every parent selector re-fires because its props change, although it never meant to use them... – Akxe Jan 07 '21 at 15:14
  • I've looked at this more but it realized it is difficult to tell exactly what your problem is. Can you create a simplified reproduction on stackblitz? You can start from ngrx template: https://stackblitz.com/edit/angular-ngrx-todos – wlf Jan 07 '21 at 21:26
  • 1
    I will give you the points, you lead me to the solution, I will however post an answer with an explanation on why it happens and how to battle against it. – Akxe Jan 08 '21 at 09:14
0

I now know why this happens, I have also had several workarounds. It all is tied to the fact that whenever a selector is called with parameters, all of its parents will be called with parameters too! I have created a GitHub issue on this.

tldr;

Original:

// replace
const elementSelectorFactory = () => createSelector(
    listAsSet,
    (set, { element }) => set.has(element),
);

Fixed v1:

// with (This needs to be done only for the first level of selectors, more info bellow)
const elementSelectorFactory = () => createSelector(
    state => listAsSet(state as any),
    (set, { element }) => set.has(element),
);

Fixed v2:

// or with
const elementSelectorFactory = element => createSelector(
    listAsSet,
    set => set.has(element),
);

// or in case you have nested props selectors (my case)
const elementSelectorFactory = element => createSelector(
    listAsSet,
    nestedSelectorFactory(element),
    (set, nested) => set.has(element) ? nested : null,
);

I will walk you through a simple selector with props.

We have code bellow

interface Store {
  elements: unknown[];
}

export const offerState = createFeatureSelector<Store>('store');

const listAsSet = createSelector(
    state => state.list,
    carriers => new Set(carriers),
);

// We create a factory like they instructed us in the docs
const elementSelectorFactory = () => createSelector(
    listAsSet,
    (set, { element }) => set.has(element),
);

Now if we call this.store$.select(elementSelectorFactory(), { element }).

  1. We create the elementSelector
  2. This selector will ask for data from its parent with the following parameters: [state, { element }].
  3. listAsSet gets called with state and props { element }, even though it does not use the props.

Now, if you were to create 2 elementSelector from the factories, step 3 will be called twice, with 2 different props! Since its arguments are not equal it must be recalculated. Set() of the same elements are not equal, thus this will invalidate arguments of our elementSelector!

What is worse, if you are using the listAsSet selector somewhere else without props, it would get invalidated too!

This was my first solution to this:

const elementSelectorFactory = () => createSelector(
    // Here we don't pass the props to the child selector, to prevent invalidating its cache
    (state, _props) => listAsSet(state as any),
    (set, { element }) => set.has(element),
);

The next solution is not to use props at all, as suggested by ngrx member...

// or with
const elementSelectorFactory = element => createSelector(
    listAsSet,
    set => set.has(element),
);

Here, the selector will never receive props, to begin with... If you want to have nested selector you will have to rewrite them all to selector factories with parameter(s) to combat this.

const elementSelectorFactory = element => createSelector(
    listAsSet,
    nestedSelectorFactory(element),
    (set, nested) => set.has(element) ? nested : null,
);
Akxe
  • 9,694
  • 3
  • 36
  • 71