diff --git a/eclipse-scout-core/src/desktop/hybrid/HybridManager.ts b/eclipse-scout-core/src/desktop/hybrid/HybridManager.ts index e9fa19b58a0..aa0a4515bb1 100644 --- a/eclipse-scout-core/src/desktop/hybrid/HybridManager.ts +++ b/eclipse-scout-core/src/desktop/hybrid/HybridManager.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2025 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -8,8 +8,8 @@ * SPDX-License-Identifier: EPL-2.0 */ import { - AnyDoEntity, App, Event, EventHandler, EventListener, EventMapOf, Form, HybridActionContextElements, HybridActionEvent, HybridManagerEventMap, HybridManagerWidgetAddEvent, HybridManagerWidgetRemoveEvent, InitModelOf, ObjectOrChildModel, - scout, Session, UuidPool, Widget + AnyDoEntity, App, arrays, DisposeWidgetsHybridActionDo, Event, EventHandler, EventListener, EventMapOf, Form, HybridActionContextElements, HybridActionEvent, HybridManagerEventMap, HybridManagerWidgetAddEvent, + HybridManagerWidgetRemoveEvent, InitModelOf, ObjectOrChildModel, objects, scout, Session, UuidPool, Widget } from '../../index'; /** @@ -22,6 +22,18 @@ export class HybridManager extends Widget { widgets: Record; + /** + * Set of {@link HybridManagerWidget}s that will be disposed in the next batch. + * See {@link #_callDisposeWidgets} for more information. + */ + protected _disposeWidgetsNextBatch: Set = null; + /** + * Set of ids that were scheduled in former batches. + * See {@link #_callDisposeWidgets} for more information. + */ + protected _disposeWidgetsScheduledWidgetIds = new Set(); + protected _widgetDestroyHandler = this._onWidgetDestroy.bind(this); + constructor() { super(); @@ -61,15 +73,44 @@ export class HybridManager extends Widget { // widgets protected _setWidgets(widgets: Record>) { + const presentWidgetIds = new Set(); + for (const [id, widgetOrModel] of Object.entries(widgets)) { + let widgetId: string; + if (typeof widgetOrModel === 'string') { + widgetId = widgetOrModel; + } else if (objects.isObject(widgetOrModel)) { + widgetId = widgetOrModel.id; + } + + if (!widgetId?.length) { + continue; + } + + // collect widget id + presentWidgetIds.add(widgetId); + + if (this._disposeWidgetsScheduledWidgetIds.has(widgetId)) { + // do not accept disposed widgets + delete widgets[id]; + } + } + + for (const id of this._disposeWidgetsScheduledWidgetIds) { + if (!presentWidgetIds.has(id)) { + // widget is no longer present -> no need to remember id any longer + this._disposeWidgetsScheduledWidgetIds.delete(id); + } + } + widgets = this._ensureWidgets(widgets); - const removedWidgets: Record = {}; + const removedWidgets: Record = {}; for (const [id, widget] of Object.entries(this.widgets)) { if (!widgets[id] || widgets[id] !== widget) { removedWidgets[id] = widget; } } - const addedWidgets: Record = {}; + const addedWidgets: Record = {}; for (const [id, widget] of Object.entries(widgets as Record)) { if (!this.widgets[id] || this.widgets[id] !== widget) { addedWidgets[id] = widget; @@ -79,8 +120,16 @@ export class HybridManager extends Widget { this._setProperty('widgets', widgets); - Object.entries(addedWidgets).forEach(([id, widget]) => this._triggerWidgetAdd(id, widget)); - Object.entries(removedWidgets).forEach(([id, widget]) => this._triggerWidgetRemove(id, widget)); + Object.entries(addedWidgets).forEach(([id, widget]) => { + this._installHybridManagerWidget(widget, id); + // trigger widgetAdd event + this._triggerWidgetAdd(id, widget); + }); + Object.entries(removedWidgets).forEach(([id, widget]) => { + this._uninstallHybridManagerWidget(widget); + // trigger widgetRemove event + this._triggerWidgetRemove(id, widget); + }); } protected _ensureWidgets(modelsOrWidgets: Record>): Record { @@ -92,6 +141,26 @@ export class HybridManager extends Widget { return result; } + protected _installHybridManagerWidget(widget: HybridManagerWidget, remoteId: string) { + if (!widget || !remoteId?.length) { + return; + } + + // mark widget with remote id and add destroy listener + widget.__remoteId = remoteId; + widget.one('destroy', this._widgetDestroyHandler); + } + + protected _uninstallHybridManagerWidget(widget: HybridManagerWidget) { + if (!widget?.__remoteId?.length) { + return; + } + + // clear remote id marker and remove destroy listener + delete widget.__remoteId; + widget.off('destroy', this._widgetDestroyHandler); + } + protected _triggerWidgetAdd(id: string, widget: Widget) { this.trigger(`widgetAdd:${id}`, {widget} as HybridManagerWidgetAddEvent); } @@ -221,6 +290,55 @@ export class HybridManager extends Widget { return form; } + /** + * Calls the hybrid action with the action type 'scout.DisposeWidgets' to dispose the given widgets on the UI server. + */ + disposeWidgets(widgets: Widget | Widget[]) { + this._callDisposeWidgets(widgets); + } + + /** + * Calls the hybrid action with the action type 'scout.DisposeWidgets' to dispose the given widgets on the UI server. + * The given widgets are collected in {@link #_disposeWidgetsNextBatch} and sent in one hybrid action. + * After the hybrid action is called the ids of the {@link HybridManagerWidget}s in {@link #_disposeWidgetsNextBatch} are transferred to {@link #_disposeWidgetsScheduledWidgetIds} and {@link #_disposeWidgetsNextBatch} is reset. + */ + protected _callDisposeWidgets(widgets: HybridManagerWidget | HybridManagerWidget[]) { + // filter remote widgets + widgets = arrays.ensure(widgets).filter(widget => !!widget.__remoteId); + + // nothing to dispose + if (!widgets.length) { + return; + } + + // if next batch was not created already, create it and queue microtask to sent hybrid action + if (!this._disposeWidgetsNextBatch) { + this._disposeWidgetsNextBatch = new Set(); + queueMicrotask(() => { + // collect remote ids, transfer widget ids to this._disposeWidgetsScheduledWidgetIds and reset next batch + const remoteIds: string[] = []; + for (const widget of [...this._disposeWidgetsNextBatch]) { + remoteIds.push(widget.__remoteId); + this._disposeWidgetsScheduledWidgetIds.add(widget.id); + } + this._disposeWidgetsNextBatch = null; + + // call hybrid action + this.callAction('scout.DisposeWidgets', scout.create(DisposeWidgetsHybridActionDo, {ids: remoteIds})); + }); + } + + // remove destroy listener and add remote id to next batch + for (const widget of widgets) { + widget.off('destroy', this._widgetDestroyHandler); + this._disposeWidgetsNextBatch.add(widget); + } + } + + protected _onWidgetDestroy(event: Event) { + this._callDisposeWidgets(event.source); + } + // event support override one>(type: K | `${K}:${string}`, handler: EventHandler[K] & Event>) { @@ -251,3 +369,7 @@ interface HybridManagerForm extends Form { */ __closeTriggered?: boolean; } + +export interface HybridManagerWidget extends Widget { + __remoteId?: string; +} diff --git a/eclipse-scout-core/test/desktop/hybrid/HybridManagerSpec.ts b/eclipse-scout-core/test/desktop/hybrid/HybridManagerSpec.ts index 3890ed86987..873453bac64 100644 --- a/eclipse-scout-core/test/desktop/hybrid/HybridManagerSpec.ts +++ b/eclipse-scout-core/test/desktop/hybrid/HybridManagerSpec.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2025 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -8,8 +8,10 @@ * SPDX-License-Identifier: EPL-2.0 */ -import {Form, FormAdapter, HybridActionContextElements, HybridManager, HybridManagerAdapter, LabelField, NumberField, scout, StringField, Tree, TreeAdapter, TreeNode, UuidPool, Widget} from '../../../src'; -import {FormSpecHelper, TreeSpecHelper} from '../../../src/testing'; +import { + Deferred, DisposeWidgetsHybridActionDo, Form, FormAdapter, HybridActionContextElements, HybridActionEvent, HybridManager, HybridManagerAdapter, LabelField, NumberField, scout, StringField, Tree, TreeAdapter, TreeNode, UuidPool, Widget +} from '../../../src'; +import {FormSpecHelper, JasmineScoutUtil, TreeSpecHelper} from '../../../src/testing'; describe('HybridManager', () => { let session: SandboxSession, formHelper: FormSpecHelper; @@ -162,6 +164,138 @@ describe('HybridManager', () => { expect(stringField.destroyed).toBeTrue(); expect(numberField.destroyed).toBeFalse(); }); + + it('removes widgets and sends \'scout.DisposeWidgets\' if widgets are destroyed', async () => { + const hybridManager = HybridManager.get(session); + expect(Object.entries(hybridManager.widgets).length).toBe(0); + + session._processSuccessResponse({ + adapterData: mapAdapterData([ + {id: 'ebe86dba-ac69-4176-8f4d-e0a60e8821dd', objectType: 'StringField'}, + {id: 'bbe0a00d-1f4a-4804-a0ac-20b50a40a206', objectType: 'StringField'}, + {id: '7b7e2da6-e43d-43d6-b83d-c252cb1c07f8', objectType: 'StringField'}, + {id: '451a8fae-eca3-4d2f-ae17-612fdbf59546', objectType: 'StringField'} + ]), + events: [createPropertyChangeEvent(hybridManager, { + widgets: { + field1: 'ebe86dba-ac69-4176-8f4d-e0a60e8821dd', + field2: 'bbe0a00d-1f4a-4804-a0ac-20b50a40a206', + field3: '7b7e2da6-e43d-43d6-b83d-c252cb1c07f8', + field4: '451a8fae-eca3-4d2f-ae17-612fdbf59546' + } + })] + }); + + const {field1, field2, field3, field4} = hybridManager.widgets; + + expect(Object.entries(hybridManager.widgets).length).toBe(4); + expect(field1).toBeInstanceOf(StringField); + expect(field1.destroyed).toBeFalse(); + expect(field2).toBeInstanceOf(StringField); + expect(field2.destroyed).toBeFalse(); + expect(field3).toBeInstanceOf(StringField); + expect(field3.destroyed).toBeFalse(); + expect(field4).toBeInstanceOf(StringField); + expect(field4.destroyed).toBeFalse(); + + let disposeWidgetsCalled = false; + const disposedWidgetIdsDeferred = new Deferred(); + JasmineScoutUtil.mockHybridAction(session, 'scout.DisposeWidgets', (event: HybridActionEvent) => { + disposeWidgetsCalled = true; + disposedWidgetIdsDeferred.resolve(event.data.data.ids); + return null; + }); + + field2.destroy(); + + expect(field1.destroyed).toBeFalse(); + expect(field2.destroyed).toBeTrue(); + expect(field3.destroyed).toBeFalse(); + expect(field4.destroyed).toBeFalse(); + expect(disposeWidgetsCalled).toBeFalse(); + + field3.destroy(); + + expect(field1.destroyed).toBeFalse(); + expect(field2.destroyed).toBeTrue(); + expect(field3.destroyed).toBeTrue(); + expect(field4.destroyed).toBeFalse(); + expect(disposeWidgetsCalled).toBeFalse(); + + hybridManager.disposeWidgets(field4); + + expect(field1.destroyed).toBeFalse(); + expect(field2.destroyed).toBeTrue(); + expect(field3.destroyed).toBeTrue(); + expect(field4.destroyed).toBeFalse(); // scheduling a widget disposal does not destroy the widget immediately in Scout JS + expect(disposeWidgetsCalled).toBeFalse(); + + const disposedWidgetIds = await disposedWidgetIdsDeferred.promise(); + + expect(disposeWidgetsCalled).toBeTrue(); + expect(disposedWidgetIds).toEqual(['field2', 'field3', 'field4']); + }); + + it('widgets for which \'scout.DisposeWidgets\' was sent are ignored on property change events', async () => { + const hybridManager = HybridManager.get(session); + expect(Object.entries(hybridManager.widgets).length).toBe(0); + + session._processSuccessResponse({ + adapterData: mapAdapterData([ + {id: 'b531e00b-a42e-4ac9-b023-262bf3d62e8e', objectType: 'StringField'}, + {id: '3c4b64d0-145f-4ef9-951d-7fbbfda4e219', objectType: 'StringField'}, + {id: '8ae24bcb-edd5-4f73-bb0d-0c119a052893', objectType: 'StringField'}, + {id: '4788f1bd-eca0-4f3c-a4d2-be6707819b97', objectType: 'StringField'} + ]), + events: [createPropertyChangeEvent(hybridManager, { + widgets: { + field1: 'b531e00b-a42e-4ac9-b023-262bf3d62e8e', + field2: '3c4b64d0-145f-4ef9-951d-7fbbfda4e219', + field3: '8ae24bcb-edd5-4f73-bb0d-0c119a052893', + field4: '4788f1bd-eca0-4f3c-a4d2-be6707819b97' + } + })] + }); + + expect(Object.entries(hybridManager.widgets).length).toBe(4); + expect(hybridManager.widgets['field1']).toBeDefined(); + expect(hybridManager.widgets['field2']).toBeDefined(); + expect(hybridManager.widgets['field3']).toBeDefined(); + expect(hybridManager.widgets['field4']).toBeDefined(); + expect(hybridManager.widgets['field5']).not.toBeDefined(); + + const disposedWidgetIdsDeferred = new Deferred(); + JasmineScoutUtil.mockHybridAction(session, 'scout.DisposeWidgets', (event: HybridActionEvent) => { + disposedWidgetIdsDeferred.resolve(); + return null; + }); + + hybridManager.disposeWidgets([hybridManager.widgets['field3'], hybridManager.widgets['field4']]); + await disposedWidgetIdsDeferred.promise(); + + session._processSuccessResponse({ + adapterData: mapAdapterData([ + {id: '0a1fbfb5-ad5f-4f1e-92b5-1781e8b5b319', objectType: 'StringField'} + ]), + events: [createPropertyChangeEvent(hybridManager, { + widgets: { + field1: 'b531e00b-a42e-4ac9-b023-262bf3d62e8e', + field2: '3c4b64d0-145f-4ef9-951d-7fbbfda4e219', + field3: '8ae24bcb-edd5-4f73-bb0d-0c119a052893', + field4: '4788f1bd-eca0-4f3c-a4d2-be6707819b97', + field5: '0a1fbfb5-ad5f-4f1e-92b5-1781e8b5b319' + } + })] + }); + + // disposed widgets field3 and field4 are removed from hybridManager.widgets on property change event from server + expect(Object.entries(hybridManager.widgets).length).toBe(3); + expect(hybridManager.widgets['field1']).toBeDefined(); + expect(hybridManager.widgets['field2']).toBeDefined(); + expect(hybridManager.widgets['field3']).not.toBeDefined(); + expect(hybridManager.widgets['field4']).not.toBeDefined(); + expect(hybridManager.widgets['field5']).toBeDefined(); + }); }); describe('callActionAndWait', () => { diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/DisposeWidgetsHybridAction.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/DisposeWidgetsHybridAction.java index 0eba4d95bab..4782e94203a 100644 --- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/DisposeWidgetsHybridAction.java +++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/DisposeWidgetsHybridAction.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2025 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -11,6 +11,8 @@ import java.util.Collection; import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; import org.eclipse.scout.rt.client.ui.IWidget; import org.slf4j.Logger; @@ -30,13 +32,20 @@ public class DisposeWidgetsHybridAction extends AbstractHybridAction widgets = hybridManager().getWidgets().entrySet().stream() + .filter(entry -> data.getIds().contains(entry.getKey()) && entry.getValue() != null) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + + // remove widgets from hybrid manager before disposing the widgets to reduce the update count of the hybrid managers widgets property + hybridManager().removeWidgetsById(widgets.keySet()); + + // dispose widgets + widgets.forEach((id, widget) -> { + widget.dispose(); + LOG.debug("Disposed hybrid widget with id {}", id); + }); + fireHybridActionEndEvent(); } } diff --git a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/HybridManager.java b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/HybridManager.java index c29414a1bda..013b60a4c00 100644 --- a/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/HybridManager.java +++ b/org.eclipse.scout.rt.client/src/main/java/org/eclipse/scout/rt/client/ui/desktop/hybrid/HybridManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2024 BSI Business Systems Integration AG + * Copyright (c) 2010, 2026 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -183,7 +183,7 @@ protected void armWidgetDisposeListener(IWidget widget) { widget.addPropertyChangeListener(IWidget.PROP_DISPOSE_DONE, getWidgetDisposeListener()); } - protected String getWidgetId(IWidget widget) { + public String getWidgetId(IWidget widget) { return getWidgets().entrySet().stream() .filter(entry -> ObjectUtility.equals(entry.getValue(), widget)) .map(Entry::getKey)