From 01ef4225720267eb0712123309e3647b07e694d7 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 29 May 2026 18:12:53 +0200 Subject: [PATCH 1/2] perf(tree): coalesce concurrent identical tree data requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree data request manager hit the network on every call, so multiple concurrent consumers (sidebar tree, breadcrumb structure, pickers) each fetched the same data independently — e.g. three identical tree/document/root requests per document-workspace load. Apply the existing UmbManagementApiInFlightRequestCache (already used by the item and detail request managers) to the tree request manager via a shared static cache, coalescing concurrent identical root/children/ancestors/siblings calls into a single in-flight request, cleared on settle (in-flight only, so no stale-cache risk). The document tree opts in; other trees are unchanged until they pass a cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../document-tree.server.request-manager.ts | 9 +++- .../tree/tree-data.request-manager.ts | 49 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) 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.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts index c2653a26028c..6886c7278d45 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 @@ -4,6 +4,7 @@ import type { UmbManagementApiTreeRootItemsRequestArgs, UmbManagementApiTreeSiblingsFromRequestArgs, } from './types.js'; +import type { UmbManagementApiInFlightRequestCache } from '../inflight-request/cache.js'; import { isOffsetPaginationRequest, isTargetPaginationRequest } from '@umbraco-cms/backoffice/utils'; import { tryExecute, UmbError, type UmbApiResponse } from '@umbraco-cms/backoffice/resources'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; @@ -33,6 +34,7 @@ export interface UmbManagementApiTreeDataRequestManagerArgs< getSiblingsFrom: ( args: SiblingsFromRequestArgsType, ) => Promise>; + inflightRequestCache?: UmbManagementApiInFlightRequestCache; } export class UmbManagementApiTreeDataRequestManager< @@ -50,6 +52,7 @@ export class UmbManagementApiTreeDataRequestManager< #getChildrenOf; #getAncestorsOf; #getSiblingsFrom; + #inflightRequestCache?: UmbManagementApiInFlightRequestCache; #defaultTakeSize = 50; constructor( @@ -71,6 +74,30 @@ export class UmbManagementApiTreeDataRequestManager< this.#getChildrenOf = args.getChildrenOf; this.#getAncestorsOf = args.getAncestorsOf; this.#getSiblingsFrom = args.getSiblingsFrom; + this.#inflightRequestCache = args.inflightRequestCache; + } + + // Coalesces concurrent identical requests so multiple consumers (tree menu, + // breadcrumb structure, pickers) share one in-flight call instead of each + // fetching the same data. In-flight only — the entry is removed once it settles. + async #coalesce(key: string, request: () => Promise): Promise { + const cache = this.#inflightRequestCache; + if (!cache) { + return request(); + } + + const existing = cache.get(key)?.requestPromise as Promise | undefined; + if (existing) { + return existing; + } + + const promise = request(); + cache.set(key, promise as Promise>); + try { + return await promise; + } finally { + cache.delete(key); + } } async getRootItems(args: UmbTreeRootItemsRequestArgs) { @@ -96,7 +123,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 +146,9 @@ 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 +191,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 +219,9 @@ 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 +241,9 @@ 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 { From f1464123d2fa644dd5c6fc3b1a37401e7936c721 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 29 May 2026 18:52:06 +0200 Subject: [PATCH 2/2] test(tree): cover request coalescing; address review feedback - Add focused tests: concurrent identical root requests share one call, the in-flight entry is cleared on settle, and no cache means no coalescing. - Build the cache key lazily (only when a cache is wired) so non-opted-in trees keep the original lightweight path. - Constrain the #coalesce generic to drop the cast on cache.set. - Document the new inflightRequestCache arg; trim the comment to one line. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tree/tree-data.request-manager.test.ts | 106 ++++++++++++++++++ .../tree/tree-data.request-manager.ts | 37 +++--- 2 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.test.ts 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 6886c7278d45..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,10 +1,10 @@ +import type { UmbManagementApiInFlightRequestCache } from '../inflight-request/cache.js'; import type { UmbManagementApiTreeAncestorsOfRequestArgs, UmbManagementApiTreeChildrenOfRequestArgs, UmbManagementApiTreeRootItemsRequestArgs, UmbManagementApiTreeSiblingsFromRequestArgs, } from './types.js'; -import type { UmbManagementApiInFlightRequestCache } from '../inflight-request/cache.js'; import { isOffsetPaginationRequest, isTargetPaginationRequest } from '@umbraco-cms/backoffice/utils'; import { tryExecute, UmbError, type UmbApiResponse } from '@umbraco-cms/backoffice/resources'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; @@ -34,6 +34,10 @@ 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; } @@ -77,22 +81,24 @@ export class UmbManagementApiTreeDataRequestManager< this.#inflightRequestCache = args.inflightRequestCache; } - // Coalesces concurrent identical requests so multiple consumers (tree menu, - // breadcrumb structure, pickers) share one in-flight call instead of each - // fetching the same data. In-flight only — the entry is removed once it settles. - async #coalesce(key: string, request: () => Promise): Promise { + // 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 as Promise>); + cache.set(key, promise); try { return await promise; } finally { @@ -124,7 +130,7 @@ export class UmbManagementApiTreeDataRequestManager< } as SiblingsFromRequestArgsType; const { data: responseData, error: responseError } = await this.#coalesce( - `siblings:${JSON.stringify(requestArgs)}`, + () => `siblings:${JSON.stringify(requestArgs)}`, () => tryExecute(this, this.#getSiblingsFrom(requestArgs)), ); @@ -146,8 +152,9 @@ export class UmbManagementApiTreeDataRequestManager< }, } as RootItemsRequestArgsType; - const { data, error } = await this.#coalesce(`root:${JSON.stringify(requestArgs)}`, () => - tryExecute(this, this.#getRootItems(requestArgs)), + const { data, error } = await this.#coalesce( + () => `root:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getRootItems(requestArgs)), ); const mappedData = data @@ -192,7 +199,7 @@ export class UmbManagementApiTreeDataRequestManager< } as unknown as SiblingsFromRequestArgsType; const { data: responseData, error: responseError } = await this.#coalesce( - `siblings:${JSON.stringify(requestArgs)}`, + () => `siblings:${JSON.stringify(requestArgs)}`, () => tryExecute(this, this.#getSiblingsFrom(requestArgs)), ); @@ -219,8 +226,9 @@ export class UmbManagementApiTreeDataRequestManager< }, } as ChildrenOfRequestArgsType; - const { data, error } = await this.#coalesce(`children:${JSON.stringify(requestArgs)}`, () => - tryExecute(this, this.#getChildrenOf(requestArgs)), + const { data, error } = await this.#coalesce( + () => `children:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getChildrenOf(requestArgs)), ); const mappedData = data @@ -241,8 +249,9 @@ export class UmbManagementApiTreeDataRequestManager< treeItem: args.treeItem, } as AncestorsOfRequestArgsType; - return this.#coalesce(`ancestors:${JSON.stringify(requestArgs)}`, () => - tryExecute(this, this.#getAncestorsOf(requestArgs)), + return this.#coalesce( + () => `ancestors:${JSON.stringify(requestArgs)}`, + () => tryExecute(this, this.#getAncestorsOf(requestArgs)), ); }