简体   繁体   中英

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:

  • getFullNodesSelector(...) I will show below, it sits in a top-level library because we may reuse it in many features. But what it does is, it takes as an argument another selector which points to an array of node & vnode key pairs {key: number, vKey: number}[] , and turns that array into an array of nodes with their vnodes attached (see below how).
  • So as you can see, the selector we pass it selects the state of our search feature, if there is a currentId , which is the id of the current request to the backend, then we select the nodes which were the result of our current request.
  • s.queries is a light wrapper around a Javascript object, which allows me easily get/set values, clone, or add new items to a clone. This I find helpful when working with key/value stores in NGRX. Hence the s.queries.get(s.currentId).nodes .
  • global.data.counts is simply a list of how many neighbors each node has. This I want to know because I'd like to sort the nodes list by "count".
  • s.sort is which sorting of the list is currently selected.
  • Note the use of sortCbFactory , this factory simply returns the correct callback to pass to Array.sort , but I need counts to be present in the local scope of the callback because otherwise I wouldn't be able to sort by counts.
  • So, whenever nodes change (for instance a new version is referenced on the node), counts change (neighbors are added to a node) or sorting changes, the projection function is called, and a new nodes list is emitted.
  • Note that we return a fresh array after sorting.

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:

  • As you see, we pass a selector which points to an array of GraphElementKeyPair ( {key: number, vKey: number} )
  • We ask the global state for the nodes store and vnodes store
  • We map all pairs to a fresh object.
  • Note that nodes and edges are again the wrapper object mentioned earlier, which has a get method.

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.

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 .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM