From 3b4639de08c02ba50833db347862bd26e1d3e35d Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 1 Jul 2025 10:37:57 +0200 Subject: [PATCH] Collection rendering performance improvements Part 1: Improve Entity actions render performance (#19605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add null checks for editPath and name in render method The render method now checks for the presence of both editPath and _name before rendering the button, preventing potential errors when these values are missing. * Refactor dropdown open state handling Replaces the public 'open' property with a private field and getter/setter to better control dropdown state. Moves popover open/close logic into the setter, removes the 'updated' lifecycle method, and conditionally renders dropdown content based on the open state. * add opened and closed events * dispatch opened and closed events * Render dropdown content only when open Introduces an _isOpen state to control rendering of the dropdown content in UmbEntityActionsBundleElement. Dropdown content is now only rendered when the dropdown is open, improving performance and preventing unnecessary DOM updates. * Update dropdown.element.ts * create a cache elements * Optimize entity actions observation with IntersectionObserver Adds an IntersectionObserver to only observe entity actions when the element is in the viewport, improving performance. Refactors element creation to use constructors, updates event handling, and ensures cleanup in disconnectedCallback. * only observe once * Update entity-actions-bundle.element.ts * Update dropdown.element.ts * Update entity-actions-bundle.element.ts * split dropdown component * pass compact prop * fix label * Update entity-actions-dropdown.element.ts * Update entity-actions-dropdown.element.ts --------- Co-authored-by: Niels Lyngsø --- .../components/dropdown/dropdown.element.ts | 53 ++++++----- .../entity-actions-bundle.element.ts | 67 ++++++------- .../entity-actions-dropdown.element.ts | 93 +++++++++++++++++++ .../entity-action/global-components/index.ts | 1 + .../src/packages/core/event/closed.event.ts | 8 ++ .../src/packages/core/event/index.ts | 2 + .../src/packages/core/event/opened.event.ts | 8 ++ .../workspace-entity-action-menu.element.ts | 65 +++---------- .../document-table-column-name.element.ts | 4 +- .../package-lock.json | 9 +- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../DocumentTypeDesignTab.spec.ts | 1 + 12 files changed, 202 insertions(+), 111 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-dropdown/entity-actions-dropdown.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/event/closed.event.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/event/opened.event.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts index cc8a844b48..fb5004f572 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/dropdown/dropdown.element.ts @@ -5,15 +5,28 @@ import type { UUIPopoverContainerElement, } from '@umbraco-cms/backoffice/external/uui'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; import { css, html, customElement, property, query, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbClosedEvent, UmbOpenedEvent } from '@umbraco-cms/backoffice/event'; // TODO: maybe move this to UI Library. @customElement('umb-dropdown') export class UmbDropdownElement extends UmbLitElement { + #open = false; + @property({ type: Boolean, reflect: true }) - open = false; + public get open() { + return this.#open; + } + public set open(value) { + this.#open = value; + + if (value === true && this.popoverContainerElement) { + this.openDropdown(); + } else { + this.closeDropdown(); + } + } @property() label?: string; @@ -36,29 +49,12 @@ export class UmbDropdownElement extends UmbLitElement { @query('#dropdown-popover') popoverContainerElement?: UUIPopoverContainerElement; - protected override updated(_changedProperties: PropertyValueMap | Map): void { - super.updated(_changedProperties); - if (_changedProperties.has('open') && this.popoverContainerElement) { - if (this.open) { - this.openDropdown(); - } else { - this.closeDropdown(); - } - } - } - - #onToggle(event: ToggleEvent) { - // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.open = event.newState === 'open'; - } - openDropdown() { // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.popoverContainerElement?.showPopover(); + this.#open = true; } closeDropdown() { @@ -66,6 +62,20 @@ export class UmbDropdownElement extends UmbLitElement { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.popoverContainerElement?.hidePopover(); + this.#open = false; + } + + #onToggle(event: ToggleEvent) { + // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.open = event.newState === 'open'; + + if (this.open) { + this.dispatchEvent(new UmbOpenedEvent()); + } else { + this.dispatchEvent(new UmbClosedEvent()); + } } override render() { @@ -81,7 +91,7 @@ export class UmbDropdownElement extends UmbLitElement { ${when( !this.hideExpand, - () => html``, + () => html``, )} @@ -97,6 +107,7 @@ export class UmbDropdownElement extends UmbLitElement { css` #dropdown-button { min-width: max-content; + height: 100%; } :host(:not([hide-expand]):not([compact])) #dropdown-button { --uui-button-padding-right-factor: 2; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts index a8d70cbac6..682ac057f9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/entity-actions-bundle/entity-actions-bundle.element.ts @@ -1,17 +1,7 @@ import { UmbEntityContext } from '../../entity/entity.context.js'; -import type { UmbDropdownElement } from '../dropdown/index.js'; import type { UmbEntityAction, ManifestEntityActionDefaultKind } from '@umbraco-cms/backoffice/entity-action'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { - html, - nothing, - customElement, - property, - state, - ifDefined, - css, - query, -} from '@umbraco-cms/backoffice/external/lit'; +import { html, nothing, customElement, property, state, ifDefined, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbExtensionsManifestInitializer, createExtensionApi } from '@umbraco-cms/backoffice/extension-api'; @@ -39,11 +29,32 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { @state() private _firstActionHref?: string; - @query('#action-modal') - private _dropdownElement?: UmbDropdownElement; - // TODO: provide the entity context on a higher level, like the root element of this entity, tree-item/workspace/... [NL] #entityContext = new UmbEntityContext(this); + #inViewport = false; + #observingEntityActions = false; + + constructor() { + super(); + + // Only observe entity actions when the element is in the viewport + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.#inViewport = true; + this.#observeEntityActions(); + } + }); + }, + { + root: null, // Use the viewport as the root + threshold: 0.1, // Trigger when at least 10% of the element is visible + }, + ); + + observer.observe(this); + } protected override updated(_changedProperties: PropertyValueMap | Map): void { if (_changedProperties.has('entityType') && _changedProperties.has('unique')) { @@ -54,6 +65,11 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { } #observeEntityActions() { + if (!this.entityType) return; + if (this.unique === undefined) return; + if (!this.#inViewport) return; // Only observe if the element is in the viewport + if (this.#observingEntityActions) return; + new UmbExtensionsManifestInitializer( this, umbExtensionsRegistry, @@ -67,6 +83,8 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { }, 'umbEntityActionsObserver', ); + + this.#observingEntityActions = true; } async #createFirstActionApi() { @@ -90,14 +108,6 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { await this._firstActionApi?.execute().catch(() => {}); } - #onActionExecuted() { - this._dropdownElement?.closeDropdown(); - } - - #onDropdownClick(event: Event) { - event.stopPropagation(); - } - override render() { if (this._numberOfActions === 0) return nothing; return html`${this.#renderMore()} ${this.#renderFirstAction()} `; @@ -107,16 +117,9 @@ export class UmbEntityActionsBundleElement extends UmbLitElement { if (this._numberOfActions === 1) return nothing; return html` - - - - - - + + + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-dropdown/entity-actions-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-dropdown/entity-actions-dropdown.element.ts new file mode 100644 index 0000000000..df3074634e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-dropdown/entity-actions-dropdown.element.ts @@ -0,0 +1,93 @@ +import type { UmbDropdownElement } from '../../../components/dropdown/index.js'; +import { UmbEntityActionListElement } from '../../entity-action-list.element.js'; +import { html, customElement, property, css, query } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UUIScrollContainerElement } from '@umbraco-cms/backoffice/external/uui'; +import { UMB_ENTITY_CONTEXT, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; + +@customElement('umb-entity-actions-dropdown') +export class UmbEntityActionsDropdownElement extends UmbLitElement { + @property({ type: Boolean }) + compact = false; + + @property({ type: String }) + public label?: string; + + @query('#action-modal') + private _dropdownElement?: UmbDropdownElement; + + #scrollContainerElement?: UUIScrollContainerElement; + #entityActionListElement?: UmbEntityActionListElement; + #entityType?: UmbEntityModel['entityType']; + #unique?: UmbEntityModel['unique']; + + constructor() { + super(); + this.consumeContext(UMB_ENTITY_CONTEXT, (context) => { + if (!context) return; + + this.observe(observeMultiple([context.entityType, context.unique]), ([entityType, unique]) => { + this.#entityType = entityType; + this.#unique = unique; + + if (this.#entityActionListElement) { + this.#entityActionListElement.entityType = entityType; + this.#entityActionListElement.unique = unique; + } + }); + }); + } + + #onActionExecuted() { + this._dropdownElement?.closeDropdown(); + } + + #onDropdownClick(event: Event) { + event.stopPropagation(); + } + + #onDropdownOpened() { + if (this.#scrollContainerElement) { + return; // Already created + } + + // First create dropdown content when the dropdown is opened. + // Programmatically create the elements so they are cached if the dropdown is opened again + this.#scrollContainerElement = new UUIScrollContainerElement(); + this.#entityActionListElement = new UmbEntityActionListElement(); + this.#entityActionListElement.addEventListener('action-executed', this.#onActionExecuted); + this.#entityActionListElement.entityType = this.#entityType; + this.#entityActionListElement.unique = this.#unique; + this.#entityActionListElement.setAttribute('label', this.label ?? ''); + this.#scrollContainerElement.appendChild(this.#entityActionListElement); + this._dropdownElement?.appendChild(this.#scrollContainerElement); + } + + override render() { + return html` + + + `; + } + + static override styles = [ + css` + uui-scroll-container { + max-height: 700px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-entity-actions-dropdown': UmbEntityActionsDropdownElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/index.ts index 841ca85af1..493801d33f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/index.ts @@ -1 +1,2 @@ +import './entity-actions-dropdown/entity-actions-dropdown.element.js'; import './entity-actions-table-column-view/entity-actions-table-column-view.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/event/closed.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/event/closed.event.ts new file mode 100644 index 0000000000..4b6fe30e33 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/event/closed.event.ts @@ -0,0 +1,8 @@ +export class UmbClosedEvent extends Event { + public static readonly TYPE = 'closed'; + + public constructor() { + // mimics the native toggle event + super(UmbClosedEvent.TYPE, { bubbles: false, composed: false, cancelable: false }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts index 7e986dc488..6d9a131fba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/event/index.ts @@ -1,8 +1,10 @@ export * from './action-executed.event.js'; export * from './change.event.js'; +export * from './closed.event.js'; export * from './delete.event.js'; export * from './deselected.event.js'; export * from './input.event.js'; +export * from './opened.event.js'; export * from './progress.event.js'; export * from './selected.event.js'; export * from './selection-change.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/event/opened.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/event/opened.event.ts new file mode 100644 index 0000000000..88e6935bc5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/event/opened.event.ts @@ -0,0 +1,8 @@ +export class UmbOpenedEvent extends Event { + public static readonly TYPE = 'opened'; + + public constructor() { + // mimics the native toggle event + super(UmbOpenedEvent.TYPE, { bubbles: false, composed: false, cancelable: false }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts index eb38a8fa53..06252f0472 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts @@ -1,10 +1,8 @@ import { UMB_ENTITY_WORKSPACE_CONTEXT } from '../../contexts/index.js'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, state, nothing, query } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbActionExecutedEvent } from '@umbraco-cms/backoffice/event'; +import { html, customElement, state, nothing, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; @customElement('umb-workspace-entity-action-menu') export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { private _workspaceContext?: typeof UMB_ENTITY_WORKSPACE_CONTEXT.TYPE; @@ -15,12 +13,6 @@ export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { @state() private _entityType?: string; - @state() - private _popoverOpen = false; - - @query('#workspace-entity-action-menu-popover') - private _popover?: UUIPopoverContainerElement; - constructor() { super(); @@ -35,48 +27,16 @@ export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { }); } - #onActionExecuted(event: UmbActionExecutedEvent) { - event.stopPropagation(); - - // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this._popover?.hidePopover(); - } - - #onPopoverToggle(event: ToggleEvent) { - // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this._popoverOpen = event.newState === 'open'; - } - override render() { - return this._unique !== undefined && this._entityType - ? html` - - - - - - - - - - - - ` - : nothing; + if (!this._entityType) return nothing; + if (this._unique === undefined) return nothing; + + return html` + + `; } static override styles = [ @@ -86,7 +46,8 @@ export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { height: 100%; margin-left: calc(var(--uui-size-layout-1) * -1); } - :host > uui-button { + + umb-entity-actions-dropdown { height: 100%; } `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts index 660c27db90..f8cc6fc297 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts @@ -34,7 +34,9 @@ export class UmbDocumentTableColumnNameElement extends UmbLitElement implements override render() { if (!this.value) return nothing; - return html` `; + if (!this.value.editPath) return nothing; + if (!this._name) return nothing; + return html``; } static override styles = [ diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index f2cabf43c3..9c0cd687ff 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.36", - "@umbraco/playwright-testhelpers": "^16.0.25", + "@umbraco/playwright-testhelpers": "^16.0.27", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -66,9 +66,10 @@ } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "16.0.25", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.25.tgz", - "integrity": "sha512-IvRkkrTIxlXbg2dw0RhAUgkb7KSBJCyktK6zJynOORgZ5RXRae19hqKk7yEu2EwJpTstl6m9AzoVf1x4b94x5w==", + "version": "16.0.27", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.27.tgz", + "integrity": "sha512-KxjIpfFsiK5b1Au8QrlWceK88eo53VxogLs0LMrxsRS3rt4rdmD1YRP6U+yIucdPKnhVgfIsh40J/taGAZyPFQ==", + "license": "MIT", "dependencies": { "@umbraco/json-models-builders": "2.0.36", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 78c09e9719..b2e9ac7ada 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.36", - "@umbraco/playwright-testhelpers": "^16.0.25", + "@umbraco/playwright-testhelpers": "^16.0.27", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeDesignTab.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeDesignTab.spec.ts index 72a5936b0c..c4c317d3e2 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeDesignTab.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeDesignTab.spec.ts @@ -213,6 +213,7 @@ test('can create a document type with a composition', {tag: '@smoke'}, async ({u // Act await umbracoUi.documentType.goToDocumentType(documentTypeName); + await umbracoUi.waitForTimeout(1000); await umbracoUi.documentType.clickCompositionsButton(); await umbracoUi.documentType.clickModalMenuItemWithName(compositionDocumentTypeName); await umbracoUi.documentType.clickSubmitButton();