From 9492a44b43ef023c946edcf1394349c2ef035423 Mon Sep 17 00:00:00 2001 From: Alex Lubbock Date: Fri, 8 May 2026 22:46:31 +0100 Subject: [PATCH 1/2] fix(effects): use switchMap for list-fetch effects to prevent stale data Replace mergeMap with switchMap in fetchDatasets$, fetchFacetCounts$, fetchMetadataKeys$ (datasets.effects) and fetchProposalDatasets$ (proposals.effects). With mergeMap, rapidly changing filters or sort order left multiple concurrent requests in flight. Responses arriving out of order could overwrite newer store state with stale results. switchMap cancels the previous in-flight HTTP request when a new action arrives, so only the latest query's response is ever committed to the store. The inner mergeMap calls used to dispatch multiple actions from a single HTTP response are intentionally unchanged. --- src/app/state-management/effects/datasets.effects.ts | 6 +++--- src/app/state-management/effects/proposals.effects.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/state-management/effects/datasets.effects.ts b/src/app/state-management/effects/datasets.effects.ts index 4d402ae286..62ca8f81e7 100644 --- a/src/app/state-management/effects/datasets.effects.ts +++ b/src/app/state-management/effects/datasets.effects.ts @@ -77,7 +77,7 @@ export class DatasetEffects { } return params; }), - mergeMap(({ query, limits }) => + switchMap(({ query, limits }) => this.datasetsService .datasetsControllerFullqueryV3( JSON.stringify(limits), @@ -102,7 +102,7 @@ export class DatasetEffects { ), concatLatestFrom(() => this.fullfacetParams$), map(([, params]) => params), - mergeMap(({ fields, facets }) => + switchMap(({ fields, facets }) => this.datasetsService .datasetsControllerFullfacetV3( JSON.stringify(facets), @@ -129,7 +129,7 @@ export class DatasetEffects { ofType(fromActions.fetchMetadataKeysAction), concatLatestFrom(() => this.fullqueryParams$), map(([, params]) => params), - mergeMap(({ query }) => { + switchMap(({ query }) => { return this.datasetsService .datasetsControllerMetadataKeysV3(JSON.stringify(query)) .pipe( diff --git a/src/app/state-management/effects/proposals.effects.ts b/src/app/state-management/effects/proposals.effects.ts index 8af4b26b09..ccdd293063 100644 --- a/src/app/state-management/effects/proposals.effects.ts +++ b/src/app/state-management/effects/proposals.effects.ts @@ -141,7 +141,7 @@ export class ProposalEffects { fetchProposalDatasets$ = createEffect(() => { return this.actions$.pipe( ofType(fromActions.fetchProposalDatasetsAction), - mergeMap(({ skip, limit, sortColumn, sortDirection, proposalId }) => { + switchMap(({ skip, limit, sortColumn, sortDirection, proposalId }) => { return this.datasetsService .datasetsControllerFindAllV3( JSON.stringify({ From 850fd26503c49a26eb777f7b896029ee177f71f1 Mon Sep 17 00:00:00 2001 From: Alex Lubbock Date: Fri, 8 May 2026 22:46:41 +0100 Subject: [PATCH 2/2] test(effects): add switchMap cancellation regression tests for list fetches --- .../effects/datasets.effects.spec.ts | 62 +++++++++++++++++++ .../effects/proposals.effects.spec.ts | 26 ++++++++ 2 files changed, 88 insertions(+) diff --git a/src/app/state-management/effects/datasets.effects.spec.ts b/src/app/state-management/effects/datasets.effects.spec.ts index 3b4b783b74..cfb26baa0c 100644 --- a/src/app/state-management/effects/datasets.effects.spec.ts +++ b/src/app/state-management/effects/datasets.effects.spec.ts @@ -135,6 +135,21 @@ describe("DatasetEffects", () => { const expected = cold("--b", { b: outcome }); expect(effects.fetchDatasets$).toBeObservable(expected); }); + + it("should cancel a previous in-flight request when a new action is dispatched", () => { + const datasets = [dataset]; + const action = fromActions.fetchDatasetsAction(); + const outcome = fromActions.fetchDatasetsCompleteAction({ datasets }); + + // Second action at frame 3 cancels the first request (which would complete at frame 3). + // Only the response to the second action, arriving at frame 5, is emitted. + actions = hot("-a-b", { a: action, b: action }); + const response = cold("--a|", { a: datasets }); + datasetApi.datasetsControllerFullqueryV3.and.returnValue(response); + + const expected = cold("-----b", { b: outcome }); + expect(effects.fetchDatasets$).toBeObservable(expected); + }); }); describe("fetchFacetCounts$", () => { @@ -182,6 +197,38 @@ describe("DatasetEffects", () => { const expected = cold("--b", { b: outcome }); expect(effects.fetchFacetCounts$).toBeObservable(expected); }); + + it("should cancel a previous in-flight request when a new action is dispatched", () => { + const facetCounts: FacetCounts = { + creationLocation: [], + creationTime: [], + keywords: [], + ownerGroup: [], + type: [], + }; + const action = fromActions.fetchFacetCountsAction(); + const outcome = fromActions.fetchFacetCountsCompleteAction({ + facetCounts, + allCounts: 0, + }); + const responseArray = [ + { + all: [{ totalSets: 0 }], + creationLocation: [], + creationTime: [], + keywords: [], + ownerGroup: [], + type: [], + }, + ]; + + actions = hot("-a-b", { a: action, b: action }); + const response = cold("--a|", { a: responseArray }); + datasetApi.datasetsControllerFullfacetV3.and.returnValue(response); + + const expected = cold("-----b", { b: outcome }); + expect(effects.fetchFacetCounts$).toBeObservable(expected); + }); }); describe("fetchMetadataKeys$", () => { @@ -211,6 +258,21 @@ describe("DatasetEffects", () => { const expected = cold("--b", { b: outcome }); expect(effects.fetchMetadataKeys$).toBeObservable(expected); }); + + it("should cancel a previous in-flight request when a new action is dispatched", () => { + const metadataKeys = ["test"]; + const action = fromActions.fetchMetadataKeysAction(); + const outcome = fromActions.fetchMetadataKeysCompleteAction({ + metadataKeys, + }); + + actions = hot("-a-b", { a: action, b: action }); + const response = cold("--a|", { a: metadataKeys }); + datasetApi.datasetsControllerMetadataKeysV3.and.returnValue(response); + + const expected = cold("-----b", { b: outcome }); + expect(effects.fetchMetadataKeys$).toBeObservable(expected); + }); }); describe("updateUserDatasetsLimit$", () => { diff --git a/src/app/state-management/effects/proposals.effects.spec.ts b/src/app/state-management/effects/proposals.effects.spec.ts index 50ce1c2fb0..1ae30bd8cf 100644 --- a/src/app/state-management/effects/proposals.effects.spec.ts +++ b/src/app/state-management/effects/proposals.effects.spec.ts @@ -305,6 +305,32 @@ describe("ProposalEffects", () => { const expected = cold("--b", { b: outcome }); expect(effects.fetchProposalDatasets$).toBeObservable(expected); }); + + it("should cancel a previous in-flight request when a new action is dispatched", () => { + const datasets = [dataset]; + const skip = 0; + const limit = 50; + const action = fromActions.fetchProposalDatasetsAction({ + proposalId, + skip, + limit, + }); + const outcome1 = fromActions.fetchProposalDatasetsCompleteAction({ + datasets, + skip, + limit, + }); + const outcome2 = fromActions.fetchProposalDatasetsCountAction({ + proposalId, + }); + + actions = hot("-a-b", { a: action, b: action }); + const response = cold("--a|", { a: datasets }); + datasetApi.datasetsControllerFindAllV3.and.returnValue(response); + + const expected = cold("-----(bc)", { b: outcome1, c: outcome2 }); + expect(effects.fetchProposalDatasets$).toBeObservable(expected); + }); }); describe("fetchProposalDatasetsCount$", () => {