diff --git a/static/js/stat_var_hierarchy/stat_var_group_node.tsx b/static/js/stat_var_hierarchy/stat_var_group_node.tsx index 49ae941885..561ca905bb 100644 --- a/static/js/stat_var_hierarchy/stat_var_group_node.tsx +++ b/static/js/stat_var_hierarchy/stat_var_group_node.tsx @@ -100,6 +100,8 @@ export class StatVarGroupNode extends React.Component< context: ContextType; // the list of entities for which data fetch has begun, but not finished. dataFetchingEntities: NamedNode[]; + // Abort controller to cancel any in-flight requests on re-render + private _abortController: AbortController; constructor(props: StatVarGroupNodePropType) { super(props); @@ -130,6 +132,13 @@ export class StatVarGroupNode extends React.Component< } } + componentWillUnmount(): void { + if (this._abortController) { + // Cancel any existing requests on unmount + this._abortController.abort(); + } + } + componentDidUpdate(prevProps: StatVarGroupNodePropType): void { const newSelectionCount = this.getSelectionCount(); const newState = { ...this.state }; @@ -260,6 +269,13 @@ export class StatVarGroupNode extends React.Component< } private fetchData(): void { + if (this._abortController) { + // Cancel any existing requests + this._abortController.abort(); + } + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const entityList = this.props.entities; this.dataFetchingEntities = this.props.entities; let numEntitiesExistence = this.props.numEntitiesExistence; @@ -272,11 +288,15 @@ export class StatVarGroupNode extends React.Component< numEntitiesExistence = entityDcids.length; } axios - .post("/api/variable-group/info", { - dcid: this.props.data.id, - entities: entityDcids, - numEntitiesExistence, - }) + .post( + "/api/variable-group/info", + { + dcid: this.props.data.id, + entities: entityDcids, + numEntitiesExistence, + }, + { signal } + ) .then((resp) => { const data = resp.data; const childSV: StatVarInfo[] = data["childStatVars"] || []; @@ -290,7 +310,11 @@ export class StatVarGroupNode extends React.Component< }); } }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } this.dataFetchingEntities = null; if (_.isEqual(entityList, this.props.entities)) { this.setState({ diff --git a/static/js/stat_var_hierarchy/stat_var_hierarchy.tsx b/static/js/stat_var_hierarchy/stat_var_hierarchy.tsx index 710b0d273a..f209299e9f 100644 --- a/static/js/stat_var_hierarchy/stat_var_hierarchy.tsx +++ b/static/js/stat_var_hierarchy/stat_var_hierarchy.tsx @@ -100,6 +100,11 @@ export class StatVarHierarchy extends React.Component< StatVarHierarchyPropType, StatVarHierarchyStateType > { + // Abort controller to cancel any in-flight data requests + private _dataAbortController: AbortController; + // Abort controller to cancel any in-flight search requests + private _searchAbortController: AbortController; + constructor(props: StatVarHierarchyPropType) { super(props); this.state = { @@ -121,6 +126,17 @@ export class StatVarHierarchy extends React.Component< this.fetchData(); } + componentWillUnmount(): void { + if (this._dataAbortController) { + // Abort any in-flight data requests + this._dataAbortController.abort(); + } + if (this._searchAbortController) { + // Abort any in-flight search requests + this._searchAbortController.abort(); + } + } + componentDidUpdate(prevProps: StatVarHierarchyPropType): void { if (this.state.searchSelectionCleared) { this.setState({ searchSelectionCleared: false }); @@ -257,6 +273,13 @@ export class StatVarHierarchy extends React.Component< } private async fetchData(): Promise { + if (this._dataAbortController) { + // Abort the previous data request + this._dataAbortController.abort(); + } + this._dataAbortController = new AbortController(); + const signal = this._dataAbortController.signal; + loadSpinner(SV_HIERARCHY_SECTION_ID); const entityList = this.props.entities.map((entity) => entity.dcid); const variableGroupInfoPromises: Promise[] = @@ -267,11 +290,15 @@ export class StatVarHierarchy extends React.Component< ? [statVarHierarchyConfigNode.dataSourceDcid] : []; return axios - .post("/api/variable-group/info", { - dcid: statVarHierarchyConfigNode.dcid, - entities: [...entityList, ...dataSourceEntities], - numEntitiesExistence: this.props.numEntitiesExistence, - }) + .post( + "/api/variable-group/info", + { + dcid: statVarHierarchyConfigNode.dcid, + entities: [...entityList, ...dataSourceEntities], + numEntitiesExistence: this.props.numEntitiesExistence, + }, + { signal } + ) .then((resp) => { return resp.data; }); @@ -283,7 +310,7 @@ export class StatVarHierarchy extends React.Component< if (this.state.svPath && sv in this.state.svPath) { svPath[sv] = this.state.svPath[sv]; } else { - statVarPathPromises.push(this.getPath(sv)); + statVarPathPromises.push(this.getPath(sv, signal)); } } } @@ -329,8 +356,12 @@ export class StatVarHierarchy extends React.Component< rootSVGs, svPath, }); - } catch { + } catch (error) { removeSpinner(SV_HIERARCHY_SECTION_ID); + // Ignore request cancellation errors + if (axios.isCancel(error) || error.name === "AbortError") { + return; + } this.setState({ errorMessage: "Error retrieving stat var group root nodes", }); @@ -338,17 +369,29 @@ export class StatVarHierarchy extends React.Component< } private onSearchSelectionChange(selection: string): void { - this.getPath(selection).then((path) => { - const searchSelectionCleared = - !_.isEmpty(this.state.focusPath) && _.isEmpty(path); - this.setState({ - focus: selection, - focusPath: path, - searchSelectionCleared, - expandedPath: searchSelectionCleared ? this.state.focusPath : [], + if (this._searchAbortController) { + this._searchAbortController.abort(); + } + this._searchAbortController = new AbortController(); + this.getPath(selection, this._searchAbortController.signal) + .then((path) => { + const searchSelectionCleared = + !_.isEmpty(this.state.focusPath) && _.isEmpty(path); + this.setState({ + focus: selection, + focusPath: path, + searchSelectionCleared, + expandedPath: searchSelectionCleared ? this.state.focusPath : [], + }); + this.togglePath(selection, path, /*isSearchSelection=*/ true); + }) + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } + console.error(error); }); - this.togglePath(selection, path, /*isSearchSelection=*/ true); - }); } // Add or remove a stat var and its path from the state. @@ -387,16 +430,22 @@ export class StatVarHierarchy extends React.Component< } // Get the path of a stat var from the hierarchy. - private getPath(sv: string): Promise { + private getPath(sv: string, signal?: AbortSignal): Promise { if (sv == "") { return Promise.resolve([]); } return axios - .get(`/api/variable/path?dcid=${encodeURIComponent(sv)}`) + .get(`/api/variable/path?dcid=${encodeURIComponent(sv)}`, { signal }) .then((resp) => { // This is to make jest test working, should find a better way to let // mock return new object each time. return _.cloneDeep(resp.data).reverse(); + }).catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } + console.error(error); }); } diff --git a/static/js/tools/map/fetcher/all_dates.ts b/static/js/tools/map/fetcher/all_dates.ts index eb7fc60668..26c49438a3 100644 --- a/static/js/tools/map/fetcher/all_dates.ts +++ b/static/js/tools/map/fetcher/all_dates.ts @@ -37,6 +37,10 @@ export function useFetchAllDates(dispatch: Dispatch): void { if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.ALL_DATES, error: null, @@ -60,6 +64,7 @@ export function useFetchAllDates(dispatch: Dispatch): void { childType: placeInfo.value.enclosedPlaceType, variable: statVar.value.dcid, }, + signal, }) .then((resp) => { const data = resp.data as ObservationDatesResponse; @@ -77,10 +82,19 @@ export function useFetchAllDates(dispatch: Dispatch): void { console.log("[Map Fetch] all dates"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching all the dates"; dispatch(action); }); + + return () => { + // Cancel the request if the component unmounts + abortController.abort(); + }; }, [ display.value.showTimeSlider, placeInfo.value.enclosingPlace.dcid, diff --git a/static/js/tools/map/fetcher/all_stat.ts b/static/js/tools/map/fetcher/all_stat.ts index 99d27944c0..a6ac4055cd 100644 --- a/static/js/tools/map/fetcher/all_stat.ts +++ b/static/js/tools/map/fetcher/all_stat.ts @@ -44,6 +44,10 @@ export function useFetchAllStat(dispatch: Dispatch): void { if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts. + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.ALL_STAT, context: { @@ -72,6 +76,7 @@ export function useFetchAllStat(dispatch: Dispatch): void { }, paramsSerializer: stringifyFn, headers: WEBSITE_SURFACE_HEADER, + signal, }) .then((resp) => { if (_.isEmpty(resp.data.data[statVar.value.dcid])) { @@ -85,10 +90,19 @@ export function useFetchAllStat(dispatch: Dispatch): void { console.log(`[Map Fetch] all stat for date: ${date}`); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching all stat data"; dispatch(action); }); + + return () => { + // Abort the request if the component unmounts. + abortController.abort(); + }; }, [ placeInfo.value.enclosingPlace.dcid, placeInfo.value.enclosedPlaceType, diff --git a/static/js/tools/map/fetcher/border_geojson.ts b/static/js/tools/map/fetcher/border_geojson.ts index e893281e33..4176d5913f 100644 --- a/static/js/tools/map/fetcher/border_geojson.ts +++ b/static/js/tools/map/fetcher/border_geojson.ts @@ -45,6 +45,10 @@ export function useFetchBorderGeoJson( if (!contextOk || !shouldShowBorder(placeInfo.value.enclosedPlaceType)) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.BORDER_GEO_JSON, error: null, @@ -59,10 +63,14 @@ export function useFetchBorderGeoJson( }, }; axios - .post("/api/choropleth/node-geojson", { - nodes: [placeInfo.value.enclosingPlace.dcid], - geoJsonProp: BORDER_GEOJSON_PROPERTY, - }) + .post( + "/api/choropleth/node-geojson", + { + nodes: [placeInfo.value.enclosingPlace.dcid], + geoJsonProp: BORDER_GEOJSON_PROPERTY, + }, + { signal } + ) .then((resp) => { if (_.isEmpty(resp.data)) { action.error = "error fetching border geo json data"; @@ -71,10 +79,19 @@ export function useFetchBorderGeoJson( } dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching border geo json data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.enclosingPlace, placeInfo.value.enclosedPlaceType, diff --git a/static/js/tools/map/fetcher/breadcrumb_denom_stat.ts b/static/js/tools/map/fetcher/breadcrumb_denom_stat.ts index f3dc31d66a..6d11560048 100644 --- a/static/js/tools/map/fetcher/breadcrumb_denom_stat.ts +++ b/static/js/tools/map/fetcher/breadcrumb_denom_stat.ts @@ -18,6 +18,7 @@ * Fetch the breadcrumb (parent places) denominator stat data */ +import axios from "axios"; import _ from "lodash"; import { Dispatch, useContext, useEffect } from "react"; @@ -43,6 +44,10 @@ export function useFetchBreadcrumbDenomStat( const placeDcids = placeInfo.value.parentPlaces.map((x) => x.dcid); placeDcids.push(placeInfo.value.selectedPlace.dcid); + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.BREADCRUMB_DENOM_STAT, context: { @@ -67,7 +72,8 @@ export function useFetchBreadcrumbDenomStat( [statVar.value.denom], null, // facetIds null, // highlightFacet - WEBSITE_SURFACE + WEBSITE_SURFACE, + signal ) .then((resp) => { if (_.isEmpty(resp.data[statVar.value.denom])) { @@ -81,10 +87,19 @@ export function useFetchBreadcrumbDenomStat( console.log("[Map Fetch] breadcrumb denom stat"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching breadcrumb denom stat data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.selectedPlace.dcid, placeInfo.value.parentPlaces, diff --git a/static/js/tools/map/fetcher/breadcrumb_stat.ts b/static/js/tools/map/fetcher/breadcrumb_stat.ts index 7b33b64ba4..e6c3b87f9b 100644 --- a/static/js/tools/map/fetcher/breadcrumb_stat.ts +++ b/static/js/tools/map/fetcher/breadcrumb_stat.ts @@ -47,6 +47,10 @@ export function useFetchBreadcrumbStat( const placeDcids = placeInfo.value.parentPlaces.map((x) => x.dcid); placeDcids.push(placeInfo.value.selectedPlace.dcid); + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.BREADCRUMB_STAT, context: { @@ -75,6 +79,7 @@ export function useFetchBreadcrumbStat( }, paramsSerializer: stringifyFn, headers: WEBSITE_SURFACE_HEADER, + signal, }) .then((resp) => { if (_.isEmpty(resp.data.data[statVar.value.dcid])) { @@ -88,10 +93,19 @@ export function useFetchBreadcrumbStat( console.log(`[Map Fetch] breadcrumb stat for date: ${date}`); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching breadcrumb stat data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.selectedPlace.dcid, placeInfo.value.parentPlaces, diff --git a/static/js/tools/map/fetcher/default_stat.ts b/static/js/tools/map/fetcher/default_stat.ts index 46942805e1..2701399182 100644 --- a/static/js/tools/map/fetcher/default_stat.ts +++ b/static/js/tools/map/fetcher/default_stat.ts @@ -18,6 +18,7 @@ * Fetch the default (best available) stat data */ +import axios from "axios"; import _ from "lodash"; import { Dispatch, useContext, useEffect } from "react"; @@ -40,6 +41,9 @@ export function useFetchDefaultStat( if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts. + const abortController = new AbortController(); + const signal = abortController.signal; const action: ChartStoreAction = { type: ChartDataType.DEFAULT_STAT, @@ -67,7 +71,9 @@ export function useFetchDefaultStat( date, null, // alignedVariables null, // facetIds - WEBSITE_SURFACE + WEBSITE_SURFACE, + undefined, // facetSelector + signal ) .then((resp) => { if (_.isEmpty(resp.data[statVar.value.dcid])) { @@ -81,10 +87,19 @@ export function useFetchDefaultStat( console.log(`[Map Fetch] default stat for date: ${date}`); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching default stat data"; dispatch(action); }); + + return () => { + // Abort the request if the component unmounts. + abortController.abort(); + }; }, [ dateCtx.value, placeInfo.value.enclosingPlace.dcid, diff --git a/static/js/tools/map/fetcher/denom_stat.ts b/static/js/tools/map/fetcher/denom_stat.ts index ce39f099a8..8d24fc8152 100644 --- a/static/js/tools/map/fetcher/denom_stat.ts +++ b/static/js/tools/map/fetcher/denom_stat.ts @@ -18,6 +18,7 @@ * Fetch the stat data for denominator stat var */ +import axios from "axios"; import _ from "lodash"; import { Dispatch, useContext, useEffect } from "react"; @@ -37,6 +38,10 @@ export function useFetchDenomStat(dispatch: Dispatch): void { if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.DENOM_STAT, error: null, @@ -59,7 +64,8 @@ export function useFetchDenomStat(dispatch: Dispatch): void { placeInfo.value.enclosedPlaceType, [statVar.value.denom], null, // facetIds - WEBSITE_SURFACE + WEBSITE_SURFACE, + signal ) .then((resp) => { if (_.isEmpty(resp.data[statVar.value.denom])) { @@ -73,10 +79,19 @@ export function useFetchDenomStat(dispatch: Dispatch): void { console.log("[Map Fetch] denom stat"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching denom stat data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.enclosingPlace.dcid, placeInfo.value.enclosedPlaceType, diff --git a/static/js/tools/map/fetcher/european_countries.ts b/static/js/tools/map/fetcher/european_countries.ts index 927e3efc02..e31c4c0021 100644 --- a/static/js/tools/map/fetcher/european_countries.ts +++ b/static/js/tools/map/fetcher/european_countries.ts @@ -18,6 +18,7 @@ * Fetch european countries. */ +import axios from "axios"; import { useEffect, useState } from "react"; import { EUROPE_NAMED_TYPED_PLACE } from "../../../shared/constants"; @@ -27,12 +28,24 @@ import { getEnclosedPlacesPromise } from "../../../utils/place_utils"; export function useFetchEuropeanCountries(): Array { const [data, setData] = useState>(); useEffect(() => { - getEnclosedPlacesPromise(EUROPE_NAMED_TYPED_PLACE.dcid, "Country").then( - (resp: Array) => { + const abortController = new AbortController(); + const signal = abortController.signal; + + getEnclosedPlacesPromise(EUROPE_NAMED_TYPED_PLACE.dcid, "Country", signal) + .then((resp: Array) => { setData(resp); console.log("[Map Fetch] european countries"); - } - ); + }) + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + return; + } + console.error("Error fetching European countries:", error); + }); + + return () => { + abortController.abort(); + }; }, []); return data; } diff --git a/static/js/tools/map/fetcher/geojson.ts b/static/js/tools/map/fetcher/geojson.ts index 784b721c1c..5a1137955c 100644 --- a/static/js/tools/map/fetcher/geojson.ts +++ b/static/js/tools/map/fetcher/geojson.ts @@ -34,6 +34,10 @@ export function useFetchGeoJson(dispatch: Dispatch): void { if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.GEO_JSON, error: null, @@ -53,6 +57,7 @@ export function useFetchGeoJson(dispatch: Dispatch): void { placeDcid: placeInfo.value.enclosingPlace.dcid, placeType: placeInfo.value.enclosedPlaceType, }, + signal, }) .then((resp) => { if (_.isEmpty(resp.data)) { @@ -63,10 +68,19 @@ export function useFetchGeoJson(dispatch: Dispatch): void { console.log("[Map Fetch] geojson"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching geo json data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.enclosingPlace.dcid, placeInfo.value.enclosedPlaceType, diff --git a/static/js/tools/map/fetcher/map_point_coordinate.ts b/static/js/tools/map/fetcher/map_point_coordinate.ts index 83d53486bc..f7fa2372f3 100644 --- a/static/js/tools/map/fetcher/map_point_coordinate.ts +++ b/static/js/tools/map/fetcher/map_point_coordinate.ts @@ -36,6 +36,10 @@ export function useFetchMapPointCoordinate( if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.MAP_POINT_COORDINATE, error: null, @@ -56,6 +60,7 @@ export function useFetchMapPointCoordinate( placeType: placeInfo.value.mapPointPlaceType, }, paramsSerializer: stringifyFn, + signal, }) .then((resp) => { if (resp.status !== 200) { @@ -66,10 +71,19 @@ export function useFetchMapPointCoordinate( console.log("[Map Fetch] map point coordinate"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching map point coordinate data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ placeInfo.value.enclosingPlace.dcid, placeInfo.value.mapPointPlaceType, diff --git a/static/js/tools/map/fetcher/map_point_stat.ts b/static/js/tools/map/fetcher/map_point_stat.ts index 5723a2b20c..19fea042ec 100644 --- a/static/js/tools/map/fetcher/map_point_stat.ts +++ b/static/js/tools/map/fetcher/map_point_stat.ts @@ -18,6 +18,7 @@ * Fetch the map point stat data. */ +import axios from "axios"; import _ from "lodash"; import { Dispatch, useContext, useEffect } from "react"; @@ -40,6 +41,10 @@ export function useFetchMapPointStat( if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.MAP_POINT_STAT, error: null, @@ -68,7 +73,9 @@ export function useFetchMapPointStat( date, null, // alignedVariables null, // facetIds - WEBSITE_SURFACE + WEBSITE_SURFACE, + undefined, // facetSelector + signal ) .then((resp) => { if (_.isEmpty(resp.data[usedSV])) { @@ -82,10 +89,19 @@ export function useFetchMapPointStat( console.log(`[Map Fetch] map point stat for: ${date}`); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching map point stat data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [ dateCtx.value, placeInfo.value.enclosingPlace.dcid, diff --git a/static/js/tools/map/fetcher/stat_var_summary.ts b/static/js/tools/map/fetcher/stat_var_summary.ts index 1622bc55e8..9363bcb69a 100644 --- a/static/js/tools/map/fetcher/stat_var_summary.ts +++ b/static/js/tools/map/fetcher/stat_var_summary.ts @@ -36,6 +36,10 @@ export function useFetchStatVarSummary( if (!contextOk) { return; } + // Use AbortController to cancel the request if the component unmounts + const abortController = new AbortController(); + const signal = abortController.signal; + const action: ChartStoreAction = { type: ChartDataType.STAT_VAR_SUMMARY, error: null, @@ -51,6 +55,7 @@ export function useFetchStatVarSummary( dcids: [statVar.value.dcid], }, paramsSerializer: stringifyFn, + signal, }) .then((resp) => { if (_.isEmpty(resp.data)) { @@ -61,9 +66,18 @@ export function useFetchStatVarSummary( console.log("[Map Fetch] stat var summary"); dispatch(action); }) - .catch(() => { + .catch((error) => { + if (axios.isCancel(error) || error.name === "AbortError") { + // Ignore abort errors + return; + } action.error = "error fetching stat var summary data"; dispatch(action); }); + + return () => { + // Cancel request if component unmounts + abortController.abort(); + }; }, [statVar.value.dcid, display.value.showTimeSlider, dispatch]); } diff --git a/static/js/tools/scatter/app.test.tsx b/static/js/tools/scatter/app.test.tsx index 8560b3f41c..ec16ac4683 100644 --- a/static/js/tools/scatter/app.test.tsx +++ b/static/js/tools/scatter/app.test.tsx @@ -285,16 +285,20 @@ function mockAxios(): void { }; when(axios.get) - .calledWith("/api/observations/point/within", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_Person_Employed"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_Person_Employed"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -304,16 +308,20 @@ function mockAxios(): void { }, }); when(axios.get) - .calledWith("/api/observations/point/within", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_HousingUnit"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_HousingUnit"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -323,16 +331,20 @@ function mockAxios(): void { }, }); when(axios.get) - .calledWith("/api/observations/point/within", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_Establishment"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_Establishment"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -342,16 +354,20 @@ function mockAxios(): void { }, }); when(axios.get) - .calledWith("/api/observations/point/within/all", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_Person_Employed"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within/all", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_Person_Employed"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -361,16 +377,20 @@ function mockAxios(): void { }, }); when(axios.get) - .calledWith("/api/observations/point/within/all", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_HousingUnit"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within/all", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_HousingUnit"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -380,16 +400,20 @@ function mockAxios(): void { }, }); when(axios.get) - .calledWith("/api/observations/point/within/all", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_Establishment"], - date: "", - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/point/within/all", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_Establishment"], + date: "", + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -400,14 +424,18 @@ function mockAxios(): void { }); when(axios.get) - .calledWith("/api/observations/point", { - params: { - variables: ["Count_Person"], - entities: ["geoId/10001", "geoId/10003", "geoId/10005"], + .calledWith( + "/api/observations/point", + { + params: { + variables: ["Count_Person"], + entities: ["geoId/10001", "geoId/10003", "geoId/10005"], + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -430,15 +458,19 @@ function mockAxios(): void { }); when(axios.get) - .calledWith("/api/observations/series/within", { - params: { - parentEntity: "geoId/10", - childType: "County", - variables: ["Count_Person"], - }, - paramsSerializer: stringifyFn, - headers: WEBSITE_SURFACE_HEADER, - }) + .calledWith( + "/api/observations/series/within", + { + params: { + parentEntity: "geoId/10", + childType: "County", + variables: ["Count_Person"], + }, + paramsSerializer: stringifyFn, + headers: WEBSITE_SURFACE_HEADER, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { data: { @@ -509,107 +541,159 @@ function mockAxios(): void { }); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: [], - numEntitiesExistence: 0, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: [], + numEntitiesExistence: 0, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10001", "geoId/10003", "geoId/10005"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10001", "geoId/10003", "geoId/10005"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10001", "geoId/10005", "geoId/10003"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10001", "geoId/10005", "geoId/10003"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10003", "geoId/10001", "geoId/10005"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10003", "geoId/10001", "geoId/10005"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10003", "geoId/10005", "geoId/10001"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10003", "geoId/10005", "geoId/10001"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10005", "geoId/10003", "geoId/10001"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10005", "geoId/10003", "geoId/10001"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: ["geoId/10005", "geoId/10001", "geoId/10003"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: ["geoId/10005", "geoId/10001", "geoId/10003"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(rootGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10001", "geoId/10003", "geoId/10005"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10001", "geoId/10003", "geoId/10005"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10001", "geoId/10005", "geoId/10003"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10001", "geoId/10005", "geoId/10003"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10003", "geoId/10001", "geoId/10005"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10003", "geoId/10001", "geoId/10005"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10003", "geoId/10005", "geoId/10001"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10003", "geoId/10005", "geoId/10001"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10005", "geoId/10003", "geoId/10001"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10005", "geoId/10003", "geoId/10001"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.post) - .calledWith("/api/variable-group/info", { - dcid: "dc/g/Demographics", - entities: ["geoId/10005", "geoId/10001", "geoId/10003"], - numEntitiesExistence: 1, - }) + .calledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Demographics", + entities: ["geoId/10005", "geoId/10001", "geoId/10003"], + numEntitiesExistence: 1, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue(demographicsGroupsData); when(axios.get) @@ -625,28 +709,41 @@ function mockAxios(): void { .mockResolvedValue(statVarInfoData); when(axios.get) - .calledWith("/api/variable/path?dcid=Count_Establishment") + .calledWith( + "/api/variable/path?dcid=Count_Establishment", + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: pathsData.Count_Establishment }); when(axios.get) - .calledWith("/api/variable/path?dcid=Count_HousingUnit") + .calledWith( + "/api/variable/path?dcid=Count_HousingUnit", + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: pathsData.Count_HousingUnit }); when(axios.get) - .calledWith("/api/variable/path?dcid=Count_Person_Employed") + .calledWith( + "/api/variable/path?dcid=Count_Person_Employed", + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: pathsData.Count_Person_Employed }); when(axios.get) - .calledWith("/api/variable/info", { - params: { - dcids: [ - "Count_Person_Employed", - "Count_HousingUnit", - "Count_Establishment", - ], - }, - paramsSerializer: stringifyFn, - }) + .calledWith( + "/api/variable/info", + { + params: { + dcids: [ + "Count_Person_Employed", + "Count_HousingUnit", + "Count_Establishment", + ], + }, + paramsSerializer: stringifyFn, + }, + expect.anything() // mock abort signal + ) .mockResolvedValue({ data: { Count_Person_Employed: { @@ -677,7 +774,10 @@ function mockAxios(): void { }); when(axios.get) - .calledWith("/api/choropleth/geojson?placeDcid=geoId/10&placeType=County") + .calledWith( + "/api/choropleth/geojson?placeDcid=geoId/10&placeType=County", + expect.anything() // mock abort signal + ) .mockResolvedValue({ features: [], type: "FeatureCollection", @@ -709,11 +809,15 @@ test("all functionalities", async () => { mockAxios(); const app = mount(); await waitFor(() => { - expect(axios.post).toHaveBeenCalledWith("/api/variable-group/info", { - dcid: "dc/g/Root", - entities: [], - numEntitiesExistence: 0, - }); + expect(axios.post).toHaveBeenCalledWith( + "/api/variable-group/info", + { + dcid: "dc/g/Root", + entities: [], + numEntitiesExistence: 0, + }, + expect.anything() // mock abort signal + ); }); await app.update(); diff --git a/static/js/utils/data_fetch_utils.ts b/static/js/utils/data_fetch_utils.ts index 4d975698c5..4effedba04 100644 --- a/static/js/utils/data_fetch_utils.ts +++ b/static/js/utils/data_fetch_utils.ts @@ -163,12 +163,19 @@ async function selectFacet( entities: string[], variables: string[], facetSelector?: FacetSelectionCriteria, - surface?: string + surface?: string, + signal?: AbortSignal ): Promise { if (!facetSelector) { return []; } - const facetsResponse = await getFacets(apiRoot, entities, variables, surface); + const facetsResponse = await getFacets( + apiRoot, + entities, + variables, + surface, + signal + ); for (const svDcid of Object.keys(facetsResponse)) { const matchingFacets = findMatchingFacets( facetsResponse[svDcid], @@ -206,11 +213,12 @@ export function getPoint( alignedVariables?: string[][], facetSelector?: FacetSelectionCriteria, facetIds?: string[], - surface?: string + surface?: string, + signal?: AbortSignal ): Promise { const facetPromise = !_.isEmpty(facetIds) ? Promise.resolve(facetIds) - : selectFacet(apiRoot, entities, variables, facetSelector, surface); + : selectFacet(apiRoot, entities, variables, facetSelector, surface, signal); return facetPromise.then((resolvedFacetIds) => { const params: Record = { date, entities, variables }; @@ -225,6 +233,7 @@ export function getPoint( params, paramsSerializer: stringifyFn, headers: getSurfaceHeader(surface), + signal, }) .then((resp) => { return getProcessedPointResponse(resp.data, alignedVariables); @@ -256,11 +265,19 @@ export function getPointWithin( alignedVariables?: string[][], facetIds?: string[], surface?: string, - facetSelector?: FacetSelectionCriteria + facetSelector?: FacetSelectionCriteria, + signal?: AbortSignal ): Promise { const facetPromise = !_.isEmpty(facetIds) ? Promise.resolve(facetIds) - : selectFacet(apiRoot, [parentEntity], variables, facetSelector, surface); + : selectFacet( + apiRoot, + [parentEntity], + variables, + facetSelector, + surface, + signal + ); return facetPromise.then((resolvedFacetIds) => { const params = { childType, date, parentEntity, variables }; @@ -275,6 +292,7 @@ export function getPointWithin( params, paramsSerializer: stringifyFn, headers: getSurfaceHeader(surface), + signal, }) .then((resp) => { return getProcessedPointResponse(resp.data, alignedVariables); @@ -302,11 +320,12 @@ export function getSeries( variables: string[], facetIds?: string[], facetSelector?: FacetSelectionCriteria, - surface?: string + surface?: string, + signal?: AbortSignal ): Promise { const params = { entities, variables }; return Promise.resolve( - selectFacet(apiRoot, entities, variables, facetSelector, surface) + selectFacet(apiRoot, entities, variables, facetSelector, surface, signal) ).then((resolvedFacetIds) => { if (!_.isEmpty(facetIds)) { params["facetIds"] = facetIds; @@ -317,6 +336,7 @@ export function getSeries( return axios .post(`${apiRoot || ""}/api/observations/series`, params, { headers: getSurfaceHeader(surface), + signal, }) .then((resp) => resp.data); }); @@ -341,7 +361,8 @@ export function getSeriesWithin( childType: string, variables: string[], facetIds?: string[], - surface?: string + surface?: string, + signal?: AbortSignal ): Promise { const params = { parentEntity, childType, variables }; if (facetIds) { @@ -352,6 +373,7 @@ export function getSeriesWithin( params, paramsSerializer: stringifyFn, headers: getSurfaceHeader(surface), + signal, }) .then((resp) => resp.data); } @@ -419,13 +441,15 @@ export function getFacets( apiRoot: string, entities: string[], variables: string[], - surface?: string + surface?: string, + signal?: AbortSignal ): Promise { return axios .get(`${apiRoot || ""}/api/facets`, { params: { entities, variables }, paramsSerializer: stringifyFn, headers: getSurfaceHeader(surface), + signal, }) .then((resp) => { const respData = resp.data;