Collection rendering performance improvements Part 1: Improve Entity actions render performance (#19605)

* 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ø <nsl@umbraco.dk>
This commit is contained in:
Mads Rasmussen
2025-07-01 10:37:57 +02:00
committed by GitHub
parent d5159ae4f7
commit 3b4639de08
12 changed files with 202 additions and 111 deletions

View File

@@ -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<any> | Map<PropertyKey, unknown>): 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 {
<slot name="label"></slot>
${when(
!this.hideExpand,
() => html`<uui-symbol-expand id="symbol-expand" .open=${this.open}></uui-symbol-expand>`,
() => html`<uui-symbol-expand id="symbol-expand" .open=${this.#open}></uui-symbol-expand>`,
)}
</uui-button>
<uui-popover-container id="dropdown-popover" .placement=${this.placement} @toggle=${this.#onToggle}>
@@ -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;

View File

@@ -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<any> | Map<PropertyKey, unknown>): 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`<uui-action-bar slot="actions">${this.#renderMore()} ${this.#renderFirstAction()} </uui-action-bar>`;
@@ -107,16 +117,9 @@ export class UmbEntityActionsBundleElement extends UmbLitElement {
if (this._numberOfActions === 1) return nothing;
return html`
<umb-dropdown id="action-modal" @click=${this.#onDropdownClick} .label=${this.label} compact hide-expand>
<uui-symbol-more slot="label" .label=${this.label}></uui-symbol-more>
<uui-scroll-container>
<umb-entity-action-list
@action-executed=${this.#onActionExecuted}
.entityType=${this.entityType}
.unique=${this.unique}
.label=${this.label}></umb-entity-action-list>
</uui-scroll-container>
</umb-dropdown>
<umb-entity-actions-dropdown .label=${this.label} compact>
<uui-symbol-more slot="label"></uui-symbol-more>
</umb-entity-actions-dropdown>
`;
}

View File

@@ -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`<umb-dropdown
id="action-modal"
@click=${this.#onDropdownClick}
@opened=${this.#onDropdownOpened}
.label=${this.label}
?compact=${this.compact}
hide-expand>
<slot name="label" slot="label"></slot>
<slot></slot>
</umb-dropdown>`;
}
static override styles = [
css`
uui-scroll-container {
max-height: 700px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'umb-entity-actions-dropdown': UmbEntityActionsDropdownElement;
}
}

View File

@@ -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';

View File

@@ -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 });
}
}

View File

@@ -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';

View File

@@ -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 });
}
}

View File

@@ -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`
<uui-button
id="action-button"
data-mark="workspace:action-menu-button"
popovertarget="workspace-entity-action-menu-popover"
label=${this.localize.term('general_actions')}>
<uui-symbol-more></uui-symbol-more>
</uui-button>
<uui-popover-container
id="workspace-entity-action-menu-popover"
placement="bottom-end"
@toggle=${this.#onPopoverToggle}>
<umb-popover-layout>
<uui-scroll-container>
<umb-entity-action-list
@action-executed=${this.#onActionExecuted}
.entityType=${this._entityType}
.unique=${this._unique}>
</umb-entity-action-list>
</uui-scroll-container>
</umb-popover-layout>
</uui-popover-container>
`
: nothing;
if (!this._entityType) return nothing;
if (this._unique === undefined) return nothing;
return html`<umb-entity-actions-dropdown>
<uui-symbol-more
slot="label"
data-mark="workspace:action-menu-button"
label=${this.localize.term('general_actions')}></uui-symbol-more>
</umb-entity-actions-dropdown>`;
}
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%;
}
`,

View File

@@ -34,7 +34,9 @@ export class UmbDocumentTableColumnNameElement extends UmbLitElement implements
override render() {
if (!this.value) return nothing;
return html` <uui-button compact href=${this.value.editPath} label=${this._name}></uui-button> `;
if (!this.value.editPath) return nothing;
if (!this._name) return nothing;
return html`<uui-button compact href=${this.value.editPath} label=${this._name}></uui-button>`;
}
static override styles = [

View File

@@ -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"

View File

@@ -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"

View File

@@ -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();