Willem van Gerven
Willem van Gerven

Reputation: 1597

angular async pipe not updating the view

My problem can be best described by analogy with the selectors example of the ngrx documentation to keep things simple (https://github.com/ngrx/platform/blob/master/docs/store/selectors.md#using-selectors-for-multiple-pieces-of-state).

I use the async pipe to subscribe to certain slices of state which I select using selectors, for instance

this.visibleBooks$ = this.store$.select(selectVisibleBooks)

The thing is that, if the allBooks array is "small", <100 items, my view gets updated instantly. But when it is large, >100, my view gets only updated next time change detection is triggered, for instance by scrolling. This is quite a bad user experience, to only see books once you scroll the list.

I looked at the source for async pipe (https://github.com/angular/angular/blob/master/packages/common/src/pipes/async_pipe.ts), and indeed the _updateLatestValue method calls ChangeDetectorRef.markForCheck(), which as far as I understand marks the component to be checked for changes the next time change detection is triggered.

My current way around this is by subscribing manually within the top-level component

this.store$.select(selectVisibleBooks).subscribe(cb)

and calling ChangeDetectorRef.detectChanges() manually within the callback.

I find this however unsatisfactory and would simply like async pipe to always work, no matter how large the Book[] array. Does anybody have some suggestions or a correction with which I could make things work?


edit as per request

The "books store" case above, as said, was just an analogy for the app I'm writing to keep things simple. In reality, my app renders nodes and edges of a graph, where nodes and edges also have a version attached, denoted "vnode", which together with "vedge"s span a version tree. So any graph element has its own version tree.

What I am developing currently is a search form, where we send a certain request to the backend, asking it for any nodes which match a certain set of search key/value pairs.

So, those nodes would then be rendered in a component <nodes-list>, which we pass nodes by input binding

<nodes-list [nodes]="nodes$ | async"></nodes-list>

nodes-list has change detection "on push", while the top-level <search> component has default strategy.

nodes$ is set within ngOnInit() as

this.nodes$ = this.store$.select(selectFullNodesList)

selectFullNodesList looks like this:

export const fullNodesSelector = getFullNodesSelector(createSelector(selectSearchState, s => {
    if (s.currentId) {
        const nodes = s.queries.get(s.currentId).nodes;
        if (nodes) {
            return [...nodes];
        }
    }
    return null;
}))

export const selectFullNodesList = createSelector(
    fullNodesSelector,
    (global: GlobalState) => global.data.counts,
    createSelector(selectSearchState, s => s.sort),
    (nodes, counts, sorting) => {
        if (!nodes || !counts || !sorting) return null;
        return [...nodes.sort(sorting.sortCbFactory(counts))];
    }
)

Let me explain:

selectSearchState is simply a feature selector

export const selectSearchState = createFeatureSelector<SearchState>('search');

getFullNodesSelector(...) looks like this:

function getFullNodesSelector(keyPairsSelector: MemoizedSelector<object, GraphElementKeyPair[]>): MemoizedSelector<object, INodeJSON<IVNodeJSON>[]> {
    return createSelector(
        keyPairsSelector,
        (s: GlobalState) => s.data.nodes,
        (s: GlobalState) => s.data.vnodes,
        (pairs, nodes, vnodes) => {
            if (!pairs || !nodes || !vnodes) return null;
            return pairs.map(pair => ({
                ...nodes.get(pair.key),
                _SUB: {
                    ...vnodes.get(pair.vKey)
                }
            }));
        })
}

Some comments again:

Thus, as we've subscribed to this.nodes$ with the async pipe, each time there is a new event on the stream <nodes-list> should be updated. However, in practice it appears that this depends on the size of INodeJSON<IVNodeJSON>[], and that if the array has length > ~80, we've got to trigger change detection manually by clicking somewhere or scrolling. nodes-list is refreshed automatically, as should be the case, for smaller arrays.

Upvotes: 2

Views: 4342

Answers (1)

maxime1992
maxime1992

Reputation: 23803

You'll not have any problem with large dataset. You selectors are supposed to be synchronous. Which means that when the selector is running, nothing else is happening in the background. Not matter how much time it takes to compute everything in your selector, you'll be fine. If it's taking too long, you might have a freeze in the browser but that's it.

When you say

I use ngrx-store-freeze which should guard me against that

It is not true.

It is true from the store point of view. But let's picture the following:

Your store has an array of IDs (let say users IDs).

You have a first selector called getAllUsers.
This one is just mapping over the users IDs and retrieving the correct users, right? Signature would be (usersIds: string[]): User[].

Of course here, you create a new array reference and you are not (supposed) to mutate the usersIds array.

But then, you've got an other selector. getUsersResolved which basically "resolve" foreign properties. Let say that a user has a list of animals. From the first selector you'll get a list of users and for each of them, an animalsIds properties. But what you want is to have an animals array instead. If from this selector you mutate the original array (the one coming from the first selector), ngrx-store-freeze will not throw any error, which make sense: You're mutating an array, but not the one from the store.

So how could that be a problem?

  • Your component subscribe to getUsersResolved, assign that to a variable which is then subscribed to from the view using the async pipe (let's say it's the first time in the whole app that you're subscribing to it!)
  • Your first selector getAllUsers is then called (for the first time) by getUsersResolved (also called for the first time)
  • getAllUsers creates a new array as intended and passes it to getUsersResolved. As it's the first time, even if you modify that array into the getUsersResolved, you wont have any problem: Change detection will be done as it's the first time receiving the array
  • Now imagine that your list of users **does not* change but the animals list changes. Your selector getUsersResolved will be triggered but in the case where you're not respecting immutability and modify the first array coming from getAllUsers, the array reference does not change and the change detection won't happen. And it's totally fine as that array is not part of the store, it was an array created from a selector

So I'm not sure whether your problem comes from there or not, but you might want to double check that you respect immutability within your selectors.

Eventually if you're not sure, please share the code of selectVisibleBooks, and every selectors used by it.

Upvotes: 1

Related Questions