Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BUG: UseLazyXXXQuery Returning undefined On Long Running Queries #3706

Closed
rwilliams3088 opened this issue Sep 9, 2023 · 4 comments · Fixed by #3709
Closed

BUG: UseLazyXXXQuery Returning undefined On Long Running Queries #3706

rwilliams3088 opened this issue Sep 9, 2023 · 4 comments · Fixed by #3709
Labels
bug Something isn't working
Milestone

Comments

@rwilliams3088
Copy link

rwilliams3088 commented Sep 9, 2023

I am in the process of optimizing my website to handle efficiently querying from tables with potentially hundreds of millions of records, and for those queries that are presently not optimized I have noticed the occasional odd error from RTK Query. In particular, I have noticed that when the queries start taking minutes to resolve (and they eventually do resolve successfully) that sometimes RTK Query returns undefined from the promise. IE...

const [trigger] = useLazyXXXQuery();
...
const query = trigger(...);
query.then(response => {
  try {
    const { field1, field2, ... } = response.data;
    ...
  } catch(e) {
    console.error(e); // => logs the error: "TypeError: response is undefined"
    console.log(`response status: ${response.status}, isSuccess? ${response.isSuccess}, isError? ${response.isError}, isLoading? ${response.isLoading}, isUninitialized? ${response.isUninitialized}`);
    console.log(`response.Error: ${JSON.stringify(response.error)}`);
  }
});

When the error occurs, here is the log trace:

TypeError: response.data is undefined
response status: uninitialized, isSuccess? false, isError? false, isLoading? false, isUninitialized? true
response.Error: undefined

NOTE: I have confirmed that the requests do resolve properly (with the expected data) by monitoring the requests via the Network tab in the Developer Tools on Firefox. Also, there are usually several similar requests being sent at once (via the 3rd party component AgGrid), but with different arguments. Some resolve successfully, others are resolving prematurely and getting the above error.


EDIT: I removed the unwrap statement and added some more trace
EDIT 2: Possibly the same bug as #3662

@rwilliams3088
Copy link
Author

rwilliams3088 commented Sep 9, 2023

After diving into the code, I realized that the useLazyQuery and useLazyQuerySubscription hooks only maintain a single promise; they are not re-entrant! I created the following useReEntrantLazyQuerySubscription hook - closely mimicking the official code - as a work-around. Now all of my requests are properly resolving no matter how many are kicked off concurrently, nor if they all take several minutes to resolve:

import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
import { QueryDefinition } from "@reduxjs/toolkit/dist/query";
import { SubscriptionOptions } from "@reduxjs/toolkit/dist/query/core/apiState";
import { QueryActionCreatorResult } from "@reduxjs/toolkit/dist/query/core/buildInitiate";
import { ApiEndpointQuery } from "@reduxjs/toolkit/dist/query/core/module";
import { LazyQueryTrigger, QueryHooks, UseLazyQuerySubscription } from "@reduxjs/toolkit/dist/query/react/buildHooks";
import { UNINITIALIZED_VALUE } from "@reduxjs/toolkit/dist/query/react/constants";
import { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { batch, shallowEqual, useDispatch } from "react-redux";

export interface ReEntrantLazyQueryArgs extends SubscriptionOptions {
  endpoint: ApiEndpointQuery<any, any> & QueryHooks<any>;
}

export function useShallowStableValue<T>(value: T) {
  const cache = useRef(value)
  useEffect(() => {
    if (!shallowEqual(cache.current, value)) {
      cache.current = value
    }
  }, [value])

  return shallowEqual(cache.current, value) ? cache.current : value
};

const unstable__sideEffectsInRender = false;
const usePossiblyImmediateEffect: (
  effect: () => void | undefined,
  deps?: DependencyList
) => void = unstable__sideEffectsInRender ? (cb) => cb() : useEffect;

// The built-in useLazyQuery hook only maintains a single promise
// This means that if you trigger multiple requests simultaneously, some of them might not be resolved
// properly... 
export function useReEntrantLazyQuerySubscription<
  QD extends QueryDefinition<any, any, any, any, any> = QueryDefinition<any, any, any, any, any>
> (
  endpoint: ApiEndpointQuery<QD, any> & QueryHooks<QD>, 
  options?: SubscriptionOptions
): readonly [LazyQueryTrigger<QD>] {
  const { 
    refetchOnReconnect = false,
    refetchOnFocus = false,
    pollingInterval = 0,
  } = options || {};

  const { initiate } = endpoint;
  const dispatch = useDispatch<ThunkDispatch<any, any, AnyAction>>()
  const promisesRef = useRef<Set<QueryActionCreatorResult<any>>>(new Set<QueryActionCreatorResult<any>>());

  const stableSubscriptionOptions = useShallowStableValue({
    refetchOnReconnect,
    refetchOnFocus,
    pollingInterval,
  });

  usePossiblyImmediateEffect(() => {
    promisesRef.current?.forEach(p => {
      const lastSubscriptionOptions = p.subscriptionOptions
      if (stableSubscriptionOptions !== lastSubscriptionOptions) {
        p.updateSubscriptionOptions(
          stableSubscriptionOptions
        );
      }
    });
  }, [stableSubscriptionOptions])

  const subscriptionOptionsRef = useRef(stableSubscriptionOptions)
  usePossiblyImmediateEffect(() => {
    subscriptionOptionsRef.current = stableSubscriptionOptions
  }, [stableSubscriptionOptions])

  const trigger = useCallback(
    function (arg: any, preferCacheValue = false) {
      let promise: QueryActionCreatorResult<QD>

      batch(() => {
        promise = dispatch(
          initiate(arg, {
            subscriptionOptions: subscriptionOptionsRef.current,
            forceRefetch: !preferCacheValue,
          })
        );

        promisesRef.current.add(promise);
        
        promise.then(
          response => { promisesRef.current.delete(promise); return response; },
          reason => { promisesRef.current.delete(promise); throw reason; }
        );
      });

      return promise!
    },
    [dispatch, initiate]
  );

  /* cleanup on unmount */
  useEffect(() => {
    return () => {
      promisesRef?.current?.forEach(p => p.unsubscribe());
    }
  }, [])

  /* if "cleanup on unmount" was triggered from a fast refresh, we want to reinstate the query 
  useEffect(() => {
    if (arg !== UNINITIALIZED_VALUE && !promiseRef.current) {
      trigger(arg, true)
    }
  }, [arg, trigger])
  */

  return useMemo(() => [trigger] as const, [trigger])
};

This might be a good hook to add to the repository + document the re-entrant behavior of the useLazyQuery and useLazyQuerySubscription hooks. This one doesn't maintain the lastArgs, but that wouldn't really make sense anyways if you are submitting simultaneous requests.

@phryneas
Copy link
Member

phryneas commented Sep 9, 2023

Interesting.

I think a full-on solution might need a change in initiate though - to add an additional subscription until the request is done, independently of the external subscription.

@phryneas phryneas added the bug Something isn't working label Sep 9, 2023
@markerikson markerikson added this to the 1.9.x milestone Sep 9, 2023
@markerikson markerikson modified the milestones: 1.9.x, 2.0 Oct 27, 2023
@markerikson
Copy link
Collaborator

Can you try the latest CodeSandbox CSB build from #3089 and see if this is resolved?

@markerikson
Copy link
Collaborator

I'm going to assume that this is fixed by #3709 , and that fix is available in https://github.com/reduxjs/redux-toolkit/releases/tag/v2.0.0-beta.4 . Please let us know if there's further issues!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants