Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -389,9 +389,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 @@ -388,7 +388,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 @@ -22,6 +22,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 @@ -120,15 +121,36 @@ export class UmbExtensionSlotElement extends UmbLitElement {

override connectedCallback(): void {
super.connectedCallback();
// Cancel any pending destruction if we're being reconnected (e.g., during a DOM move/sort)
if (this.#disconnectTimeoutId !== undefined) {
clearTimeout(this.#disconnectTimeoutId);
this.#disconnectTimeoutId = undefined;
// Only skip re-initialization if the controller still exists
if (this.#extensionsController) {
this.#attached = true;
return;
}
}
this.#attached = true;
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;
// Clear any existing pending timeout (defensive cleanup)
if (this.#disconnectTimeoutId !== undefined) {
clearTimeout(this.#disconnectTimeoutId);
}
// Defer destruction to allow for reconnection during DOM moves/sorting
// If reconnected before the timeout, the destruction is cancelled
this.#disconnectTimeoutId = setTimeout(() => {
Comment thread
nielslyngsoe marked this conversation as resolved.
Outdated
this.#disconnectTimeoutId = undefined;
// Only destroy if still detached
if (!this.#attached) {
this.#removeEventListenersFromExtensionElement();
this.#extensionsController?.destroy();
this.#extensionsController = undefined;
}
}, 0) as unknown as number;
Comment thread
rickbutterfield marked this conversation as resolved.
Outdated
super.disconnectedCallback();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,99 @@ 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);
Comment thread
nielslyngsoe marked this conversation as resolved.

// 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 extension when permanently removed from DOM', 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
expect(element.shadowRoot!.firstElementChild).to.be.instanceOf(UmbTestExtensionSlotManifestElement);

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

// Wait for deferred destruction to complete
await sleep(20);

// Extension should be destroyed (shadowRoot children cleared due to controller destruction)
// The element's internal state should be cleaned up
expect(element.shadowRoot!.childElementCount).to.equal(0);
});

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