vue.jsvuejs3vue-composition-apipinia

Computed is still dirty after getter evaluation, with dependency being set asyncronously


I have a pinia store which loads data on demand from a database (specifically Firestore, but I don't think it matters).

When the data is queried, it sets a value into a reactive Map ({ id: <id>, loaded: false }). When the database query returns (via a promise) it sets the same map value to the data loaded from the database. At the same time, a computed ref is returned to the initial call site, returning a db value if the data is present (loaded == true), or the id if not.

This works, but I have been getting the Computed is still dirty after getter evaluation, likely because a computed is mutating its own dependency in its getter warning, presumably because the promise is resolving while the computed property is calculating.

Is there any way to suppress this warning, as I want the computed property to recalculate when the data changes, and it will happen a maximum of once for each entry in the database that is queried.

The code for my store is below (typescript)

export const usePlayerStore = defineStore("player", () => {
  const db = getFirestore();

  const loadedPlayers = ref(new Map<string, LoadedPlayer>());
  const subscriptions = new Map<string, Unsubscribe>();
  const loadPlayer = (playerId: string) => {
    if (!subscriptions.has(playerId)) {
      subscriptions.set(
        playerId,
        onSnapshot(doc(db, "players", playerId), (snapshot) => {
          if (snapshot.exists()) {
            const p = new LoadedPlayer(playerId, snapshot.data() as DBPlayer);
            // Warning caused by this line
            loadedPlayers.value.set(playerId, p);
          } else {
            loadedPlayers.value.delete(playerId);
          }
        }),
      );
    }
  };
  const playerName = (playerId: string) => {
    loadPlayer(playerId);
    // Warning caused by this computed property
    return computed(() => {
      const p = loadedPlayers.value.get(playerId);
      if (p) {
        return p.name;
      }
      return playerId;
    });
  };
  const getPlayer = (playerId: string): Ref<Player> => {
    loadPlayer(playerId);
    return computed(
      () =>
        (loadedPlayers.value.get(playerId) as LoadedPlayer | undefined) ?? {
          id: playerId,
          loaded: false,
          name: playerId,
        },
    );
  };

  return {
    loadPlayer,
    playerName,
    getPlayer,
  };
});

Sample use (a table with a column for each player): Written using Vue JSX

export default defineComponent({
  props: {
    players: { type: Array as PropType<string[]>, required: true },
  },
  setup: (props) => {
    const playerStore = usePlayerStore();
    return () => <table>
      <thead>
        <tr>
          <td>&nbsp;</td>
          {props.players.map(pid => <td class="playerName" data-player-id={pid}>{playerStore.playerName(pid).value}</td>)}
        </tr>
      </thead>
      <tbody>
        {/* Dynamic table body content here */}
      </tbody>
    </table>;
  }
})

Solution

  • The computeds are misused, they are supposed to be defined once on store initialization, they shouldn't be called multiple times with return computed.

    A parametrized computed could be defined like:

    const getPlayerName = computed(() => (playerId: string) => {...});
    

    But a computed needs to be separated from side effects like loadPlayer:

    loadPlayer('foo');
    
    watchEffect(() => {
      const fooName  = getPlayerName.value('foo'),
      ...
    );
    

    It also would make sense for loadPlayer to return a promise for strict control flow, this would allow the action to return a promise of a result and avoid possible race conditions resulting from a player not being loaded at the time when it's accessed.