Skip to content

@cubejs-client/react cubeApi.load sometimes resolves to undefined - overwriting the resultSet from useCubeQuery #10261

@johnhunter

Description

@johnhunter

Describe the bug
cubeApi.load can sometimes resolve to undefined instead of ResultSet | null

Exact steps to reproduce are hard to pin down but I'm seeing this intermittently in useCubeQuery where after a resultSet is returned the resultSet value is then subsequently reset to undefined and isLoading remains as false. This seems to occur when the Cube api returns a continueWait response but not always.

To Reproduce
Steps to reproduce the behavior:

  1. With the replacement useCubeQuery hook below
  2. Create a React application that uses several useCubeQuery instances in the same page.
  3. Ensure the Cube api query is suboptimal resulting in some continueWait responses
  4. Note calls to logger.log after using the application for some time.

Expected behavior

  • cubeApi.load should not resolve to undefined
  • useCubeQuery should not give an undefined resultSet after correctly loading data

Minimally reproducible case
I have not produced an isolated test case but have been able to reproduce in our own codebase by reproducing the useQueryQuery hook and instrumenting the undefined condition.

While difficult to reproduce this I am reporting it in case others have seen similar behaviour.

The code below is taken from the Cube react package, with minimal changes to port to TypeScript, and the instrumentation mentioned above. I also ignore undefined resolutions and only update hook state setResultSet when the result is not undefined - this resolves the problem I'm seeing.

import { useContext, useEffect, useRef, useState } from "react";
import {
  areQueriesEqual,
  type DeeplyReadonly,
  isQueryPresent,
  type ProgressResponse,
  type ProgressResult,
  type Query,
  type ResultSet,
  type UnsubscribeObj,
} from "@cubejs-client/core";
import { CubeContext, type UseCubeQueryOptions } from "@cubejs-client/react";
import logger from "@/helpers/logger";
import useDeepCompareMemoize from "./deep-compare-memoize";

/**
 * Derived from @cubejs-client/react@1.5.12 /hooks/cube-query.js
 *
 * @see https://cube.dev/docs/product/apis-integrations/javascript-sdk/reference/cubejs-client-react#usecubequery
 */
export function useCubeQuery(
  query: DeeplyReadonly<Query>,
  options: UseCubeQueryOptions = {}
) {
  const mutexRef = useRef({});
  const [currentQuery, setCurrentQuery] =
    useState<DeeplyReadonly<Query> | null>(null);
  const [isLoading, setLoading] = useState(!options.skip);
  const [resultSet, setResultSet] = useState<ResultSet | null>(null);
  const [progress, setProgress] = useState<ProgressResponse | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const context = useContext(CubeContext);

  let subscribeRequest: UnsubscribeObj | null = null;

  // @ts-expect-error invalid access to private member
  const progressCallback = ({ progressResponse }: ProgressResult): void => {
    setProgress(progressResponse);
  };

  async function fetch() {
    const { resetResultSetOnChange } = options;
    const cubeApi = options.cubeApi || context?.cubeApi;

    if (!cubeApi) {
      throw new Error("Cube API client is not provided");
    }

    if (resetResultSetOnChange) {
      setResultSet(null);
    }

    setError(null);
    setLoading(true);

    try {
      const response = await cubeApi.load(query, {
        mutexObj: mutexRef.current,
        mutexKey: "query",
        progressCallback,
        castNumerics: Boolean(
          typeof options.castNumerics === "boolean"
            ? options.castNumerics
            : context?.options?.castNumerics
        ),
      });
      if (response === undefined) {
        // Response from load should never be undefined so we will ignore and wait for a new result.
        logger.log(
          "cubeApi.load (debug) resolved to `undefined` for query",
          JSON.stringify(query)
        );
        return;
      }

      setResultSet(response);
      setProgress(null);
      setLoading(false);
    } catch (error: unknown) {
      setError(error as Error);
      setResultSet(null);
      setProgress(null);
      setLoading(false);
    }
  }

  useEffect(
    () => {
      const { skip = false, resetResultSetOnChange } = options;

      const cubeApi = options.cubeApi || context?.cubeApi;

      if (!cubeApi) {
        throw new Error("Cube API client is not provided");
      }

      async function loadQuery() {
        if (!skip && isQueryPresent(query)) {
          if (!areQueriesEqual(currentQuery, query)) {
            if (resetResultSetOnChange == null || resetResultSetOnChange) {
              setResultSet(null);
            }
            setCurrentQuery(query);
          }

          setError(null);
          setLoading(true);

          try {
            if (subscribeRequest) {
              await subscribeRequest.unsubscribe();
              // eslint-disable-next-line react-hooks/exhaustive-deps
              subscribeRequest = null;
            }

            if (options.subscribe) {
              subscribeRequest = cubeApi.subscribe(
                query,
                {
                  mutexObj: mutexRef.current,
                  mutexKey: "query",
                  progressCallback,
                },
                (e, result) => {
                  if (e) {
                    setError(e);
                  } else {
                    setResultSet(result);
                  }
                  setLoading(false);
                  setProgress(null);
                }
              );
            } else {
              await fetch();
            }
          } catch (e: unknown) {
            setError(e as Error);
            setResultSet(null);
            setLoading(false);
            setProgress(null);
          }
        }
      }

      loadQuery();

      return () => {
        if (subscribeRequest) {
          subscribeRequest.unsubscribe();
          subscribeRequest = null;
        }
      };
    },
    useDeepCompareMemoize([
      query,
      Object.keys(query?.order || {}),
      options,
      context,
    ])
  );

  return {
    isLoading,
    resultSet,
    error,
    progress,
    previousQuery: currentQuery,
    refetch: fetch,
  };
}

Version:
@cubejs-client/core@>=1.5.11

Additional context
As mentioned, this is intermittent but I have been able to reproduce consistently in both local development and production environments within our codebase.
Other than the useCubeQuery hook, I also had to copy across deep-compare-memoize to our codebase and used lodash rather than ramda for the deep compare, but I believe they are functionally equivalent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    client:coreIssues relating to the JavaScript client SDK

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions