Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e09607d
Block List/Grid: Prevent custom view re-rendering during sorting
rickbutterfield Dec 17, 2025
51c48e0
Merge branch 'main' into v17/bugfix/block-rerendering
rickbutterfield Dec 17, 2025
bc00ca2
fix(backoffice): improve extension-slot deferred destruction robustness
rickbutterfield Dec 17, 2025
a78e5db
Merge branch 'main' into v17/bugfix/block-rerendering
rickbutterfield Dec 17, 2025
901932b
fix(backoffice): correct test for extension-slot destruction behavior
rickbutterfield Dec 17, 2025
7d9cd1b
refactor(backoffice): use requestAnimationFrame for deferred destruction
rickbutterfield Dec 17, 2025
5402b39
Merge branch 'main' into v17/bugfix/block-rerendering
rickbutterfield Dec 18, 2025
1e1dab1
Merge branch 'main' into v17/bugfix/block-rerendering
rickbutterfield Jan 23, 2026
e05096a
Merge branch 'main' into v17/bugfix/block-rerendering
rickbutterfield Mar 9, 2026
80dd1fb
move attached = true line
nielslyngsoe Mar 9, 2026
d0e388d
reflect change to slot with api
nielslyngsoe Mar 9, 2026
26ee83a
ensure context consumer reacts delayed towards a disconnect
nielslyngsoe Mar 9, 2026
67c50e2
refactor: extract handleDisconnect
nielslyngsoe Mar 9, 2026
fb5d135
alignment
nielslyngsoe Mar 9, 2026
b42e2dd
clean up blocks
nielslyngsoe Mar 9, 2026
caa995c
delay the destroy directory as well
nielslyngsoe Mar 9, 2026
57d6ec2
only destroy tiptap status bar when disconnected more than one frame
nielslyngsoe Mar 9, 2026
15433dd
avoid rerendering a property if it the same manifest
nielslyngsoe Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export class UmbContextConsumer<
> {
protected _retrieveHost: HostElementMethod;

#raf?: number;
#requestRaf?: number;
#disconnectRaf?: number;
#skipHost?: boolean;
#stopAtContextMatch = true;
#callback?: UmbContextCallback<ResultType>;
Expand Down Expand Up @@ -187,8 +188,8 @@ export class UmbContextConsumer<
* @description Request the context from the host element.
*/
public request(): void {
if (this.#raf !== undefined) {
cancelAnimationFrame(this.#raf);
if (this.#requestRaf !== undefined) {
cancelAnimationFrame(this.#requestRaf);
}

const hostElement = this._retrieveHost();
Expand All @@ -208,25 +209,36 @@ export class UmbContextConsumer<
(this.#skipHost ? hostElement?.parentNode : hostElement)?.dispatchEvent(event);

if (this.#promiseResolver && this.#promiseOptions?.preventTimeout !== true) {
this.#raf = requestAnimationFrame(() => {
this.#requestRaf = requestAnimationFrame(() => {
// For unproviding, then setInstance to undefined here. [NL]
this.#rejectPromise();
this.#raf = undefined;
this.#requestRaf = undefined;
});
}
}

public hostConnected(): void {
if (this.#disconnectRaf !== undefined) {
cancelAnimationFrame(this.#disconnectRaf);
this.#disconnectRaf = undefined;
}
this.#setupCurrentTarget();
this.request();
}

public hostDisconnected(): void {
if (this.#raf !== undefined) {
cancelAnimationFrame(this.#raf);
this.#raf = undefined;
if (this.#requestRaf !== undefined) {
cancelAnimationFrame(this.#requestRaf);
this.#requestRaf = undefined;
}
if (this.#disconnectRaf !== undefined) {
cancelAnimationFrame(this.#disconnectRaf);
}
this.#disconnectRaf = requestAnimationFrame(this.#handleDisconnect);
}

#handleDisconnect = () => {
this.#disconnectRaf = undefined;
this.#unprovide();
if (this.#promiseRejecter) {
const hostElement = this._retrieveHost();
Expand All @@ -239,13 +251,13 @@ export class UmbContextConsumer<
this.#promiseResolver = undefined;
this.#promiseRejecter = undefined;

this.#dismentalCurrentTarget();
this.#dismantleCurrentTarget();
this.#currentTarget = window;
}
};

#currentTarget: EventTarget = window;
#setCurrentTarget(target: EventTarget | undefined) {
this.#dismentalCurrentTarget();
this.#dismantleCurrentTarget();
this.#currentTarget = target ?? window;
this.#setupCurrentTarget();
}
Expand All @@ -256,7 +268,7 @@ export class UmbContextConsumer<
this.#currentTarget.addEventListener(UMB_CONTEXT_UNPROVIDED_EVENT_TYPE, this.#onUnprovided);
}

#dismentalCurrentTarget() {
#dismantleCurrentTarget() {
if (this.#currentTarget) {
this.#currentTarget.removeEventListener(UMB_CONTEXT_PROVIDE_EVENT_TYPE, this.#onProvide);
this.#currentTarget.removeEventListener(UMB_CONTEXT_UNPROVIDED_EVENT_TYPE, this.#onUnprovided);
Expand Down Expand Up @@ -289,7 +301,13 @@ export class UmbContextConsumer<
}

public destroy(): void {
this.hostDisconnected();
if (this.#requestRaf !== undefined) {
cancelAnimationFrame(this.#requestRaf);
}
if (this.#disconnectRaf !== undefined) {
cancelAnimationFrame(this.#disconnectRaf);
}
this.#handleDisconnect();
this._retrieveHost = undefined as any;
this.#callback = undefined;
this.#promise = undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,9 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
<div class="umb-block-grid__layout-container" data-area-length=${this._layoutEntries.length}>
${repeat(
this._layoutEntries,
(layout, index) => `${index}_${layout.contentKey}`,
(layout, index) => html`
<umb-block-grid-entry
(layout) => layout.contentKey,
(layout, index) =>
html`<umb-block-grid-entry
class="umb-block-grid__layout-item"
index=${index}
.contentKey=${layout.contentKey}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UmbBlockGridEntryContext } from './block-grid-entry.context.js';
import { css, customElement, html, nothing, property, state, when } from '@umbraco-cms/backoffice/external/lit';
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbDestroyOnDisconnect, UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation';
import { UUIBlinkAnimationValue, UUIBlinkKeyframes } from '@umbraco-cms/backoffice/external/uui';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
Expand Down Expand Up @@ -505,10 +505,10 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
class="umb-block-grid__block--view"
.config=${this._blockViewProps.config}
.content=${this._blockViewProps.content}
.settings=${this._blockViewProps.settings}>
.settings=${this._blockViewProps.settings}
${umbDestroyOnDisconnect()}>
</umb-block-grid-block-unsupported>
`;
//TODO: investigate if we should have ${umbDestroyOnDisconnect()} here. Note how it works for drag n' drop in grid between areas and areas-root. [NL]
}

#renderInlineBlock() {
Expand All @@ -521,10 +521,10 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
.unpublished=${!this._exposed}
.config=${this._blockViewProps.config}
.content=${this._blockViewProps.content}
.settings=${this._blockViewProps.settings}>
.settings=${this._blockViewProps.settings}
${umbDestroyOnDisconnect()}>
</umb-block-grid-block-inline>
`;
//TODO: investigate if we should have ${umbDestroyOnDisconnect()} here. Note how it works for drag n' drop in grid between areas and areas-root. [NL]
}

#renderRefBlock() {
Expand All @@ -537,10 +537,10 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
.unpublished=${!this._exposed}
.config=${this._blockViewProps.config}
.content=${this._blockViewProps.content}
.settings=${this._blockViewProps.settings}>
.settings=${this._blockViewProps.settings}
${umbDestroyOnDisconnect()}>
</umb-block-grid-block>
`;
//TODO: investigate if we should have ${umbDestroyOnDisconnect()} here. Note how it works for drag n' drop in grid between areas and areas-root. [NL]
}

#renderCreateBeforeInlineButton() {
Expand Down Expand Up @@ -652,7 +652,11 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
#renderDeleteAction() {
if (this._isReadOnly) return nothing;
return html`
<uui-button label="delete" look="secondary" @click=${() => this.#context.requestDelete()} title=${this.localize.term('general_delete')}>
<uui-button
label="delete"
look="secondary"
@click=${() => this.#context.requestDelete()}
title=${this.localize.term('general_delete')}>
<uui-icon name="icon-remove"></uui-icon>
</uui-button>
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ export class UmbPropertyEditorUIBlockListElement
${this.#renderSortModeToolbar()}
${repeat(
this._layouts,
(layout, index) => `${index}_${layout.contentKey}`,
(layout) => layout.contentKey,
(layout, index) => html`
${this.#renderInlineCreateButton(index)}
<umb-block-list-entry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
export class UmbExtensionSlotElement extends UmbLitElement {
#attached = false;
#extensionsController?: UmbExtensionsElementInitializer | UmbExtensionElementInitializer;
#disconnectTimeoutId?: number;

@state()
private _permitted?: Array<UmbExtensionElementInitializer>;
Expand Down Expand Up @@ -272,17 +273,39 @@ export class UmbExtensionSlotElement extends UmbLitElement {
override connectedCallback(): void {
super.connectedCallback();
this.#attached = true;
// Cancel any pending destruction if we're being reconnected (e.g., during a DOM move/sort)
if (this.#disconnectTimeoutId !== undefined) {
cancelAnimationFrame(this.#disconnectTimeoutId);
this.#disconnectTimeoutId = undefined;
// Only skip re-initialization if the controller still exists
if (this.#extensionsController) {
return;
}
}
this.#observeExtensions();
}
override disconnectedCallback(): void {
// _permitted is reset as the extensionsController fires a callback on destroy.
this.#removeEventListenersFromExtensionElement();
this.#attached = false;
this.#extensionsController?.destroy();
this.#extensionsController = undefined;
super.disconnectedCallback();
this.#attached = false;
// Clear any existing pending frame request (defensive cleanup)
if (this.#disconnectTimeoutId !== undefined) {
cancelAnimationFrame(this.#disconnectTimeoutId);
}
// Defer destruction to allow for reconnection during DOM moves/sorting
// If reconnected before the next frame, the destruction is cancelled
this.#disconnectTimeoutId = requestAnimationFrame(this.#handleDisconnect);
}

#handleDisconnect = () => {
this.#disconnectTimeoutId = undefined;
// Only destroy if still detached
if (!this.#attached) {
this.#removeEventListenersFromExtensionElement();
this.#extensionsController?.destroy();
this.#extensionsController = undefined;
}
};

#observeExtensions(): void {
if (!this.#attached) return;
this.#extensionsController?.destroy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,105 @@ describe('UmbExtensionSlotElement', () => {
expect(element.shadowRoot!.childElementCount).to.be.equal(1);
});
});

describe('deferred destruction pattern', () => {
beforeEach(async () => {
umbExtensionsRegistry.register({
type: 'dashboard',
alias: 'unit-test-ext-slot-deferred-manifest',
name: 'unit-test-deferred-extension',
elementName: 'umb-test-extension-slot-manifest-element',
weight: 200,
meta: {
pathname: 'test/test',
},
});
});

afterEach(async () => {
umbExtensionsRegistry.unregister('unit-test-ext-slot-deferred-manifest');
});

it('preserves extension when moved in DOM (simulating drag-and-drop)', async () => {
const container = await fixture(html`<div></div>`) as HTMLDivElement;
element = document.createElement('umb-extension-slot') as UmbExtensionSlotElement;
element.type = 'dashboard';
element.filter = (x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-deferred-manifest';

container.appendChild(element);
await sleep(20);

// Get reference to the rendered extension element
const originalExtensionElement = element.shadowRoot!.firstElementChild;
expect(originalExtensionElement).to.be.instanceOf(UmbTestExtensionSlotManifestElement);

// Simulate DOM move: remove and immediately re-add (like during sorting)
container.removeChild(element);
container.appendChild(element);

// Wait for the deferred timeout to pass
await sleep(20);

// Extension should still exist and be the same instance (not recreated)
const extensionAfterMove = element.shadowRoot!.firstElementChild;
expect(extensionAfterMove).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(extensionAfterMove).to.equal(originalExtensionElement);
});

it('properly destroys and reinitializes extension after permanent removal', async () => {
const container = await fixture(html`<div></div>`) as HTMLDivElement;
element = document.createElement('umb-extension-slot') as UmbExtensionSlotElement;
element.type = 'dashboard';
element.filter = (x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-deferred-manifest';

container.appendChild(element);
await sleep(20);

// Verify extension is rendered
const originalExtensionElement = element.shadowRoot!.firstElementChild;
expect(originalExtensionElement).to.be.instanceOf(UmbTestExtensionSlotManifestElement);

// Permanently remove from DOM
container.removeChild(element);

// Wait for deferred destruction to complete (controller should be destroyed)
await sleep(20);

// Re-add the element to DOM - it should reinitialize with a fresh controller
container.appendChild(element);
await sleep(20);

// Extension should be a NEW instance (not the same as before destruction)
const newExtensionElement = element.shadowRoot!.firstElementChild;
expect(newExtensionElement).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(newExtensionElement).to.not.equal(originalExtensionElement);
});

it('cancels pending destruction timeout when reconnected', async () => {
const container = await fixture(html`<div></div>`) as HTMLDivElement;
element = document.createElement('umb-extension-slot') as UmbExtensionSlotElement;
element.type = 'dashboard';
element.filter = (x: ManifestDashboard) => x.alias === 'unit-test-ext-slot-deferred-manifest';

container.appendChild(element);
await sleep(20);

const originalExtensionElement = element.shadowRoot!.firstElementChild;
expect(originalExtensionElement).to.be.instanceOf(UmbTestExtensionSlotManifestElement);

// Disconnect (starts destruction timeout)
container.removeChild(element);

// Immediately reconnect (should cancel the destruction timeout)
container.appendChild(element);

// Wait longer than the timeout would need
await sleep(20);

// Extension should still exist and be the same instance
const extensionAfterReconnect = element.shadowRoot!.firstElementChild;
expect(extensionAfterReconnect).to.be.instanceOf(UmbTestExtensionSlotManifestElement);
expect(extensionAfterReconnect).to.equal(originalExtensionElement);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
export class UmbExtensionWithApiSlotElement extends UmbLitElement {
#attached = false;
#extensionsController?: UmbExtensionsElementAndApiInitializer;
#disconnectTimeoutId?: number;

@state()
private _permitted?: Array<UmbExtensionElementAndApiInitializer>;
Expand Down Expand Up @@ -337,15 +338,39 @@ export class UmbExtensionWithApiSlotElement extends UmbLitElement {
override connectedCallback(): void {
super.connectedCallback();
this.#attached = true;
// Cancel any pending destruction if we're being reconnected (e.g., during a DOM move/sort)
if (this.#disconnectTimeoutId !== undefined) {
cancelAnimationFrame(this.#disconnectTimeoutId);
this.#disconnectTimeoutId = undefined;
// Only skip re-initialization if the controller still exists
if (this.#extensionsController) {
return;
}
}
this.#observeExtensions();
}

override disconnectedCallback(): void {
this.#attached = false;
this.#extensionsController?.destroy();
this.#extensionsController = undefined;
super.disconnectedCallback();
this.#attached = false;
// Clear any existing pending frame request (defensive cleanup)
if (this.#disconnectTimeoutId !== undefined) {
cancelAnimationFrame(this.#disconnectTimeoutId);
}
// Defer destruction to allow for reconnection during DOM moves/sorting
// If reconnected before the next frame, the destruction is cancelled
this.#disconnectTimeoutId = requestAnimationFrame(this.#handleDisconnect);
}

#handleDisconnect = () => {
this.#disconnectTimeoutId = undefined;
// Only destroy if still detached
if (!this.#attached) {
this.#extensionsController?.destroy();
this.#extensionsController = undefined;
}
};

#observeExtensions(): void {
// We want to be attached before we start observing extensions, cause first at this point we know that we got the right properties. [NL]
if (!this.#attached) return;
Expand Down
Loading
Loading