function selector()

in packages/recoil/recoil_values/Recoil_selector.js [227:793]


function selector<T>(
  options: ReadOnlySelectorOptions<T> | ReadWriteSelectorOptions<T>,
): RecoilValue<T> {
  let recoilValue: ?RecoilValue<T> = null;

  const {key, get, cachePolicy_UNSTABLE: cachePolicy} = options;
  const set = options.set != null ? options.set : undefined; // flow
  if (__DEV__) {
    if (typeof key !== 'string') {
      throw err(
        'A key option with a unique string value must be provided when creating a selector.',
      );
    }
    if (typeof get !== 'function') {
      throw err(
        'Selectors must specify a get callback option to get the selector value.',
      );
    }
  }

  // This is every discovered dependency across all executions
  const discoveredDependencyNodeKeys = new Set();

  const cache: TreeCacheImplementation<Loadable<T>> = treeCacheFromPolicy(
    cachePolicy ?? {
      equality: 'reference',
      eviction: 'keep-all',
    },
    key,
  );

  const retainedBy = retainedByOptionWithDefault(options.retainedBy_UNSTABLE);

  const executionInfoMap: Map<Store, ExecutionInfo<T>> = new Map();
  let liveStoresCount = 0;

  function selectorIsLive() {
    return !gkx('recoil_memory_managament_2020') || liveStoresCount > 0;
  }

  function selectorInit(store: Store): () => void {
    store.getState().knownSelectors.add(key);
    liveStoresCount++;
    return () => {
      liveStoresCount--;
    };
  }

  function selectorShouldDeleteConfigOnRelease() {
    return getConfigDeletionHandler(key) !== undefined && !selectorIsLive();
  }

  function resolveAsync(
    store: Store,
    state: TreeState,
    executionId: ExecutionId,
    loadable: Loadable<T>,
    depValues: DepValues,
  ): void {
    setCache(state, loadable, depValues);
    notifyStoresOfResolvedAsync(store, executionId);
  }

  function notifyStoresOfResolvedAsync(
    store: Store,
    executionId: ExecutionId,
  ): void {
    if (isLatestExecution(store, executionId)) {
      clearExecutionInfo(store);
    }
    const stores = waitingStores.get(executionId);
    if (stores !== undefined) {
      for (const waitingStore of stores) {
        markRecoilValueModified(waitingStore, nullthrows(recoilValue));
      }
      waitingStores.delete(executionId);
    }
  }

  function markStoreWaitingForResolvedAsync(
    store: Store,
    executionId: ExecutionId,
  ): void {
    let stores = waitingStores.get(executionId);
    if (stores == null) {
      waitingStores.set(executionId, (stores = new Set()));
    }
    stores.add(store);
  }

  function getCachedNodeLoadable<TT>(
    store: Store,
    state: TreeState,
    nodeKey: NodeKey,
  ): Loadable<TT> {
    const isKeyPointingToSelector = store
      .getState()
      .knownSelectors.has(nodeKey);

    /**
     * It's important that we don't bypass calling getNodeLoadable for atoms
     * as getNodeLoadable has side effects in state
     */
    if (isKeyPointingToSelector && state.atomValues.has(nodeKey)) {
      return nullthrows(state.atomValues.get(nodeKey));
    }

    const loadable = getNodeLoadable(store, state, nodeKey);

    if (loadable.state !== 'loading' && isKeyPointingToSelector) {
      state.atomValues.set(nodeKey, loadable);
    }

    return loadable;
  }

  /**
   * This function attaches a then() and a catch() to a promise that was
   * returned from a selector's get() (either explicitly or implicitly by
   * running a function that uses the "async" keyword). If a selector's get()
   * returns a promise, we have two possibilities:
   *
   * 1. The promise will resolve, in which case it will have completely finished
   *    executing without any remaining pending dependencies. No more retries
   *    are needed and we can proceed with updating the cache and notifying
   *    subscribers (if it is the latest execution, otherwise only the cache
   *    will be updated and subscriptions will not be fired). This is the case
   *    handled by the attached then() handler.
   *
   * 2. The promise will throw because it either has an error or it came across
   *    an async dependency that has not yet resolved, in which case we will
   *    call wrapDepdencyPromise(), whose responsibility is to handle dependency
   *    promises. This case is handled by the attached catch() handler.
   *
   * Both branches will eventually resolve to the final result of the selector
   * (or an error if a real error occurred).
   *
   * The execution will run to completion even if it is stale, and its value
   * will be cached. But stale executions will not update global state or update
   * executionInfo as that is the responsibility of the 'latest' execution.
   *
   * Note this function should not be passed a promise that was thrown--AKA a
   * dependency promise. Dependency promises should be passed to
   * wrapPendingDependencyPromise()).
   */
  function wrapPendingPromise(
    store: Store,
    promise: Promise<T>,
    state: TreeState,
    depValues: DepValues,
    executionId: ExecutionId,
    loadingDepsState: LoadingDepsState,
  ): Promise<T> {
    return promise
      .then(value => {
        if (!selectorIsLive()) {
          // The selector was released since the request began; ignore the response.
          clearExecutionInfo(store);
          throw CANCELED;
        }

        const loadable = loadableWithValue(value);
        resolveAsync(store, state, executionId, loadable, depValues);
        return value;
      })
      .catch(errorOrPromise => {
        if (!selectorIsLive()) {
          // The selector was released since the request began; ignore the response.
          clearExecutionInfo(store);
          throw CANCELED;
        }

        updateExecutionInfoDepValues(store, executionId, depValues);

        if (isPromise(errorOrPromise)) {
          return wrapPendingDependencyPromise(
            store,
            errorOrPromise,
            state,
            depValues,
            executionId,
            loadingDepsState,
          );
        }

        const loadable = loadableWithError(errorOrPromise);
        resolveAsync(store, state, executionId, loadable, depValues);
        throw errorOrPromise;
      });
  }

  /**
   * This function attaches a then() and a catch() to a promise that was
   * thrown from a selector's get(). If a selector's get() throws a promise,
   * we have two possibilities:
   *
   * 1. The promise will resolve, meaning one of our selector's dependencies is
   *    now available and we should "retry" our get() by running it again. This
   *    is the case handled by the attached then() handler.
   *
   * 2. The promise will throw because something went wrong with the dependency
   *    promise (in other words a real error occurred). This case is handled by
   *    the attached catch() handler. If the dependency promise throws, it is
   *    _always_ a real error and not another dependency promise (any dependency
   *    promises would have been handled upstream).
   *
   * The then() branch will eventually resolve to the final result of the
   * selector (or an error if a real error occurs), and the catch() will always
   * resolve to an error because the dependency promise is a promise that was
   * wrapped upstream, meaning it will only resolve to its real value or to a
   * real error.
   *
   * The execution will run to completion even if it is stale, and its value
   * will be cached. But stale executions will not update global state or update
   * executionInfo as that is the responsibility of the 'latest' execution.
   *
   * Note this function should not be passed a promise that was returned from
   * get(). The intention is that this function is only passed promises that
   * were thrown due to a pending dependency. Promises returned by get() should
   * be passed to wrapPendingPromise() instead.
   */
  function wrapPendingDependencyPromise(
    store: Store,
    promise: Promise<mixed>,
    state: TreeState,
    existingDeps: DepValues,
    executionId: ExecutionId,
    loadingDepsState: LoadingDepsState,
  ): Promise<T> {
    return promise
      .then(resolvedDep => {
        if (!selectorIsLive()) {
          // The selector was released since the request began; ignore the response.
          clearExecutionInfo(store);
          throw CANCELED;
        }

        // Check if we are handling a pending Recoil dependency or if the user
        // threw their own Promise to "suspend" a selector evaluation.  We need
        // to check that the loadingDepPromise actually matches the promise that
        // we caught in case the selector happened to catch the promise we threw
        // for a pending Recoil dependency from `getRecoilValue()` and threw
        // their own promise instead.
        if (
          loadingDepsState.loadingDepKey != null &&
          loadingDepsState.loadingDepPromise === promise
        ) {
          /**
           * Note for async atoms, this means we are changing the atom's value
           * in the store for the given version. This should be alright because
           * the version of state is now stale and a new version will have
           * already been triggered by the atom being resolved (see this logic
           * in Recoil_atom.js)
           */
          state.atomValues.set(
            loadingDepsState.loadingDepKey,
            loadableWithValue(resolvedDep),
          );
        } else {
          /**
           * If resolvedDepKey is not defined, the promise was a user-thrown
           * promise. User-thrown promises are an advanced feature and they
           * should be avoided in almost all cases. Using `loadable.map()` inside
           * of selectors for loading loadables and then throwing that mapped
           * loadable's promise is an example of a user-thrown promise.
           *
           * When we hit a user-thrown promise, we have to bail out of an optimization
           * where we bypass calculating selector cache keys for selectors that
           * have been previously seen for a given state (these selectors are saved in
           * state.atomValues) to avoid stale state as we have no way of knowing
           * what state changes happened (if any) in result to the promise resolving.
           *
           * Ideally we would only bail out selectors that are in the chain of
           * dependencies for this selector, but there's currently no way to get
           * a full list of a selector's downstream nodes because the state that
           * is executing may be a discarded tree (so store.getGraph(state.version)
           * will be empty), and the full dep tree may not be in the selector
           * caches in the case where the selector's cache was cleared. To solve
           * for this we would have to keep track of all running selector
           * executions and their downstream deps. Because this only covers edge
           * cases, that complexity might not be justifyable.
           */
          store.getState().knownSelectors.forEach(nodeKey => {
            state.atomValues.delete(nodeKey);
          });
        }

        /**
         * Optimization: Now that the dependency has resolved, let's try hitting
         * the cache in case the dep resolved to a value we have previously seen.
         *
         * TODO:
         * Note this optimization is not perfect because it only prevents re-executions
         * _after_ the point where an async dependency is found. Any code leading
         * up to the async dependency may have run unnecessarily. The ideal case
         * would be to wait for the async dependency to resolve first, check the
         * cache, and prevent _any_ execution of the selector if the resulting
         * value of the dependency leads to a path that is found in the cache.
         * The ideal case is more difficult to implement as it would require that
         * we capture and wait for the the async dependency right after checking
         * the cache. The current approach takes advantage of the fact that running
         * the selector already has a code path that lets us exit early when
         * an async dep resolves.
         */
        const cachedLoadable = getValFromCacheAndUpdatedDownstreamDeps(
          store,
          state,
        );
        if (cachedLoadable && cachedLoadable.state !== 'loading') {
          /**
           * This has to notify stores of a resolved async, even if there is no
           * current pending execution for the following case:
           * 1) A component renders with this pending loadable.
           * 2) The upstream dependency resolves.
           * 3) While processing some other selector it reads this one, such as
           *    while traversing its dependencies.  At this point it gets the
           *    new resolved value synchronously and clears the current
           *    execution ID.  The component wasn't getting the value itself,
           *    though, so it still has the pending loadable.
           * 4) When this code executes the current execution id was cleared
           *    and it wouldn't notify the component of the new value.
           *
           * I think this is only an issue with "early" rendering since the
           * components got their value using the in-progress execution.
           * We don't have a unit test for this case yet.  I'm not sure it is
           * necessary with recoil_concurrent_support mode.
           */
          if (
            isLatestExecution(store, executionId) ||
            getExecutionInfo(store) == null
          ) {
            notifyStoresOfResolvedAsync(store, executionId);
          }

          if (cachedLoadable.state === 'hasValue') {
            return cachedLoadable.contents;
          } else {
            throw cachedLoadable.contents;
          }
        }

        /**
         * If this execution is stale, let's check to see if there is some in
         * progress execution with a matching state. If we find a match, then
         * we can take the value from that in-progress execution. Note this may
         * sound like an edge case, but may be very common in cases where a
         * loading dependency resolves from loading to having a value (thus
         * possibly triggering a re-render), and React re-renders before the
         * chained .then() functions run, thus starting a new execution as the
         * dep has changed value. Without this check we will run the selector
         * twice (once in the new execution and once again in this .then(), so
         * this check is necessary to keep unnecessary re-executions to a
         * minimum).
         *
         * Also note this code does not check across all executions that may be
         * running. It only optimizes for the _latest_ execution per store as
         * we currently do not maintain a list of all currently running executions.
         * This means in some cases we may run selectors more than strictly
         * necessary when there are multiple executions running for the same
         * selector. This may be a valid tradeoff as checking for dep changes
         * across all in-progress executions may take longer than just
         * re-running the selector. This will be app-dependent, and maybe in the
         * future we can make the behavior configurable. An ideal fix may be
         * to extend the tree cache to support caching loading states.
         */
        if (!isLatestExecution(store, executionId)) {
          const executionInfo = getExecutionInfoOfInProgressExecution(state);
          if (executionInfo?.latestLoadable.state === 'loading') {
            /**
             * Returning promise here without wrapping as the wrapper logic was
             * already done upstream when this promise was generated.
             */
            return executionInfo.latestLoadable.contents;
          }
        }

        // Retry the selector evaluation now that the dependency has resolved
        const [loadable, depValues] = evaluateSelectorGetter(
          store,
          state,
          executionId,
        );

        updateExecutionInfoDepValues(store, executionId, depValues);

        if (loadable.state !== 'loading') {
          resolveAsync(store, state, executionId, loadable, depValues);
        }

        if (loadable.state === 'hasError') {
          throw loadable.contents;
        }
        return loadable.contents;
      })
      .catch(error => {
        // The selector was released since the request began; ignore the response.
        if (error instanceof Canceled) {
          throw CANCELED;
        }
        if (!selectorIsLive()) {
          clearExecutionInfo(store);
          throw CANCELED;
        }

        const loadable = loadableWithError(error);
        resolveAsync(store, state, executionId, loadable, existingDeps);
        throw error;
      });
  }

  function setDepsInStore(
    store: Store,
    state: TreeState,
    deps: Set<NodeKey>,
    executionId: ?ExecutionId,
  ): void {
    if (
      isLatestExecution(store, executionId) ||
      state.version === store.getState()?.currentTree?.version ||
      state.version === store.getState()?.nextTree?.version
    ) {
      saveDependencyMapToStore(
        new Map([[key, deps]]),
        store,
        store.getState()?.nextTree?.version ??
          store.getState().currentTree.version,
      );
      deps.forEach(nodeKey => discoveredDependencyNodeKeys.add(nodeKey));
    }
  }

  function evaluateSelectorGetter(
    store: Store,
    state: TreeState,
    executionId: ExecutionId,
  ): [Loadable<T>, DepValues] {
    const endPerfBlock = startPerfBlock(key); // TODO T63965866: use execution ID here
    let duringSynchronousExecution = true;
    let duringAsynchronousExecution = true;
    const finishEvaluation = () => {
      endPerfBlock();
      duringAsynchronousExecution = false;
    };

    let result;
    let resultIsError = false;
    let loadable: Loadable<T>;
    const loadingDepsState: LoadingDepsState = {
      loadingDepKey: null,
      loadingDepPromise: null,
    };

    /**
     * Starting a fresh set of deps that we'll be using to update state. We're
     * starting a new set versus adding it in existing state deps because
     * the version of state that we update deps for may be a more recent version
     * than the version the selector was called with. This is because the latest
     * execution will update the deps of the current/latest version of state (
     * this is safe to do because the fact that the selector is the latest
     * execution means the deps we discover below are our best guess at the
     * deps for the current/latest state in the store)
     */
    const depValues = new Map();
    const deps = new Set();

    function getRecoilValue<S>(dep: RecoilValue<S>): S {
      const {key: depKey} = dep;

      deps.add(depKey);
      // We need to update asynchronous dependencies as we go so the selector
      // knows if it has to restart evaluation if one of them is updated before
      // the asynchronous selector completely resolves.
      if (!duringSynchronousExecution) {
        setDepsInStore(store, state, deps, executionId);
      }

      const depLoadable = getCachedNodeLoadable(store, state, depKey);

      depValues.set(depKey, depLoadable);

      switch (depLoadable.state) {
        case 'hasValue':
          return depLoadable.contents;
        case 'hasError':
          throw depLoadable.contents;
        case 'loading':
          loadingDepsState.loadingDepKey = depKey;
          loadingDepsState.loadingDepPromise = depLoadable.contents;
          throw depLoadable.contents;
      }
      throw err('Invalid Loadable state');
    }

    const getCallback = <Args: $ReadOnlyArray<mixed>, Return>(
      fn: (SelectorCallbackInterface<T>) => (...Args) => Return,
    ): ((...Args) => Return) => {
      return (...args) => {
        if (duringAsynchronousExecution) {
          throw err(
            'Callbacks from getCallback() should only be called asynchronously after the selector is evalutated.  It can be used for selectors to return objects with callbacks that can work with Recoil state without a subscription.',
          );
        }
        invariant(recoilValue != null, 'Recoil Value can never be null');
        return recoilCallback<Args, Return, {node: RecoilState<T>}>(
          store,
          fn,
          args,
          {node: (recoilValue: any)}, // flowlint-line unclear-type:off
        );
      };
    };

    try {
      result = get({get: getRecoilValue, getCallback});
      result = isRecoilValue(result) ? getRecoilValue(result) : result;

      if (isLoadable(result)) {
        if (result.state === 'hasError') {
          resultIsError = true;
        }
        result = (result: ValueLoadableType<T>).contents;
      }

      if (isPromise(result)) {
        result = wrapPendingPromise(
          store,
          result,
          state,
          depValues,
          executionId,
          loadingDepsState,
        ).finally(finishEvaluation);
      } else {
        finishEvaluation();
      }

      result = result instanceof WrappedValue ? result.value : result;
    } catch (errorOrDepPromise) {
      result = errorOrDepPromise;

      if (isPromise(result)) {
        result = wrapPendingDependencyPromise(
          store,
          result,
          state,
          depValues,
          executionId,
          loadingDepsState,
        ).finally(finishEvaluation);
      } else {
        resultIsError = true;
        finishEvaluation();
      }
    }

    if (resultIsError) {
      loadable = loadableWithError(result);
    } else if (isPromise(result)) {
      loadable = loadableWithPromise<T>(result);
    } else {
      loadable = loadableWithValue<T>(result);
    }

    duringSynchronousExecution = false;
    setDepsInStore(store, state, deps, executionId);
    return [loadable, depValues];
  }