diff --git a/teamapps-client/ts/modules/UiPopup.ts b/teamapps-client/ts/modules/UiPopup.ts index 6cae7ec56..859893e70 100644 --- a/teamapps-client/ts/modules/UiPopup.ts +++ b/teamapps-client/ts/modules/UiPopup.ts @@ -22,16 +22,23 @@ import {AbstractUiComponent} from "./AbstractUiComponent"; import {TeamAppsUiContext} from "./TeamAppsUiContext"; import {UiComponent} from "./UiComponent"; import {TeamAppsUiComponentRegistry} from "./TeamAppsUiComponentRegistry"; -import {UiPopupCommandHandler, UiPopupConfig} from "../generated/UiPopupConfig"; +import {UiPopup_ClosedEvent, UiPopupCommandHandler, UiPopupConfig, UiPopupEventSource} from "../generated/UiPopupConfig"; import {parseHtml} from "./Common"; import {executeWhenFirstDisplayed} from "./util/ExecuteWhenFirstDisplayed"; +import {TeamAppsEvent} from "./util/TeamAppsEvent"; +import {keyCodes} from "./trivial-components/TrivialCore"; -export class UiPopup extends AbstractUiComponent implements UiPopupCommandHandler { +export class UiPopup extends AbstractUiComponent implements UiPopupCommandHandler, UiPopupEventSource { + + public readonly onClosed: TeamAppsEvent = new TeamAppsEvent(); private contentComponent: UiComponent; private $main: HTMLElement; private $componentWrapper: HTMLElement; + private escapeKeyListener: (e: KeyboardEvent) => void; + private clickOutsideListener: (e: MouseEvent) => void; + constructor(config: UiPopupConfig, context: TeamAppsUiContext) { super(config, context); @@ -47,6 +54,34 @@ export class UiPopup extends AbstractUiComponent implements UiPop this.setBackgroundColor(config.backgroundColor); this.setDimmingColor(config.dimmingColor); this.setDimensions(config.width, config.height); + + this.escapeKeyListener = (e) => { + if (this._config.closeOnEscape && e.keyCode === keyCodes.escape) { + this.closeByUser(); + } + }; + document.body.addEventListener("keydown", this.escapeKeyListener, {capture: true}); + + this.clickOutsideListener = (e) => { + if (this._config.closeOnClickOutside && e.target === this.$main) { + this.closeByUser(); + } + }; + this.$main.addEventListener("click", this.clickOutsideListener); + } + + private removeEventListeners() { + if (this.escapeKeyListener) { + document.body.removeEventListener("keydown", this.escapeKeyListener, {capture: true}); + } + if (this.clickOutsideListener) { + this.$main.removeEventListener("click", this.clickOutsideListener); + } + } + + private closeByUser() { + this.close(); + this.onClosed.fire({}); } doGetMainElement(): HTMLElement { @@ -112,8 +147,14 @@ export class UiPopup extends AbstractUiComponent implements UiPop } close(): void { + this.removeEventListeners(); this.getMainElement().remove(); } + + public destroy(): void { + super.destroy(); + this.removeEventListeners(); + } } TeamAppsUiComponentRegistry.registerComponentClass("UiPopup", UiPopup); diff --git a/teamapps-ui-api/src/main/dto/UiPopup.dto b/teamapps-ui-api/src/main/dto/UiPopup.dto index b653602e9..c2e311218 100644 --- a/teamapps-ui-api/src/main/dto/UiPopup.dto +++ b/teamapps-ui-api/src/main/dto/UiPopup.dto @@ -35,4 +35,6 @@ class UiPopup extends UiComponent { command setDimensions(int width, int height); command close(); + + event closed(); } diff --git a/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java b/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java index c7845071c..49f821141 100644 --- a/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java +++ b/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java @@ -170,6 +170,21 @@ public class TeamAppsConfiguration { */ private String navigationPathPrefix = ""; + /** + * If enabled, client objects (components) are only weakly referenced from the session's registry, + * so components that are neither displayed (reachable from a root panel, shown window, popup, etc.) + * nor referenced by application code get garbage collected during the session's lifetime. + * Their client-side counterparts are destroyed automatically. + *

+ * If disabled (default), components stay strongly referenced by the session until it is destroyed + * (historical behavior — long-lived sessions accumulate all components ever rendered). + *

+ * Note: components remain strongly reachable through listener registrations they (or their event handlers) + * hold on application-scoped objects such as shared models or application-wide events. For such components + * to become collectable, the application must dispose/deregister those listeners when discarding the component. + */ + private boolean clientObjectGarbageCollectionEnabled = false; + public TeamAppsConfiguration() { } @@ -371,4 +386,18 @@ public String getNavigationPathPrefix() { public void setNavigationPathPrefix(String navigationPathPrefix) { this.navigationPathPrefix = navigationPathPrefix; } + + /** + * @see #clientObjectGarbageCollectionEnabled + */ + public boolean isClientObjectGarbageCollectionEnabled() { + return clientObjectGarbageCollectionEnabled; + } + + /** + * @see #clientObjectGarbageCollectionEnabled + */ + public void setClientObjectGarbageCollectionEnabled(boolean clientObjectGarbageCollectionEnabled) { + this.clientObjectGarbageCollectionEnabled = clientObjectGarbageCollectionEnabled; + } } diff --git a/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java b/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java index d9cb7df06..8830dbeff 100644 --- a/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java +++ b/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java @@ -116,7 +116,21 @@ public TeamAppsSessionManager(TeamAppsConfiguration config, ObjectMapper objectM this.houseKeepingScheduledExecutor.scheduleAtFixedRate( () -> { try { - sessionsById.values().forEach(s -> s.getUiSession().updateStats()); + sessionsById.values().forEach(s -> { + try { + // destroy the client-side counterparts of garbage collected client objects + // (also covers idle sessions, which do not trigger the opportunistic drain) + s.getSessionContext().drainCollectedClientObjects(); + } catch (Exception e) { + LOGGER.error("Exception while draining collected client objects for session " + s.getUiSession().getSessionId() + "!", e); + } + // Note: the drain above updates the registry asynchronously within the session context, + // so these counts may lag behind by one housekeeping cycle. That is acceptable for statistics. + s.getUiSession().getStatistics().updateClientObjectCounts( + s.getSessionContext().getClientObjectCount(), + s.getSessionContext().getCollectedClientObjectsCount()); + s.getUiSession().updateStats(); + }); onStatsUpdated.fire(new SessionStatsUpdatedEventData(getAllSessions(), getClosedSessionsStatistics())); } catch (Exception e) { LOGGER.error("Exception while flushing stats!", e); @@ -301,7 +315,8 @@ public SessionContext createSessionContext(UiSession uiSession, UiClientInfo uiC httpSession, uxServerContext, new SessionIconProvider(iconProvider), - navigationPathPrefix, new ParameterConverterProvider() + navigationPathPrefix, new ParameterConverterProvider(), + config.isClientObjectGarbageCollectionEnabled() ); } diff --git a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/ImmutableUiSessionStats.java b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/ImmutableUiSessionStats.java index 6e811e8aa..d28442336 100644 --- a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/ImmutableUiSessionStats.java +++ b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/ImmutableUiSessionStats.java @@ -36,6 +36,8 @@ public class ImmutableUiSessionStats implements UiSessionStats { private final ImmutableCountStats queryResultStats; private final ImmutableSumStats sentDataStats; private final ImmutableSumStats receivedDataStats; + private final long clientObjectCount; + private final long collectedClientObjectCount; public static class ImmutableCountStats implements CountStats { private final long count; @@ -106,7 +108,9 @@ public ImmutableUiSessionStats(long startTime, long endTime, ImmutableCountStats queryStats , ImmutableCountStats queryResultStats , ImmutableSumStats sentDataStats, - ImmutableSumStats receivedDataStats + ImmutableSumStats receivedDataStats, + long clientObjectCount, + long collectedClientObjectCount ) { this.startTime = startTime; this.endTime = endTime; @@ -120,6 +124,8 @@ public ImmutableUiSessionStats(long startTime, long endTime, this.queryResultStats = queryResultStats; this.sentDataStats = sentDataStats; this.receivedDataStats = receivedDataStats; + this.clientObjectCount = clientObjectCount; + this.collectedClientObjectCount = collectedClientObjectCount; } @Override @@ -181,4 +187,14 @@ public SumStats getSentDataStats() { public SumStats getReceivedDataStats() { return receivedDataStats; } + + @Override + public long getClientObjectCount() { + return clientObjectCount; + } + + @Override + public long getCollectedClientObjectCount() { + return collectedClientObjectCount; + } } diff --git a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/RunningUiSessionStats.java b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/RunningUiSessionStats.java index 2afc3948d..4498e9850 100644 --- a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/RunningUiSessionStats.java +++ b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/RunningUiSessionStats.java @@ -133,6 +133,9 @@ public ImmutableUiSessionStats.ImmutableSumStats toImmutable() { private final RunningSumStats sentDataStats = new RunningSumStats(); private final RunningSumStats receivedDataStats = new RunningSumStats(); + private volatile long clientObjectCount; + private volatile long collectedClientObjectCount; + public RunningUiSessionStats(long startTime, String sessionId, String name) { this.startTime = startTime; @@ -200,6 +203,21 @@ public SumStats getReceivedDataStats() { return receivedDataStats; } + @Override + public long getClientObjectCount() { + return clientObjectCount; + } + + @Override + public long getCollectedClientObjectCount() { + return collectedClientObjectCount; + } + + public void updateClientObjectCounts(long clientObjectCount, long collectedClientObjectCount) { + this.clientObjectCount = clientObjectCount; + this.collectedClientObjectCount = collectedClientObjectCount; + } + public void nameChanged(String name) { this.name = name; } @@ -240,7 +258,9 @@ public ImmutableUiSessionStats immutableCopy() { queryStats.toImmutable(), queryResultStats.toImmutable(), sentDataStats.toImmutable(), - receivedDataStats.toImmutable()); + receivedDataStats.toImmutable(), + clientObjectCount, + collectedClientObjectCount); } public void update(long totalDataSent, long totalDataReceived) { diff --git a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/UiSessionStats.java b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/UiSessionStats.java index 2b77dd0e0..ef935797e 100644 --- a/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/UiSessionStats.java +++ b/teamapps-ux/src/main/java/org/teamapps/uisession/statistics/UiSessionStats.java @@ -38,4 +38,14 @@ public interface UiSessionStats { SumStats getSentDataStats(); SumStats getReceivedDataStats(); + /** + * Number of client objects currently registered in the session (gauge). + */ + long getClientObjectCount(); + + /** + * Total number of client objects garbage collected during the session's lifetime (cumulative). + */ + long getCollectedClientObjectCount(); + } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/AbstractComponent.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/AbstractComponent.java index bbcbc16cb..216c1e377 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/AbstractComponent.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/AbstractComponent.java @@ -61,6 +61,8 @@ private enum RenderingState { private final Map stylesBySelector = new HashMap<>(0); private final Map> attributesBySelector = new HashMap<>(0); + private boolean pinnedWhileDisplayed; + public AbstractComponent() { this.sessionContext = CurrentSessionContext.get(); id = getClass().getSimpleName() + "-" + UUID.randomUUID().toString(); @@ -84,6 +86,30 @@ public SessionContext getSessionContext() { return sessionContext; } + /** + * Pins this component in its session context until {@link #releaseDisplayPin()}, so it does not get garbage + * collected while displayed even if the application holds no reference to it + * (see {@link org.teamapps.config.TeamAppsConfiguration#setClientObjectGarbageCollectionEnabled(boolean)}). + * Idempotent per display cycle: consecutive calls acquire only one pin, so a racing double-release + * (e.g. a server-side close plus the client's close event) cannot steal an independent application pin. + */ + public final void pinWhileDisplayed() { + if (!pinnedWhileDisplayed) { + pinnedWhileDisplayed = true; + sessionContext.pinClientObject(this); + } + } + + /** + * Releases the pin acquired by {@link #pinWhileDisplayed()}. No-op if not pinned. + */ + protected final void releaseDisplayPin() { + if (pinnedWhileDisplayed) { + pinnedWhileDisplayed = false; + sessionContext.unpinClientObject(this); + } + } + @Override public boolean isRendered() { return renderingState == RenderingState.RENDERED; diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java index 768960313..3934afc68 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java @@ -44,6 +44,10 @@ public class ChatDisplay extends AbstractComponent { private Icon deletedMessageIcon = MaterialIcon.DELETE.withStyle(MaterialIconStyles.OUTLINE_GREY_900); private Function contextMenuProvider = null; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; public ChatDisplay(ChatDisplayModel model) { this.model = model; @@ -98,6 +102,7 @@ public Object handleUiQuery(UiQuery query) { ChatMessage chatMessage = model.getChatMessageById(q.getChatMessageId()); if (chatMessage != null) { Component component = contextMenuProvider.apply(chatMessage); + lastContextMenuComponent = component; // strong reference while the context menu is displayed return component != null ? component.createUiReference() : null; } } @@ -175,6 +180,7 @@ public void setContextMenuProvider(Function contextMenuP } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiChatDisplay.CloseContextMenuCommand(getId())); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView.java index c0dae118b..856092e5b 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView.java @@ -69,6 +69,10 @@ public class InfiniteItemView extends AbstractComponent { private final Consumer> modelOnRecordDeletedListener = x -> this.refresh(); private Function contextMenuProvider = null; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; private int lastSeenContextMenuRequestId; private int displayedRangeStart = 0; @@ -148,6 +152,7 @@ public void handleUiEvent(UiEvent event) { RECORD record = itemCache.getRecordByClientId(e.getRecordId()); if (record != null && contextMenuProvider != null) { Component contextMenuContent = contextMenuProvider.apply(record); + lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed if (contextMenuContent != null) { queueCommandIfRendered(() -> new UiInfiniteItemView.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference())); } else { @@ -311,6 +316,7 @@ public void setContextMenuProvider(Function contextMenuProvid } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiInfiniteItemView.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView2.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView2.java index bb48db537..7e02d3a11 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView2.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/infiniteitemview/InfiniteItemView2.java @@ -59,6 +59,10 @@ public class InfiniteItemView2 extends AbstractInfiniteListComponent contextMenuProvider = null; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; private int lastSeenContextMenuRequestId; private boolean selectionEnabled; @@ -131,6 +135,7 @@ public void handleUiEvent(UiEvent event) { RECORD record = renderedRecords.getRecord(e.getRecordId()); if (record != null) { Component contextMenuContent = contextMenuProvider.apply(record); + lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed if (contextMenuContent != null) { queueCommandIfRendered(() -> new UiInfiniteItemView2.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference())); } else { @@ -186,6 +191,7 @@ public void setContextMenuProvider(Function contextMenuProvid } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiInfiniteItemView2.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/notification/Notification.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/notification/Notification.java index a28e4a7bd..dcb77a225 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/notification/Notification.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/notification/Notification.java @@ -90,6 +90,7 @@ public void handleUiEvent(UiEvent event) { } case UI_NOTIFICATION_CLOSED: { this.showing = false; + releaseDisplayPin(); onClosed.fire(((UiNotification.ClosedEvent) event).getByUser()); break; } @@ -97,6 +98,8 @@ public void handleUiEvent(UiEvent event) { } public void close() { + // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the pin here + releaseDisplayPin(); queueCommandIfRendered(() -> new UiNotification.CloseCommand(getId())); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/popup/Popup.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/popup/Popup.java index 3a471efbb..67fa906ce 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/popup/Popup.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/popup/Popup.java @@ -22,12 +22,16 @@ import org.teamapps.common.format.RgbaColor; import org.teamapps.common.format.Color; import org.teamapps.dto.UiComponent; +import org.teamapps.dto.UiEvent; import org.teamapps.dto.UiPopup; +import org.teamapps.event.Event; import org.teamapps.ux.component.AbstractComponent; import org.teamapps.ux.component.Component; public class Popup extends AbstractComponent { + public final Event onClosed = new Event<>(); + private Component contentComponent; private int x; private int y; @@ -157,6 +161,19 @@ public void setCloseOnClickOutside(boolean closeOnClickOutside) { } public void close() { + // release the strong reference created when the popup was shown + releaseDisplayPin(); queueCommandIfRendered(() -> new UiPopup.CloseCommand(getId())); } + + @Override + public void handleUiEvent(UiEvent event) { + switch (event.getUiEventType()) { + case UI_POPUP_CLOSED -> { + // user-initiated close (escape, click outside) — release the strong reference created in SessionContext.showPopup*() + releaseDisplayPin(); + onClosed.fire(); + } + } + } } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/table/Table.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/table/Table.java index 7969024d2..a735f65b9 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/table/Table.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/table/Table.java @@ -123,6 +123,10 @@ public class Table extends AbstractInfiniteListComponent contextMenuProvider = null; private int lastSeenContextMenuRequestId; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; private int rowBorderWidth; public Table() { @@ -352,6 +356,7 @@ public void handleUiEvent(UiEvent event) { RECORD record = renderedRecords.getRecord(e.getRecordId()); if (record != null && contextMenuProvider != null) { Component contextMenuContent = contextMenuProvider.apply(record); + lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed if (contextMenuContent != null) { queueCommandIfRendered(() -> new UiInfiniteItemView.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference())); } else { @@ -1248,6 +1253,7 @@ public void setContextMenuProvider(Function contextMenuProvid } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiTable.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/toolbar/ToolbarButton.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/toolbar/ToolbarButton.java index e68025ee2..eb2167829 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/toolbar/ToolbarButton.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/toolbar/ToolbarButton.java @@ -55,6 +55,8 @@ public class ToolbarButton { // ===== END HACKS ===== private Supplier dropDownComponentSupplier; + // strong reference to the rendered drop-down — the client caches it too, and it must not get garbage collected + private Component renderedDropDownComponent; private boolean eagerDropDownRendering = false; private int droDownPanelWidth; @@ -132,7 +134,8 @@ public UiToolbarButton createUiToolbarButton() { UiToolbarButton ui = new UiToolbarButton(clientId, template.createUiTemplate(), values); if (this.eagerDropDownRendering && this.dropDownComponentSupplier != null) { - ui.setDropDownComponent(dropDownComponentSupplier.get().createUiReference()); + this.renderedDropDownComponent = dropDownComponentSupplier.get(); + ui.setDropDownComponent(renderedDropDownComponent.createUiReference()); } ui.setHasDropDown(this.dropDownComponentSupplier != null); ui.setDropDownPanelWidth(droDownPanelWidth > 0 ? droDownPanelWidth : 450); @@ -219,11 +222,13 @@ public ToolbarButton setDroDownPanelWidth(int droDownPanelWidth) { public ToolbarButton setDropDownComponent(Component dropDownComponent) { this.dropDownComponentSupplier = () -> dropDownComponent; + this.renderedDropDownComponent = null; return this; } public ToolbarButton updateDropDownComponent(Component dropDownComponent) { this.dropDownComponentSupplier = () -> dropDownComponent; + this.renderedDropDownComponent = dropDownComponent; // sent to the client right away this.toolbarButtonGroup.handleDropDownComponentUpdate(this, dropDownComponent); return this; } @@ -279,11 +284,28 @@ public Supplier getDropDownComponentSupplier() { } /*package-private*/ Component getDropDownComponent() { - return this.dropDownComponentSupplier != null ? this.dropDownComponentSupplier.get() : null; + if (this.dropDownComponentSupplier == null) { + return null; + } + // cache the supplier's result: the client caches the drop-down content too (it requests it only once), + // so the component must stay strongly referenced + if (this.renderedDropDownComponent == null) { + this.renderedDropDownComponent = this.dropDownComponentSupplier.get(); + } + return this.renderedDropDownComponent; } + /** + * Sets the supplier for this button's drop-down content. + *

+ * Note: the supplier's result is cached once rendered and reused across re-render cycles — the supplier is + * NOT invoked again to produce a fresh component per render. To replace the currently displayed drop-down + * content, use {@link #updateDropDownComponent(Component)}. Calling this setter (or + * {@link #setDropDownComponent(Component)}) also resets the cached component. + */ public ToolbarButton setDropDownComponentSupplier(Supplier dropDownComponentSupplier) { this.dropDownComponentSupplier = dropDownComponentSupplier; + this.renderedDropDownComponent = null; return this; } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/tree/Tree.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/tree/Tree.java index df5202cd5..f29ab07cc 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/tree/Tree.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/tree/Tree.java @@ -66,6 +66,10 @@ public class Tree extends AbstractComponent { private boolean enforceSingleExpandedPath = false; private Function contextMenuProvider = null; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; private int lastSeenContextMenuRequestId; private Function recordToStringFunction = Object::toString; @@ -245,6 +249,7 @@ public void handleUiEvent(UiEvent event) { RECORD record = getRecordByUiId(e.getRecordId()); if (record != null) { Component contextMenuContent = contextMenuProvider.apply(record); + lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed if (contextMenuContent != null) { queueCommandIfRendered(() -> new UiInfiniteItemView2.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference())); } else { @@ -339,6 +344,7 @@ public void setContextMenuProvider(Function contextMenuProvid } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiInfiniteItemView2.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/webrtc/MediaSoupV3WebRtcClient.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/webrtc/MediaSoupV3WebRtcClient.java index 3ad39f95d..cd63c9669 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/webrtc/MediaSoupV3WebRtcClient.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/webrtc/MediaSoupV3WebRtcClient.java @@ -69,6 +69,10 @@ public class MediaSoupV3WebRtcClient extends AbstractComponent { private UiMediaSoupPlaybackParameters playbackParameters; private Supplier contextMenuProvider = null; + // Strong reference — displayed context menus must not get garbage collected. + // Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu(); + // a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive). + private Component lastContextMenuComponent; private int lastSeenContextMenuRequestId; private boolean bitrateDisplayEnabled; @@ -150,6 +154,7 @@ public void handleUiEvent(UiEvent event) { lastSeenContextMenuRequestId = e.getRequestId(); if (contextMenuProvider != null) { Component contextMenuContent = contextMenuProvider.get(); + lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed if (contextMenuContent != null) { queueCommandIfRendered(() -> new UiInfiniteItemView.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference())); } else { @@ -359,6 +364,7 @@ public void setContextMenuProvider(Supplier contextMenuProvider) { } public void closeContextMenu() { + lastContextMenuComponent = null; queueCommandIfRendered(() -> new UiInfiniteItemView.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/component/window/Window.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/window/Window.java index 7156139dc..d9374960b 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/component/window/Window.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/component/window/Window.java @@ -97,6 +97,8 @@ public void handleUiEvent(UiEvent event) { super.handleUiEvent(event); switch (event.getUiEventType()) { case UI_WINDOW_CLOSED -> { + // user-initiated close (close button, escape, click outside) — release the strong reference created in show() + releaseDisplayPin(); onClosed.fire(); } } @@ -194,6 +196,7 @@ public void show() { } public void show(int animationDuration) { + pinWhileDisplayed(); // shown windows must not get garbage collected; unpinned on close render(); queueCommandIfRendered(() -> new UiWindow.ShowCommand(getId(), animationDuration)); } @@ -203,6 +206,8 @@ public void close() { } public void close(int animationDuration) { + // server-initiated close does not fire UI_WINDOW_CLOSED back, so unpin here + releaseDisplayPin(); queueCommandIfRendered(() -> new UiWindow.CloseCommand(getId(), animationDuration)); } diff --git a/teamapps-ux/src/main/java/org/teamapps/ux/session/SessionContext.java b/teamapps-ux/src/main/java/org/teamapps/ux/session/SessionContext.java index c8cadd9c9..daa85ff76 100644 --- a/teamapps-ux/src/main/java/org/teamapps/ux/session/SessionContext.java +++ b/teamapps-ux/src/main/java/org/teamapps/ux/session/SessionContext.java @@ -59,10 +59,14 @@ import org.teamapps.ux.session.navigation.*; import java.io.File; +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; import java.time.ZoneId; import java.util.*; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -76,6 +80,16 @@ public class SessionContext { private static final Logger LOGGER = LoggerFactory.getLogger(SessionContext.class); + + private static class ClientObjectWeakReference extends WeakReference { + final String clientObjectId; + + ClientObjectWeakReference(ClientObject clientObject, ReferenceQueue queue) { + super(clientObject, queue); + this.clientObjectId = clientObject.getId(); + } + } + private static final String DEFAULT_BACKGROUND_NAME = "defaultBackground"; private static final String DEFAULT_BACKGROUND_URL = "/resources/backgrounds/default-bl.jpg"; @@ -104,7 +118,35 @@ public class SessionContext { private final UxServerContext serverContext; private final SessionIconProvider iconProvider; private final UxJacksonSerializationTemplate uxJacksonSerializationTemplate; - private final HashMap clientObjectsById = new HashMap<>(); + /** + * Registry of all rendered client objects, keyed by id. If {@link #clientObjectGarbageCollectionEnabled}, the values + * are weak references, so client objects that are neither displayed (see {@link #pinnedClientObjects} and + * {@link #attachedRootComponents}, plus the strong parent→child references between components) nor referenced by + * application code get garbage collected. Their client-side counterparts are destroyed via + * {@link #drainCollectedClientObjects()}. If the flag is disabled, every registered client object is additionally + * pinned, restoring the historical strongly-referenced behavior. + */ + private final ConcurrentHashMap clientObjectsById = new ConcurrentHashMap<>(); + private final ReferenceQueue collectedClientObjectsQueue = new ReferenceQueue<>(); + /** + * Strong references to client objects that are displayed without necessarily being referenced by application code: + * shown windows, popups, notifications etc. Reference-counted: {@link #pinClientObject(ClientObject)} increments, + * {@link #unpinClientObject(ClientObject)} decrements and removes the entry when the count reaches zero, so an + * object pinned for multiple independent reasons stays pinned until every reason released it. + * Mutated only within the session context. + */ + private final Map pinnedClientObjects = new IdentityHashMap<>(); + /** + * Strong references to components attached as root panels ({@link #addRootPanel(String, Component)}). Deliberately + * accumulating and never released: the client-side {@code UiRootPanel.buildRootPanel} appends the root + * panel to the container element, so every attached root component stays displayed for the rest of the session + * (there is no API for removing or replacing a root panel). In particular, this must not be keyed by container + * selector: adding a second root panel for the same selector displays both, so releasing the first one would let + * it be garbage collected and its still-visible client-side counterpart destroyed. + */ + private final List attachedRootComponents = new ArrayList<>(); + private volatile long collectedClientObjectsCount; // written within the session context, read by the housekeeping thread + private final boolean clientObjectGarbageCollectionEnabled; private final SessionContextResourceManager sessionResourceProvider; private TranslationProvider translationProvider; @@ -119,6 +161,10 @@ public class SessionContext { private Window sessionExpiredWindow; private Window sessionErrorWindow; private Window sessionTerminatedWindow; + // strong references to the effective (potentially default) session message windows sent to the client + private Window effectiveSessionExpiredWindow; + private Window effectiveSessionErrorWindow; + private Window effectiveSessionTerminatedWindow; private final ParamConverterProvider navigationParamConverterProvider; private final String navigationPathPrefix; @@ -140,6 +186,9 @@ public void onUiEvent(String sessionId, UiEvent event) { ClientObject clientObject = getClientObject(uiComponentId); if (clientObject != null) { clientObject.handleUiEvent(event); + } else if (clientObjectGarbageCollectionEnabled) { + // The client object may have been garbage collected (or unrendered) while the event was in flight. + LOGGER.warn("Ignoring UI event {} for unknown or garbage collected client object {}", event.getUiEventType(), uiComponentId); } else { throw new TeamAppsComponentNotFoundException(sessionId, uiComponentId); } @@ -156,9 +205,15 @@ public void onUiQuery(String sessionId, UiQuery query, Consumer resultCa ClientObject clientObject = getClientObject(uiComponentId); if (clientObject != null) { Object result = clientObject.handleUiQuery(query); - new UxJacksonSerializationTemplate(SessionContext.this).doWithUxJacksonSerializers(() -> { + uxJacksonSerializationTemplate.doWithUxJacksonSerializers(() -> { resultCallback.accept(result); }); + } else if (clientObjectGarbageCollectionEnabled) { + // The client object may have been garbage collected (or unrendered) while the query was in flight. + LOGGER.warn("Returning null result for UI query {} for unknown or garbage collected client object {}", query.getUiQueryType(), uiComponentId); + uxJacksonSerializationTemplate.doWithUxJacksonSerializers(() -> { + resultCallback.accept(null); + }); } else { throw new TeamAppsComponentNotFoundException(sessionId, uiComponentId); } @@ -199,8 +254,10 @@ public SessionContext(UiSession uiSession, UxServerContext serverContext, SessionIconProvider iconProvider, String navigationPathPrefix, - ParamConverterProvider navigationParamConverterProvider // TODO #ownInterfaces + ParamConverterProvider navigationParamConverterProvider, // TODO #ownInterfaces + boolean clientObjectGarbageCollectionEnabled ) { + this.clientObjectGarbageCollectionEnabled = clientObjectGarbageCollectionEnabled; this.sessionExecutor = sessionExecutor; this.uiSession = uiSession; this.httpSession = httpSession; @@ -574,10 +631,12 @@ private void sendConfigToClient() { } public void showPopupAtCurrentMousePosition(Popup popup) { + popup.pinWhileDisplayed(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupAtCurrentMousePositionCommand(popup.createUiReference())); } public void showPopup(Popup popup) { + popup.pinWhileDisplayed(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupCommand(popup.createUiReference())); } @@ -604,15 +663,99 @@ public String resolveIcon(Icon icon) { public void registerClientObject(ClientObject clientObject) { CurrentSessionContext.throwIfNotSameAs(this); - clientObjectsById.put(clientObject.getId(), clientObject); + drainCollectedClientObjects(); + clientObjectsById.put(clientObject.getId(), new ClientObjectWeakReference(clientObject, collectedClientObjectsQueue)); + if (!clientObjectGarbageCollectionEnabled) { + pinnedClientObjects.putIfAbsent(clientObject, 1); + } } public void unregisterClientObject(ClientObject clientObject) { - clientObjectsById.remove(clientObject.getId()); + CurrentSessionContext.throwIfNotSameAs(this); + ClientObjectWeakReference reference = clientObjectsById.get(clientObject.getId()); + if (reference != null && reference.get() == clientObject) { + clientObjectsById.remove(clientObject.getId()); + } + pinnedClientObjects.remove(clientObject); } public ClientObject getClientObject(String clientObjectId) { - return clientObjectsById.get(clientObjectId); + ClientObjectWeakReference reference = clientObjectsById.get(clientObjectId); + return reference != null ? reference.get() : null; + } + + /** + * Strongly references the given client object from this session context, preventing its garbage collection + * (see {@link org.teamapps.config.TeamAppsConfiguration#setClientObjectGarbageCollectionEnabled(boolean)}). + * Use this for client objects that must stay alive while displayed although application code may not reference them, + * e.g. custom components sending {@link ClientObject#createUiReference()} of temporary objects. + * Pins are reference-counted: pinning the same object multiple times requires the same number of + * {@link #unpinClientObject(ClientObject)} calls to release it. + * Must be invoked with this session context bound to the current thread. + */ + public void pinClientObject(ClientObject clientObject) { + CurrentSessionContext.throwIfNotSameAs(this); + pinnedClientObjects.merge(clientObject, 1, Integer::sum); + } + + /** + * Releases one strong reference (pin) created by {@link #pinClientObject(ClientObject)}. The object stays pinned + * until every pin has been released. Unpinning an object that is not pinned is a no-op, so it never releases + * pins held for other reasons. + * Must be invoked with this session context bound to the current thread. + */ + public void unpinClientObject(ClientObject clientObject) { + CurrentSessionContext.throwIfNotSameAs(this); + if (!clientObjectGarbageCollectionEnabled) { + // with garbage collection disabled, client objects must stay strongly referenced until the session + // is destroyed (exact historical behavior) + return; + } + pinnedClientObjects.computeIfPresent(clientObject, (o, count) -> count > 1 ? count - 1 : null); + } + + /** + * Removes registry entries of garbage collected client objects and destroys their client-side counterparts. + * May be called from any thread. The registry cleanup and command queuing are executed within the session context. + */ + public void drainCollectedClientObjects() { + List collectedReferences = null; + Reference reference; + while ((reference = collectedClientObjectsQueue.poll()) != null) { + if (collectedReferences == null) { + collectedReferences = new ArrayList<>(); + } + collectedReferences.add((ClientObjectWeakReference) reference); + } + if (collectedReferences == null) { + return; + } + List references = collectedReferences; + runWithContext(() -> { + for (ClientObjectWeakReference collectedReference : references) { + // Remove only if the registry still holds this very reference. Otherwise the client object was explicitly + // unregistered (unrender() already sent a destroy command) or a new client object reuses the id. + if (clientObjectsById.remove(collectedReference.clientObjectId, collectedReference)) { + collectedClientObjectsCount++; + LOGGER.debug("Client object {} was garbage collected. Destroying its client-side counterpart.", collectedReference.clientObjectId); + queueCommand(new UiRootPanel.DestroyComponentCommand(collectedReference.clientObjectId)); + } + } + }); + } + + /** + * Number of client objects currently registered (including those garbage collected but not yet drained). + */ + public int getClientObjectCount() { + return clientObjectsById.size(); + } + + /** + * Total number of client objects that have been garbage collected during this session's lifetime. + */ + public long getCollectedClientObjectsCount() { + return collectedClientObjectsCount; } public String createResourceLink(Resource resource) { @@ -668,6 +811,7 @@ public void addRootComponent(String containerElementSelector, Component componen } public void addRootPanel(String containerElementSelector, Component rootPanel) { + attachedRootComponents.add(rootPanel); // strong reference: the displayed tree must not get garbage collected queueCommand(new UiRootPanel.BuildRootPanelCommand(containerElementSelector, rootPanel.createUiReference())); } @@ -698,6 +842,7 @@ public void clearClientTokens() { public void showNotification(Notification notification, NotificationPosition position, EntranceAnimation entranceAnimation, ExitAnimation exitAnimation) { runWithContext(() -> { + notification.pinWhileDisplayed(); // unpinned when the notification closes (Notification.close() or client-side close event) queueCommand(new UiRootPanel.ShowNotificationCommand(notification.createUiReference(), position.toUiNotificationPosition(), entranceAnimation.toUiEntranceAnimation(), exitAnimation.toUiExitAnimation())); }); @@ -755,14 +900,20 @@ public void setSessionTerminatedWindow(Window sessionTerminatedWindow) { } private void updateSessionMessageWindows() { + // keep strong references to the effective windows — the client-side objects must not get garbage collected + effectiveSessionExpiredWindow = sessionExpiredWindow != null ? sessionExpiredWindow + : createDefaultSessionMessageWindow(getLocalized("teamapps.common.sessionExpired"), getLocalized("teamapps.common.sessionExpiredText"), + getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")); + effectiveSessionErrorWindow = sessionErrorWindow != null ? sessionErrorWindow + : createDefaultSessionMessageWindow(getLocalized("teamapps.common.error"), getLocalized("teamapps.common.sessionErrorText"), + getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")); + effectiveSessionTerminatedWindow = sessionTerminatedWindow != null ? sessionTerminatedWindow + : createDefaultSessionMessageWindow(getLocalized("teamapps.common.sessionTerminated"), getLocalized("teamapps.common.sessionTerminatedText"), + getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")); queueCommand(new UiRootPanel.SetSessionMessageWindowsCommand( - sessionExpiredWindow != null ? sessionExpiredWindow.createUiReference() - : createDefaultSessionMessageWindow(getLocalized("teamapps.common.sessionExpired"), getLocalized("teamapps.common.sessionExpiredText"), - getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")).createUiReference(), - sessionErrorWindow != null ? sessionErrorWindow.createUiReference() : createDefaultSessionMessageWindow(getLocalized("teamapps.common.error"), getLocalized("teamapps.common.sessionErrorText"), - getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")).createUiReference(), - sessionTerminatedWindow != null ? sessionTerminatedWindow.createUiReference() : createDefaultSessionMessageWindow(getLocalized("teamapps.common.sessionTerminated"), getLocalized("teamapps.common.sessionTerminatedText"), - getLocalized("teamapps.common.refresh"), getLocalized("teamapps.common.cancel")).createUiReference()) + effectiveSessionExpiredWindow.createUiReference(), + effectiveSessionErrorWindow.createUiReference(), + effectiveSessionTerminatedWindow.createUiReference()) ); } diff --git a/teamapps-ux/src/test/java/org/teamapps/testutil/UxTestUtil.java b/teamapps-ux/src/test/java/org/teamapps/testutil/UxTestUtil.java index 0fc09540c..ed59db2e3 100644 --- a/teamapps-ux/src/test/java/org/teamapps/testutil/UxTestUtil.java +++ b/teamapps-ux/src/test/java/org/teamapps/testutil/UxTestUtil.java @@ -45,6 +45,10 @@ public static CompletableFuture doWithMockedSessionContext(Runnable runnab } public static SessionContext createDummySessionContext() { + return createDummySessionContext(false); + } + + public static SessionContext createDummySessionContext(boolean clientObjectGarbageCollectionEnabled) { final ClientInfo clientInfo = new ClientInfo("ip", 1024, 768, 1000, 700, "en", false, "Europe/Berlin", 120, Collections.emptyList(), "userAgentString", Mockito.mock(Location.class), Collections.emptyMap(), TEAMAPPS_VERSION); return new SessionContext( Mockito.mock(UiSession.class), @@ -53,7 +57,8 @@ public static SessionContext createDummySessionContext() { Mockito.mock(UxServerContext.class), Mockito.mock(SessionIconProvider.class), "", - Mockito.mock(ParamConverterProvider.class) + Mockito.mock(ParamConverterProvider.class), + clientObjectGarbageCollectionEnabled ); } diff --git a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java new file mode 100644 index 000000000..c1101bf48 --- /dev/null +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -0,0 +1,553 @@ +/*- + * ========================LICENSE_START================================= + * TeamApps + * --- + * Copyright (C) 2014 - 2026 TeamApps.org + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.teamapps.ux.session; + +import jakarta.servlet.http.HttpSession; +import jakarta.ws.rs.ext.ParamConverterProvider; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.teamapps.dto.UiNotification; +import org.teamapps.dto.UiPopup; +import org.teamapps.dto.UiSessionClosingReason; +import org.teamapps.dto.UiQuery; +import org.teamapps.dto.UiRootPanel; +import org.teamapps.dto.UiWindow; +import org.teamapps.icons.SessionIconProvider; +import org.teamapps.server.UxServerContext; +import org.teamapps.testutil.UxTestUtil; +import org.teamapps.uisession.TeamAppsComponentNotFoundException; +import org.teamapps.uisession.UiCommandWithResultCallback; +import org.teamapps.uisession.UiSession; +import org.teamapps.ux.component.div.Div; +import org.teamapps.ux.component.notification.Notification; +import org.teamapps.ux.component.notification.NotificationPosition; +import org.teamapps.ux.component.popup.Popup; +import org.teamapps.ux.component.rootpanel.RootPanel; +import org.teamapps.ux.component.window.Window; +import org.teamapps.ux.session.navigation.Location; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.teamapps.common.TeamAppsVersion.TEAMAPPS_VERSION; + +/** + * These tests rely on {@link System#gc()} actually triggering a garbage collection in order to observe + * weak-reference-based client object collection. They will not work (time out via Awaitility) when the JVM + * is run with {@code -XX:+DisableExplicitGC}. + */ +public class SessionContextClientObjectGcTest { + + private final UiSession uiSession = Mockito.mock(UiSession.class); + private final List createdExecutors = new ArrayList<>(); + + @After + public void shutDownExecutors() { + createdExecutors.forEach(ExecutorService::shutdown); // shutdown() is idempotent — some session contexts may already have shut their executor down + } + + private SessionContext createSessionContext(boolean clientObjectGarbageCollectionEnabled) { + ClientInfo clientInfo = new ClientInfo("ip", 1024, 768, 1000, 700, "en", false, "Europe/Berlin", 120, Collections.emptyList(), + "userAgentString", Mockito.mock(Location.class), Collections.emptyMap(), TEAMAPPS_VERSION); + ExecutorService sessionExecutor = Executors.newSingleThreadExecutor(); + createdExecutors.add(sessionExecutor); + return new SessionContext( + uiSession, + sessionExecutor, + clientInfo, SessionConfiguration.createForClientInfo(clientInfo), Mockito.mock(HttpSession.class), + Mockito.mock(UxServerContext.class), + Mockito.mock(SessionIconProvider.class), + "", + Mockito.mock(ParamConverterProvider.class), + clientObjectGarbageCollectionEnabled + ); + } + + @Test + public void unreferencedComponentGetsCollectedAndClientSideCounterpartDestroyed() { + SessionContext sessionContext = createSessionContext(true); + String componentId = renderThrowAwayComponent(sessionContext); + + Awaitility.await().atMost(30, SECONDS).until(() -> { + System.gc(); + UxTestUtil.runWithSessionContext(sessionContext, sessionContext::drainCollectedClientObjects); + return !destroyCommandsSentFor(componentId).isEmpty(); + }); + + assertThat(sessionContext.getClientObject(componentId)).isNull(); + assertThat(destroyCommandsSentFor(componentId)).hasSize(1); + assertThat(sessionContext.getCollectedClientObjectsCount()).isEqualTo(1); + } + + @Test + public void componentsStayReferencedWhenGcIsDisabled() { + SessionContext sessionContext = createSessionContext(false); + String componentId = renderThrowAwayComponent(sessionContext); + + attemptGc(sessionContext); + + assertThat(sessionContext.getClientObject(componentId)).isNotNull(); + assertThat(destroyCommandsSentFor(componentId)).isEmpty(); + } + + @Test + public void closedWindowStaysReferencedWhenGcIsDisabled() { + SessionContext sessionContext = createSessionContext(false); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Window window = new Window(); + window.show(0); + id.set(window.getId()); + window.close(0); + }); + + attemptGc(sessionContext); + + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + assertThat(destroyCommandsSentFor(id.get())).isEmpty(); + } + + @Test + public void rootPanelStaysReferencedAlthoughApplicationDropsIt() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + RootPanel rootPanel = sessionContext.addRootPanel(); + id.set(rootPanel.getId()); + }); + + attemptGc(sessionContext); + + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + } + + @Test + public void allRootPanelsForTheSameSelectorStayReferenced() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference firstId = new AtomicReference<>(); + AtomicReference secondId = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + firstId.set(sessionContext.addRootPanel().getId()); // container selector "body" + secondId.set(sessionContext.addRootPanel().getId()); // same selector: the client appends, displaying both + }); + + attemptGc(sessionContext); + + // UiRootPanel.buildRootPanel appends to the container element, so both root panels remain displayed + // and must both stay strongly referenced (see SessionContext#attachedRootComponents) + assertThat(sessionContext.getClientObject(firstId.get())).isNotNull(); + assertThat(sessionContext.getClientObject(secondId.get())).isNotNull(); + assertThat(destroyCommandsSentFor(firstId.get())).isEmpty(); + assertThat(destroyCommandsSentFor(secondId.get())).isEmpty(); + } + + @Test + public void shownWindowIsPinnedAndCollectableAfterServerSideClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Window window = new Window(); + window.show(0); + id.set(window.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + UxTestUtil.runWithSessionContext(sessionContext, () -> ((Window) sessionContext.getClientObject(id.get())).close(0)); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void shownWindowIsCollectableAfterClientSideClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Window window = new Window(); + window.show(0); + id.set(window.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + UxTestUtil.runWithSessionContext(sessionContext, + () -> ((Window) sessionContext.getClientObject(id.get())).handleUiEvent(new UiWindow.ClosedEvent(id.get()))); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void shownPopupIsPinnedAndCollectableAfterClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = new Popup(new Div()); + sessionContext.showPopup(popup); + id.set(popup.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + UxTestUtil.runWithSessionContext(sessionContext, () -> ((Popup) sessionContext.getClientObject(id.get())).close()); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void shownPopupIsCollectableAfterClientSideClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = new Popup(new Div()); + sessionContext.showPopup(popup); + id.set(popup.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + AtomicBoolean onClosedFired = new AtomicBoolean(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = (Popup) sessionContext.getClientObject(id.get()); + popup.onClosed.addListener(() -> onClosedFired.set(true)); + popup.handleUiEvent(new UiPopup.ClosedEvent(id.get())); + }); + assertThat(onClosedFired).isTrue(); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void popupServerSideCloseAfterClientSideCloseIsHarmless() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = new Popup(new Div()); + sessionContext.showPopup(popup); + id.set(popup.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = (Popup) sessionContext.getClientObject(id.get()); + popup.handleUiEvent(new UiPopup.ClosedEvent(id.get())); + popup.close(); // double unpin must not throw + }); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void shownNotificationIsPinnedAndCollectableAfterClientSideClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Notification notification = new Notification(new Div()); + sessionContext.showNotification(notification, NotificationPosition.TOP_RIGHT); + id.set(notification.getId()); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + UxTestUtil.runWithSessionContext(sessionContext, + () -> ((Notification) sessionContext.getClientObject(id.get())).handleUiEvent(new UiNotification.ClosedEvent(id.get(), true))); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void repeatedServerSideShowAndCloseDoesNotAccumulateOnClosedListeners() throws Exception { + SessionContext sessionContext = createSessionContext(true); + AtomicReference notificationRef = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> notificationRef.set(new Notification(new Div()))); + Notification notification = notificationRef.get(); + int listenerCountBefore = listenerCount(notification.onClosed); + + for (int i = 0; i < 5; i++) { + UxTestUtil.runWithSessionContext(sessionContext, () -> { + sessionContext.showNotification(notification, NotificationPosition.TOP_RIGHT); + notification.close(); + }); + } + + assertThat(listenerCount(notification.onClosed)).isEqualTo(listenerCountBefore); + } + + @Test + public void notificationShownAgainAfterCloseIsPinnedAgainAndCollectableAfterFinalClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Notification notification = new Notification(new Div()); + id.set(notification.getId()); + sessionContext.showNotification(notification, NotificationPosition.TOP_RIGHT); + notification.close(); + sessionContext.showNotification(notification, NotificationPosition.TOP_RIGHT); + }); + + // pinned while showing (second show), although the application holds no reference + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + + // client-side close unpins again + UxTestUtil.runWithSessionContext(sessionContext, + () -> ((Notification) sessionContext.getClientObject(id.get())).handleUiEvent(new UiNotification.ClosedEvent(id.get(), true))); + awaitCollected(sessionContext, id.get()); + } + + private static int listenerCount(org.teamapps.event.Event event) throws Exception { + java.lang.reflect.Method getListeners = org.teamapps.event.Event.class.getDeclaredMethod("getListeners"); + getListeners.setAccessible(true); + return ((List) getListeners.invoke(event)).size(); + } + + @Test + public void explicitlyUnrenderedComponentDoesNotGetDestroyedTwice() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Div div = new Div(); + div.render(); + id.set(div.getId()); + div.unrender(); + }); + assertThat(destroyCommandsSentFor(id.get())).hasSize(1); + + // simulate the client acknowledging the destroy command, which unregisters the component + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(UiCommandWithResultCallback.class); + Mockito.verify(uiSession, Mockito.atLeastOnce()).sendCommand(commandCaptor.capture()); + UiCommandWithResultCallback destroyCommandWithCallback = commandCaptor.getAllValues().stream() + .filter(c -> c.getUiCommand() instanceof UiRootPanel.DestroyComponentCommand) + .findFirst().orElseThrow(); + UxTestUtil.runWithSessionContext(sessionContext, () -> destroyCommandWithCallback.getResultCallback().accept(null)); + assertThat(sessionContext.getClientObject(id.get())).isNull(); + + // let Mockito forget the recorded invocations (they strongly reference the component via the result callback) + Mockito.clearInvocations(uiSession); + attemptGc(sessionContext); + + assertThat(destroyCommandsSentFor(id.get())).isEmpty(); + } + + @Test + public void registrySizeStaysBoundedWhenContentIsReplacedRepeatedly() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference rootPanel = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> rootPanel.set(sessionContext.addRootPanel())); + + for (int i = 0; i < 100; i++) { + UxTestUtil.runWithSessionContext(sessionContext, () -> rootPanel.get().setContent(new Div(new Div()))); + } + // A natural GC during the loop may already have pruned registry entries (registerClientObject drains + // on every render), so assert on the deterministic sum: 1 root panel + 200 Divs were registered. + assertThat(sessionContext.getClientObjectCount() + sessionContext.getCollectedClientObjectsCount()).isGreaterThanOrEqualTo(201); + + // all discarded content (100 iterations à 2 Divs, minus the 2 still attached) must get collected + Awaitility.await().atMost(30, SECONDS).until(() -> { + System.gc(); + UxTestUtil.runWithSessionContext(sessionContext, sessionContext::drainCollectedClientObjects); + return sessionContext.getCollectedClientObjectsCount() >= 198; + }); + // the root panel and the currently attached Divs must survive + assertThat(sessionContext.getClientObject(rootPanel.get().getId())).isNotNull(); + assertThat(sessionContext.getClientObject(((Div) rootPanel.get().getContent()).getId())).isNotNull(); + } + + @Test + public void eventsAndQueriesForUnknownComponentsAreToleratedWhenGcEnabled() { + SessionContext sessionContext = createSessionContext(true); + + UxTestUtil.runWithSessionContext(sessionContext, + () -> sessionContext.getAsUiSessionListenerInternal().onUiEvent("session-id", new UiWindow.ClosedEvent("unknown-component-id"))); + + UiQuery query = Mockito.mock(UiQuery.class); + Mockito.when(query.getComponentId()).thenReturn("unknown-component-id"); + AtomicBoolean resultCallbackCalled = new AtomicBoolean(); + AtomicReference result = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, + () -> sessionContext.getAsUiSessionListenerInternal().onUiQuery("session-id", query, r -> { + resultCallbackCalled.set(true); + result.set(r); + })); + + assertThat(resultCallbackCalled).isTrue(); + assertThat(result.get()).isNull(); + // the session must not have been destroyed + Mockito.verify(uiSession, Mockito.never()).close(Mockito.any()); + } + + @Test + public void eventsAndQueriesForUnknownComponentsThrowWhenGcDisabled() { + SessionContext sessionContext = createSessionContext(false); + + assertThatThrownBy(() -> UxTestUtil.runWithSessionContext(sessionContext, + () -> sessionContext.getAsUiSessionListenerInternal().onUiEvent("session-id", new UiWindow.ClosedEvent("unknown-component-id")))) + .isInstanceOf(FastLaneExecutionException.class) + .hasCauseInstanceOf(TeamAppsComponentNotFoundException.class); + + UiQuery query = Mockito.mock(UiQuery.class); + Mockito.when(query.getComponentId()).thenReturn("unknown-component-id"); + AtomicBoolean resultCallbackCalled = new AtomicBoolean(); + assertThatThrownBy(() -> UxTestUtil.runWithSessionContext(sessionContext, + () -> sessionContext.getAsUiSessionListenerInternal().onUiQuery("session-id", query, r -> resultCallbackCalled.set(true)))) + .isInstanceOf(FastLaneExecutionException.class) + .hasCauseInstanceOf(TeamAppsComponentNotFoundException.class); + assertThat(resultCallbackCalled).isFalse(); + } + + @Test + public void eventForUnknownComponentDestroysSessionWhenGcDisabled() { + SessionContext sessionContext = createSessionContext(false); + + // not bound to the session context -> async execution path, which destroys the session on error + sessionContext.getAsUiSessionListenerInternal().onUiEvent("session-id", new UiWindow.ClosedEvent("unknown-component-id")); + + Mockito.verify(uiSession, Mockito.timeout(10_000)).close(UiSessionClosingReason.SERVER_SIDE_ERROR); + } + + @Test + public void doublePinnedObjectStaysPinnedUntilUnpinnedTwice() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Div div = new Div(); + div.render(); + id.set(div.getId()); + sessionContext.pinClientObject(div); + sessionContext.pinClientObject(div); + sessionContext.unpinClientObject(div); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); // one pin left + + UxTestUtil.runWithSessionContext(sessionContext, () -> sessionContext.unpinClientObject(sessionContext.getClientObject(id.get()))); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void unpinWithoutPinDoesNotStealSubsequentPins() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Div div = new Div(); + div.render(); + id.set(div.getId()); + sessionContext.unpinClientObject(div); // spurious unpin must be a no-op, not a negative count + sessionContext.pinClientObject(div); + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + } + + @Test + public void windowCloseFollowedByClientClosedEventDoesNotUnderflowPinning() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Window window = new Window(); + window.show(0); + id.set(window.getId()); + window.close(0); // server-side close unpins + window.handleUiEvent(new UiWindow.ClosedEvent(window.getId())); // racing client close event must not unpin again + window.show(0); // re-shown once -> exactly one pin + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); // re-shown window must stay pinned + + UxTestUtil.runWithSessionContext(sessionContext, () -> ((Window) sessionContext.getClientObject(id.get())).close(0)); + awaitCollected(sessionContext, id.get()); + } + + @Test + public void shownPopupWithAdditionalApplicationPinSurvivesClose() { + SessionContext sessionContext = createSessionContext(true); + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Popup popup = new Popup(new Div()); + sessionContext.showPopup(popup); + sessionContext.pinClientObject(popup); // independent second pin reason + id.set(popup.getId()); + popup.close(); // releases only the show-pin + }); + + attemptGc(sessionContext); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); // application pin must still hold + + UxTestUtil.runWithSessionContext(sessionContext, () -> sessionContext.unpinClientObject(sessionContext.getClientObject(id.get()))); + awaitCollected(sessionContext, id.get()); + } + + private String renderThrowAwayComponent(SessionContext sessionContext) { + AtomicReference id = new AtomicReference<>(); + UxTestUtil.runWithSessionContext(sessionContext, () -> { + Div div = new Div(); + div.render(); + id.set(div.getId()); + assertThat(sessionContext.getClientObject(id.get())).isNotNull(); + }); + return id.get(); + } + + private void awaitCollected(SessionContext sessionContext, String componentId) { + Awaitility.await().atMost(30, SECONDS).until(() -> { + System.gc(); + UxTestUtil.runWithSessionContext(sessionContext, sessionContext::drainCollectedClientObjects); + return sessionContext.getClientObject(componentId) == null; + }); + } + + private void attemptGc(SessionContext sessionContext) { + for (int i = 0; i < 5; i++) { + System.gc(); + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + UxTestUtil.runWithSessionContext(sessionContext, sessionContext::drainCollectedClientObjects); + } + } + + private List destroyCommandsSentFor(String componentId) { + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(UiCommandWithResultCallback.class); + Mockito.verify(uiSession, Mockito.atLeast(0)).sendCommand(commandCaptor.capture()); + return commandCaptor.getAllValues().stream() + .map(UiCommandWithResultCallback::getUiCommand) + .filter(c -> c instanceof UiRootPanel.DestroyComponentCommand) + .map(c -> (UiRootPanel.DestroyComponentCommand) c) + .filter(c -> componentId.equals(c.getId())) + .collect(Collectors.toList()); + } +}