diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/server-data-source/document-tree.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/server-data-source/document-tree.server.request-manager.ts index e37daf5cda2e..5b21526238b0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/server-data-source/document-tree.server.request-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/server-data-source/document-tree.server.request-manager.ts @@ -2,7 +2,10 @@ import type { UmbDocumentTreeChildrenOfRequestArgs, UmbDocumentTreeRootItemsRequestArgs } from '../types.js'; import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; -import { UmbManagementApiTreeDataRequestManager } from '@umbraco-cms/backoffice/management-api'; +import { + UmbManagementApiInFlightRequestCache, + UmbManagementApiTreeDataRequestManager, +} from '@umbraco-cms/backoffice/management-api'; import type { DocumentTreeItemResponseModel, PagedDocumentTreeItemResponseModel, @@ -41,6 +44,8 @@ export class UmbManagementApiDocumentTreeDataRequestManager extends UmbManagemen UmbManagementApiDocumentTreeSiblingsFromRequestArgs, SubsetDocumentTreeItemResponseModel > { + static #inflightRequestCache = new UmbManagementApiInFlightRequestCache(); + constructor(host: UmbControllerHost) { super(host, { getRootItems: (args) => @@ -78,6 +83,8 @@ export class UmbManagementApiDocumentTreeDataRequestManager extends UmbManagemen after: args.paging.takeAfter, }, }), + + inflightRequestCache: UmbManagementApiDocumentTreeDataRequestManager.#inflightRequestCache, }); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.test.ts new file mode 100644 index 000000000000..851869895846 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.test.ts @@ -0,0 +1,106 @@ +import { UmbManagementApiTreeDataRequestManager } from './tree-data.request-manager.js'; +import type { + UmbManagementApiTreeAncestorsOfRequestArgs, + UmbManagementApiTreeChildrenOfRequestArgs, + UmbManagementApiTreeRootItemsRequestArgs, + UmbManagementApiTreeSiblingsFromRequestArgs, +} from './types.js'; +import { UmbManagementApiInFlightRequestCache } from '../inflight-request/cache.js'; +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +interface TestTreeItemModel { + parent?: { id: string } | null; +} + +@customElement('test-tree-request-manager-host') +class UmbTestTreeRequestManagerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbManagementApiTreeDataRequestManager', () => { + let hostElement: UmbTestTreeRequestManagerHostElement; + let inflightRequestCache: UmbManagementApiInFlightRequestCache; + let manager: + | UmbManagementApiTreeDataRequestManager< + TestTreeItemModel, + UmbManagementApiTreeRootItemsRequestArgs, + { items: Array; total: number }, + UmbManagementApiTreeChildrenOfRequestArgs, + { items: Array; total: number }, + UmbManagementApiTreeAncestorsOfRequestArgs, + Array, + UmbManagementApiTreeSiblingsFromRequestArgs, + { items: Array; totalBefore: number; totalAfter: number } + > + | undefined; + + const createManager = ( + getRootItems: (args: UmbManagementApiTreeRootItemsRequestArgs) => Promise<{ + data: { items: Array; total: number }; + }>, + options?: { withInflightCache?: boolean }, + ) => + new UmbManagementApiTreeDataRequestManager(hostElement, { + getRootItems, + getChildrenOf: async () => ({ data: { items: [], total: 0 } }), + getAncestorsOf: async () => ({ data: [] }), + getSiblingsFrom: async () => ({ data: { items: [], totalBefore: 0, totalAfter: 0 } }), + inflightRequestCache: options?.withInflightCache === false ? undefined : inflightRequestCache, + }); + + beforeEach(() => { + hostElement = new UmbTestTreeRequestManagerHostElement(); + document.body.appendChild(hostElement); + inflightRequestCache = new UmbManagementApiInFlightRequestCache(); + }); + + afterEach(() => { + manager?.destroy(); + manager = undefined; + document.body.innerHTML = ''; + }); + + it('coalesces concurrent identical root requests into a single underlying call', async () => { + let callCount = 0; + manager = createManager(async () => { + callCount++; + await new Promise((resolve) => setTimeout(resolve, 50)); + return { data: { items: [], total: 0 } }; + }); + + const [first, second] = await Promise.all([manager.getRootItems({}), manager.getRootItems({})]); + + expect(callCount).to.equal(1); + expect(first.data).to.deep.equal(second.data); + }); + + it('removes the in-flight entry once settled so a later identical call fetches again', async () => { + let callCount = 0; + manager = createManager(async () => { + callCount++; + return { data: { items: [], total: 0 } }; + }); + + await manager.getRootItems({}); + await manager.getRootItems({}); + + expect(callCount).to.equal(2); + expect(inflightRequestCache.has(`root:${JSON.stringify({ paging: { skip: 0, take: 50 } })}`)).to.be.false; + }); + + it('does not coalesce when no in-flight cache is provided', async () => { + let callCount = 0; + manager = createManager( + async () => { + callCount++; + await new Promise((resolve) => setTimeout(resolve, 50)); + return { data: { items: [], total: 0 } }; + }, + { withInflightCache: false }, + ); + + await Promise.all([manager.getRootItems({}), manager.getRootItems({})]); + + expect(callCount).to.equal(2); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts index c2653a26028c..544da16bc561 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts @@ -1,3 +1,4 @@ +import type { UmbManagementApiInFlightRequestCache } from '../inflight-request/cache.js'; import type { UmbManagementApiTreeAncestorsOfRequestArgs, UmbManagementApiTreeChildrenOfRequestArgs, @@ -33,6 +34,11 @@ export interface UmbManagementApiTreeDataRequestManagerArgs< getSiblingsFrom: ( args: SiblingsFromRequestArgsType, ) => Promise>; + /** + * Optional in-flight deduplication cache. When provided, concurrent identical requests + * share a single in-flight call instead of each hitting the network independently. + */ + inflightRequestCache?: UmbManagementApiInFlightRequestCache; } export class UmbManagementApiTreeDataRequestManager< @@ -50,6 +56,7 @@ export class UmbManagementApiTreeDataRequestManager< #getChildrenOf; #getAncestorsOf; #getSiblingsFrom; + #inflightRequestCache?: UmbManagementApiInFlightRequestCache; #defaultTakeSize = 50; constructor( @@ -71,6 +78,32 @@ export class UmbManagementApiTreeDataRequestManager< this.#getChildrenOf = args.getChildrenOf; this.#getAncestorsOf = args.getAncestorsOf; this.#getSiblingsFrom = args.getSiblingsFrom; + this.#inflightRequestCache = args.inflightRequestCache; + } + + // In-flight only — dedupes concurrent identical requests; entry removed on settle. + async #coalesce>( + keyFactory: () => string, + request: () => Promise, + ): Promise { + const cache = this.#inflightRequestCache; + if (!cache) { + return request(); + } + + const key = keyFactory(); + const existing = cache.get(key)?.requestPromise as Promise | undefined; + if (existing) { + return existing; + } + + const promise = request(); + cache.set(key, promise); + try { + return await promise; + } finally { + cache.delete(key); + } } async getRootItems(args: UmbTreeRootItemsRequestArgs) { @@ -96,7 +129,10 @@ export class UmbManagementApiTreeDataRequestManager< }, } as SiblingsFromRequestArgsType; - const { data: responseData, error: responseError } = await tryExecute(this, this.#getSiblingsFrom(requestArgs)); + const { data: responseData, error: responseError } = await this.#coalesce( + () => `siblings:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getSiblingsFrom(requestArgs)), + ); if (responseError) { return { data: undefined, error: responseError }; @@ -116,7 +152,10 @@ export class UmbManagementApiTreeDataRequestManager< }, } as RootItemsRequestArgsType; - const { data, error } = await tryExecute(this, this.#getRootItems(requestArgs)); + const { data, error } = await this.#coalesce( + () => `root:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getRootItems(requestArgs)), + ); const mappedData = data ? { @@ -159,7 +198,10 @@ export class UmbManagementApiTreeDataRequestManager< }, } as unknown as SiblingsFromRequestArgsType; - const { data: responseData, error: responseError } = await tryExecute(this, this.#getSiblingsFrom(requestArgs)); + const { data: responseData, error: responseError } = await this.#coalesce( + () => `siblings:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getSiblingsFrom(requestArgs)), + ); if (responseError) { return { data: undefined, error: responseError }; @@ -184,7 +226,10 @@ export class UmbManagementApiTreeDataRequestManager< }, } as ChildrenOfRequestArgsType; - const { data, error } = await tryExecute(this, this.#getChildrenOf(requestArgs)); + const { data, error } = await this.#coalesce( + () => `children:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getChildrenOf(requestArgs)), + ); const mappedData = data ? { @@ -204,7 +249,10 @@ export class UmbManagementApiTreeDataRequestManager< treeItem: args.treeItem, } as AncestorsOfRequestArgsType; - return tryExecute(this, this.#getAncestorsOf(requestArgs)); + return this.#coalesce( + () => `ancestors:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getAncestorsOf(requestArgs)), + ); } #getSkipFromArgs(args: UmbTreeRootItemsRequestArgs | UmbTreeChildrenOfRequestArgs): number {