Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
136 changes: 129 additions & 7 deletions eclipse-scout-core/src/desktop/hybrid/HybridManager.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';

/**
Expand All @@ -22,6 +22,18 @@ export class HybridManager extends Widget {

widgets: Record<string, Widget>;

/**
* Set of {@link HybridManagerWidget}s that will be disposed in the next batch.
* See {@link #_callDisposeWidgets} for more information.
*/
protected _disposeWidgetsNextBatch: Set<HybridManagerWidget> = null;
/**
* Set of ids that were scheduled in former batches.
* See {@link #_callDisposeWidgets} for more information.
*/
protected _disposeWidgetsScheduledWidgetIds = new Set<string>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the name is a little cumbersome... Maybe call it widgetIdsBeingDisposed and the other one _widgetsToBeDisposed?

protected _widgetDestroyHandler = this._onWidgetDestroy.bind(this);

constructor() {
super();

Expand Down Expand Up @@ -61,15 +73,44 @@ export class HybridManager extends Widget {
// widgets

protected _setWidgets(widgets: Record<string, ObjectOrChildModel<Widget>>) {
const presentWidgetIds = new Set<string>();
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<string, Widget> = {};
const removedWidgets: Record<string, HybridManagerWidget> = {};
for (const [id, widget] of Object.entries(this.widgets)) {
if (!widgets[id] || widgets[id] !== widget) {
removedWidgets[id] = widget;
}
}
const addedWidgets: Record<string, Widget> = {};
const addedWidgets: Record<string, HybridManagerWidget> = {};
for (const [id, widget] of Object.entries(widgets as Record<string, Widget>)) {
if (!this.widgets[id] || this.widgets[id] !== widget) {
addedWidgets[id] = widget;
Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to add a comment if the method call is meaningful enough and basically the same as the comment

this._triggerWidgetAdd(id, widget);
});
Object.entries(removedWidgets).forEach(([id, widget]) => {
this._uninstallHybridManagerWidget(widget);
// trigger widgetRemove event
this._triggerWidgetRemove(id, widget);
});
}

protected _ensureWidgets(modelsOrWidgets: Record<string, ObjectOrChildModel<Widget>>): Record<string, Widget> {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<HybridManagerWidget>();
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • widget.__removeId is null for forms, because _uninstallHybridManagerWidget is called before the microtask runs
  • Forms are disposed on server, do we even need a destroy listener for forms?

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<Widget>) {
this._callDisposeWidgets(event.source);
}

// event support

override one<K extends string & keyof EventMapOf<this['self']>>(type: K | `${K}:${string}`, handler: EventHandler<EventMapOf<this>[K] & Event<this>>) {
Expand Down Expand Up @@ -251,3 +369,7 @@ interface HybridManagerForm extends Form {
*/
__closeTriggered?: boolean;
}

export interface HybridManagerWidget extends Widget {
__remoteId?: string;
}
140 changes: 137 additions & 3 deletions eclipse-scout-core/test/desktop/hybrid/HybridManagerSpec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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<string[]>();
JasmineScoutUtil.mockHybridAction(session, 'scout.DisposeWidgets', (event: HybridActionEvent<DisposeWidgetsHybridActionDo>) => {
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<void>();
JasmineScoutUtil.mockHybridAction(session, 'scout.DisposeWidgets', (event: HybridActionEvent<DisposeWidgetsHybridActionDo>) => {
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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -30,13 +32,20 @@ public class DisposeWidgetsHybridAction extends AbstractHybridAction<DisposeWidg

@Override
public void execute(DisposeWidgetsHybridActionDo data) {
for (String id : data.getIds()) {
IWidget widget = hybridManager().getWidgetById(id);
if (widget != null) {
widget.dispose();
LOG.debug("Disposed hybrid widget with id {}", id);
}
}
// get all widgets that need to be disposed
Map<String, IWidget> widgets = hybridManager().getWidgets().entrySet().stream()
.filter(entry -> data.getIds().contains(entry.getKey()) && entry.getValue() != null)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not loop over data.getIds() and call getWidgetById()? It would be faster because looping over getWidgets() and using getIds().contains is O(n^2)

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