Skip to content
Snippets Groups Projects
Unverified Commit 2fd311c5 authored by Phoomparin Mano's avatar Phoomparin Mano Committed by GitHub
Browse files

fix(sdk): static question should cancel requests on component unmount (#48808)

* cancel request on unmount in static question

* use deferred promise

* remove logs

* alias cancelled to deferred.promise

* extract useLoadStaticQuestion hook

* add tests for request cancellation

* change updateQuestion method

* add sanity check with fetchMock
parent cca66d44
No related branches found
No related tags found
No related merge requests found
import cx from "classnames"; import cx from "classnames";
import { useEffect, useState } from "react";
import { t } from "ttag"; import { t } from "ttag";
import { import {
...@@ -7,11 +6,10 @@ import { ...@@ -7,11 +6,10 @@ import {
SdkLoader, SdkLoader,
withPublicComponentWrapper, withPublicComponentWrapper,
} from "embedding-sdk/components/private/PublicComponentWrapper"; } from "embedding-sdk/components/private/PublicComponentWrapper";
import { useLoadStaticQuestion } from "embedding-sdk/hooks/private/use-load-static-question";
import { getDefaultVizHeight } from "embedding-sdk/lib/default-height"; import { getDefaultVizHeight } from "embedding-sdk/lib/default-height";
import { loadStaticQuestion } from "embedding-sdk/lib/load-static-question";
import CS from "metabase/css/core/index.css"; import CS from "metabase/css/core/index.css";
import { useValidatedEntityId } from "metabase/lib/entity-id/hooks/use-validated-entity-id"; import { useValidatedEntityId } from "metabase/lib/entity-id/hooks/use-validated-entity-id";
import type { GenericErrorResponse } from "metabase/lib/errors";
import { getResponseErrorMessage } from "metabase/lib/errors"; import { getResponseErrorMessage } from "metabase/lib/errors";
import { useSelector } from "metabase/lib/redux"; import { useSelector } from "metabase/lib/redux";
import QueryVisualization from "metabase/query_builder/components/QueryVisualization"; import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
...@@ -23,7 +21,7 @@ import { getMetadata } from "metabase/selectors/metadata"; ...@@ -23,7 +21,7 @@ import { getMetadata } from "metabase/selectors/metadata";
import { Box, Group } from "metabase/ui"; import { Box, Group } from "metabase/ui";
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode"; import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
import Question from "metabase-lib/v1/Question"; import Question from "metabase-lib/v1/Question";
import type { Card, CardEntityId, CardId, Dataset } from "metabase-types/api"; import type { CardEntityId, CardId, Dataset } from "metabase-types/api";
export type StaticQuestionProps = { export type StaticQuestionProps = {
questionId: CardId | CardEntityId; questionId: CardId | CardEntityId;
...@@ -32,13 +30,6 @@ export type StaticQuestionProps = { ...@@ -32,13 +30,6 @@ export type StaticQuestionProps = {
parameterValues?: Record<string, string | number>; parameterValues?: Record<string, string | number>;
}; };
type State = {
loading: boolean;
card: Card | null;
result: Dataset | null;
error: GenericErrorResponse | null;
};
type StaticQuestionVisualizationSelectorProps = { type StaticQuestionVisualizationSelectorProps = {
question: Question; question: Question;
result: Dataset | null; result: Dataset | null;
...@@ -87,63 +78,8 @@ const StaticQuestionInner = ({ ...@@ -87,63 +78,8 @@ const StaticQuestionInner = ({
const metadata = useSelector(getMetadata); const metadata = useSelector(getMetadata);
const [{ loading, card, result, error }, setState] = useState<State>({ const { card, loading, result, error, updateQuestion } =
loading: false, useLoadStaticQuestion(questionId, parameterValues);
card: null,
result: null,
error: null,
});
useEffect(() => {
async function loadCardData() {
setState(prevState => ({
...prevState,
loading: true,
}));
if (!questionId) {
return;
}
try {
const { card, result } = await loadStaticQuestion({
questionId,
parameterValues,
});
setState(prevState => ({
...prevState,
card,
result,
loading: false,
error: null,
}));
} catch (error) {
if (typeof error === "object") {
setState(prevState => ({
...prevState,
result: null,
card: null,
loading: false,
error,
}));
} else {
console.error("error loading static question", error);
}
}
}
loadCardData();
}, [questionId, parameterValues]);
const changeVisualization = (newQuestion: Question) => {
setState({
card: newQuestion.card(),
result: result,
loading: false,
error: null,
});
};
const isLoading = loading || (!result && !error) || isValidatingEntityId; const isLoading = loading || (!result && !error) || isValidatingEntityId;
...@@ -173,7 +109,7 @@ const StaticQuestionInner = ({ ...@@ -173,7 +109,7 @@ const StaticQuestionInner = ({
<StaticQuestionVisualizationSelector <StaticQuestionVisualizationSelector
question={question} question={question}
result={result} result={result}
onUpdateQuestion={changeVisualization} onUpdateQuestion={updateQuestion}
/> />
)} )}
<QueryVisualization <QueryVisualization
......
import { act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
...@@ -85,7 +86,7 @@ const setup = ({ ...@@ -85,7 +86,7 @@ const setup = ({
setupCardQueryEndpoints(card, TEST_DATASET); setupCardQueryEndpoints(card, TEST_DATASET);
renderWithProviders( return renderWithProviders(
<StaticQuestion <StaticQuestion
questionId={TEST_QUESTION_ID} questionId={TEST_QUESTION_ID}
showVisualizationSelector={showVisualizationSelector} showVisualizationSelector={showVisualizationSelector}
...@@ -174,4 +175,19 @@ describe("StaticQuestion", () => { ...@@ -174,4 +175,19 @@ describe("StaticQuestion", () => {
value: 1024, value: 1024,
}); });
}); });
it("should cancel the request when the component unmounts", async () => {
const abortSpy = jest.spyOn(AbortController.prototype, "abort");
const { unmount } = setup();
await act(async () => unmount());
expect(abortSpy).toHaveBeenCalled();
abortSpy.mockRestore();
// sanity check that the two requests were made initially
expect(fetchMock.calls(`path:/api/card/1`).length).toBe(1);
expect(fetchMock.calls(`path:/api/card/1/query`).length).toBe(1);
});
}); });
import { useEffect, useState } from "react";
import { loadStaticQuestion } from "embedding-sdk/lib/load-static-question";
import type { GenericErrorResponse } from "metabase/lib/errors";
import { defer } from "metabase/lib/promise";
import type Question from "metabase-lib/v1/Question";
import type { Card, Dataset } from "metabase-types/api";
type QuestionState = {
loading: boolean;
card: Card | null;
result: Dataset | null;
error: GenericErrorResponse | null;
};
export function useLoadStaticQuestion(
questionId: number | null,
parameterValues?: Record<string, string | number>,
) {
const [questionState, setQuestionState] = useState<QuestionState>({
loading: false,
card: null,
result: null,
error: null,
});
const updateQuestion = (newQuestion: Question) =>
setQuestionState(state => ({
...state,
card: newQuestion.card(),
loading: false,
error: null,
}));
useEffect(() => {
const cancelDeferred = defer();
async function loadCardData() {
setQuestionState(state => ({ ...state, loading: true }));
if (!questionId) {
return;
}
try {
const { card, result } = await loadStaticQuestion({
questionId,
parameterValues,
cancelDeferred,
});
setQuestionState({
card,
result,
loading: false,
error: null,
});
} catch (error) {
if (typeof error === "object") {
setQuestionState({
result: null,
card: null,
loading: false,
error,
});
} else {
console.error("error loading static question", error);
}
}
}
loadCardData();
return () => {
// cancel pending requests upon unmount
cancelDeferred.resolve();
};
}, [questionId, parameterValues]);
return { ...questionState, updateQuestion };
}
import type { Deferred } from "metabase/lib/promise";
import { CardApi } from "metabase/services"; import { CardApi } from "metabase/services";
import type { Card, Dataset, ParameterQueryObject } from "metabase-types/api"; import type { Card, Dataset, ParameterQueryObject } from "metabase-types/api";
interface Options { interface Options {
questionId: number; questionId: number;
parameterValues?: Record<string, string | number>; parameterValues?: Record<string, string | number>;
cancelDeferred?: Deferred;
} }
type ParameterQueryInput = { id: string } & ParameterQueryObject; type ParameterQueryInput = { id: string } & ParameterQueryObject;
export async function loadStaticQuestion(options: Options) { export async function loadStaticQuestion(options: Options) {
const { questionId, parameterValues } = options; const { questionId, parameterValues, cancelDeferred } = options;
let card: Card | null; let card: Card | null;
let result: Dataset | null; let result: Dataset | null;
const cancelled = cancelDeferred?.promise;
[card, result] = await Promise.all([ [card, result] = await Promise.all([
CardApi.get({ cardId: questionId }), CardApi.get({ cardId: questionId }, { cancelled }),
// Query the card in parallel when no parameters are provided. // Query the card in parallel when no parameters are provided.
!parameterValues && CardApi.query({ cardId: questionId }), !parameterValues && CardApi.query({ cardId: questionId }, { cancelled }),
]); ]);
if (parameterValues && card?.parameters) { if (parameterValues && card?.parameters) {
...@@ -31,10 +35,10 @@ export async function loadStaticQuestion(options: Options) { ...@@ -31,10 +35,10 @@ export async function loadStaticQuestion(options: Options) {
value: parameterValues[parameter.slug], value: parameterValues[parameter.slug],
})); }));
result = await CardApi.query({ result = await CardApi.query(
cardId: questionId, { cardId: questionId, parameters },
parameters, { cancelled },
}); );
} }
return { card, result }; return { card, result };
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment