From e3d9a7f15b93df3e52354d96598e48fe2daa8933 Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 11:01:24 +0000 Subject: [PATCH 1/8] Garbage collection of unused components within live sessions Components used to stay strongly referenced in SessionContext's registry for the whole session lifetime, so long-lived browser tabs accumulated every component ever rendered and eventually caused server OOM (the browser-side registry leaked identically). The registry now holds weak references: while a component is displayed (reachable from a root panel, shown window/popup/notification, context menu, toolbar dropdown) or referenced by application code, it stays alive; once unreachable, the JVM collects it and a ReferenceQueue drain sends UiRootPanel.destroyComponent so the client frees its DOM and registry entry too. Draining happens on component registration and every 10 seconds in TeamAppsSessionManager's housekeeping loop. Gated by TeamAppsConfiguration.clientObjectGarbageCollectionEnabled, default OFF. With the flag off, behavior is strictly historical (every registered component is pinned; unpin is a no-op), with one deliberate exception: events/queries for unknown component ids now log a warning instead of throwing TeamAppsComponentNotFoundException, which used to destroy the entire session. With the flag ON, note two behavior changes: - render-and-forget of NOT-displayed components breaks: apps must hold a Java reference to any component they want to reuse later - custom components referencing a temporary component only through a queued command must pin it via the new SessionContext.pinClientObject/unpinClientObject escape hatch New observability: SessionContext.getClientObjectCount() / getCollectedClientObjectsCount(), surfaced in UiSessionStats. Co-Authored-By: Claude Fable 5 --- .../config/TeamAppsConfiguration.java | 25 ++ .../uisession/TeamAppsSessionManager.java | 17 +- .../statistics/ImmutableUiSessionStats.java | 18 +- .../statistics/RunningUiSessionStats.java | 22 +- .../uisession/statistics/UiSessionStats.java | 10 + .../ux/component/chat/ChatDisplay.java | 3 + .../infiniteitemview/InfiniteItemView.java | 3 + .../infiniteitemview/InfiniteItemView2.java | 3 + .../component/notification/Notification.java | 4 + .../teamapps/ux/component/popup/Popup.java | 2 + .../teamapps/ux/component/table/Table.java | 3 + .../ux/component/toolbar/ToolbarButton.java | 18 +- .../org/teamapps/ux/component/tree/Tree.java | 3 + .../webrtc/MediaSoupV3WebRtcClient.java | 3 + .../teamapps/ux/component/window/Window.java | 5 + .../teamapps/ux/session/SessionContext.java | 168 +++++++++- .../org/teamapps/testutil/UxTestUtil.java | 7 +- .../SessionContextClientObjectGcTest.java | 317 ++++++++++++++++++ 18 files changed, 610 insertions(+), 21 deletions(-) create mode 100644 teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java 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..429749b71 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,17 @@ 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). + */ + private boolean clientObjectGarbageCollectionEnabled = false; + public TeamAppsConfiguration() { } @@ -371,4 +382,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..6c9070ced 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,19 @@ 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); + } + 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 +313,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/chat/ChatDisplay.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/chat/ChatDisplay.java index 768960313..947ad60d1 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,7 @@ public class ChatDisplay extends AbstractComponent { private Icon deletedMessageIcon = MaterialIcon.DELETE.withStyle(MaterialIconStyles.OUTLINE_GREY_900); private Function contextMenuProvider = null; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected public ChatDisplay(ChatDisplayModel model) { this.model = model; @@ -98,6 +99,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 +177,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..cc2b96dca 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,7 @@ public class InfiniteItemView extends AbstractComponent { private final Consumer> modelOnRecordDeletedListener = x -> this.refresh(); private Function contextMenuProvider = null; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected private int lastSeenContextMenuRequestId; private int displayedRangeStart = 0; @@ -148,6 +149,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 +313,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..acd459cf7 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,7 @@ public class InfiniteItemView2 extends AbstractInfiniteListComponent contextMenuProvider = null; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected private int lastSeenContextMenuRequestId; private boolean selectionEnabled; @@ -131,6 +132,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 +188,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..d7ed08791 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 @@ -97,6 +97,10 @@ public void handleUiEvent(UiEvent event) { } public void close() { + // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the strong reference + // created in SessionContext.showNotification() here (the one-shot onClosed listener stays until fired once, + // which is harmless) + getSessionContext().unpinClientObject(this); 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..45657fc13 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 @@ -157,6 +157,8 @@ public void setCloseOnClickOutside(boolean closeOnClickOutside) { } public void close() { + // release the strong reference created in SessionContext.showPopup*() + getSessionContext().unpinClientObject(this); queueCommandIfRendered(() -> new UiPopup.CloseCommand(getId())); } } 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..267b59b3d 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,7 @@ public class Table extends AbstractInfiniteListComponent contextMenuProvider = null; private int lastSeenContextMenuRequestId; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected private int rowBorderWidth; public Table() { @@ -352,6 +353,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 +1250,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..06ce5faa9 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,20 @@ 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; } 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..623fb12b6 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,7 @@ public class Tree extends AbstractComponent { private boolean enforceSingleExpandedPath = false; private Function contextMenuProvider = null; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected private int lastSeenContextMenuRequestId; private Function recordToStringFunction = Object::toString; @@ -245,6 +246,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 +341,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..f7a463c77 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,7 @@ public class MediaSoupV3WebRtcClient extends AbstractComponent { private UiMediaSoupPlaybackParameters playbackParameters; private Supplier contextMenuProvider = null; + private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected private int lastSeenContextMenuRequestId; private boolean bitrateDisplayEnabled; @@ -150,6 +151,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 +361,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..b16719c68 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() + getSessionContext().unpinClientObject(this); onClosed.fire(); } } @@ -194,6 +196,7 @@ public void show() { } public void show(int animationDuration) { + getSessionContext().pinClientObject(this); // 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 + getSessionContext().unpinClientObject(this); 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..dfbd10584 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 @@ -29,6 +29,7 @@ import org.teamapps.dto.*; import org.teamapps.event.Disposable; import org.teamapps.event.Event; +import org.teamapps.event.SelfDisposingEventListener; import org.teamapps.icons.Icon; import org.teamapps.icons.SessionIconProvider; import org.teamapps.server.UxServerContext; @@ -59,10 +60,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 +81,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 +119,28 @@ 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, context menus etc. Mutated only within the session context. + */ + private final Set pinnedClientObjects = Collections.newSetFromMap(new IdentityHashMap<>()); + /** + * Strong references to components attached as root panels ({@link #addRootPanel(String, Component)}). Never released + * (there is no API for removing a root panel). + */ + 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 +155,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; @@ -141,7 +181,8 @@ public void onUiEvent(String sessionId, UiEvent event) { if (clientObject != null) { clientObject.handleUiEvent(event); } else { - throw new TeamAppsComponentNotFoundException(sessionId, uiComponentId); + // 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 { handleStaticEvent(event); @@ -160,7 +201,11 @@ public void onUiQuery(String sessionId, UiQuery query, Consumer resultCa resultCallback.accept(result); }); } else { - throw new TeamAppsComponentNotFoundException(sessionId, uiComponentId); + // 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); + new UxJacksonSerializationTemplate(SessionContext.this).doWithUxJacksonSerializers(() -> { + resultCallback.accept(null); + }); } }); } @@ -199,8 +244,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 +621,12 @@ private void sendConfigToClient() { } public void showPopupAtCurrentMousePosition(Popup popup) { + pinClientObject(popup); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupAtCurrentMousePositionCommand(popup.createUiReference())); } public void showPopup(Popup popup) { + pinClientObject(popup); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupCommand(popup.createUiReference())); } @@ -604,15 +653,94 @@ 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.add(clientObject); + } } public void unregisterClientObject(ClientObject clientObject) { - clientObjectsById.remove(clientObject.getId()); + 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. + * Must be invoked with this session context bound to the current thread. + */ + public void pinClientObject(ClientObject clientObject) { + CurrentSessionContext.throwIfNotSameAs(this); + pinnedClientObjects.add(clientObject); + } + + /** + * Releases a strong reference created by {@link #pinClientObject(ClientObject)}. + * 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.remove(clientObject); + } + + /** + * 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 +796,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 +827,11 @@ public void clearClientTokens() { public void showNotification(Notification notification, NotificationPosition position, EntranceAnimation entranceAnimation, ExitAnimation exitAnimation) { runWithContext(() -> { + pinClientObject(notification); + notification.onClosed.addListener((SelfDisposingEventListener) (byUser, disposable) -> { + unpinClientObject(notification); + disposable.dispose(); + }); queueCommand(new UiRootPanel.ShowNotificationCommand(notification.createUiReference(), position.toUiNotificationPosition(), entranceAnimation.toUiEntranceAnimation(), exitAnimation.toUiExitAnimation())); }); @@ -755,14 +889,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..b2e951f14 --- /dev/null +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -0,0 +1,317 @@ +/*- + * ========================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.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.teamapps.dto.UiNotification; +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.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.Collections; +import java.util.List; +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.teamapps.common.TeamAppsVersion.TEAMAPPS_VERSION; + +public class SessionContextClientObjectGcTest { + + private final UiSession uiSession = Mockito.mock(UiSession.class); + + 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); + return new SessionContext( + uiSession, + Executors.newSingleThreadExecutor(), + 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 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 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 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()))); + } + assertThat(sessionContext.getClientObjectCount()).isGreaterThanOrEqualTo(200); // the leak, before collection + + // 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 eventsAndQueriesForUnknownComponentsAreTolerated() { + 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()); + } + + 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()); + } +} From 3bcdb3fc5d0b899bf5a358d067699e9e987cf34a Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:46:05 +0000 Subject: [PATCH 2/8] Document deliberate accumulation of attached root panels and pin semantics with a test The client-side UiRootPanel.buildRootPanel appends the root panel to the container element instead of replacing an existing one, so every component attached via SessionContext.addRootPanel stays displayed for the rest of the session and must stay strongly referenced. Clarify this in the Javadoc of attachedRootComponents (it must not be keyed by container selector) and add a test asserting that two root panels added for the same selector both stay referenced under client-object garbage collection. Co-Authored-By: Claude Fable 5 --- .../teamapps/ux/session/SessionContext.java | 8 ++++++-- .../SessionContextClientObjectGcTest.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) 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 dfbd10584..d75ba3998 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 @@ -135,8 +135,12 @@ private static class ClientObjectWeakReference extends WeakReference pinnedClientObjects = Collections.newSetFromMap(new IdentityHashMap<>()); /** - * Strong references to components attached as root panels ({@link #addRootPanel(String, Component)}). Never released - * (there is no API for removing a root panel). + * 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 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 index b2e951f14..b4a85283d 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -130,6 +130,26 @@ public void rootPanelStaysReferencedAlthoughApplicationDropsIt() { 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); From f97574ae6b85593de99340ad287210965a5dc9da Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:47:52 +0000 Subject: [PATCH 3/8] Throw TeamAppsComponentNotFoundException for unknown client objects when GC is disabled The client-object GC commit unconditionally changed handling of UI events/queries referencing unknown client object ids from fail-fast to warn-and-continue. That tolerant path is only justified when garbage collection is enabled, where an event/query can legitimately race with collection of its target. With the flag off (the default), an unknown id indicates a genuine client/server desync, so the historical fail-fast behavior is restored: TeamAppsComponentNotFoundException is thrown and the session is destroyed with SERVER_SIDE_ERROR. Co-Authored-By: Claude Fable 5 --- .../teamapps/ux/session/SessionContext.java | 8 +++-- .../SessionContextClientObjectGcTest.java | 34 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) 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 d75ba3998..5f2dc9d10 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 @@ -184,9 +184,11 @@ public void onUiEvent(String sessionId, UiEvent event) { ClientObject clientObject = getClientObject(uiComponentId); if (clientObject != null) { clientObject.handleUiEvent(event); - } else { + } 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); } } else { handleStaticEvent(event); @@ -204,12 +206,14 @@ public void onUiQuery(String sessionId, UiQuery query, Consumer resultCa new UxJacksonSerializationTemplate(SessionContext.this).doWithUxJacksonSerializers(() -> { resultCallback.accept(result); }); - } else { + } 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); new UxJacksonSerializationTemplate(SessionContext.this).doWithUxJacksonSerializers(() -> { resultCallback.accept(null); }); + } else { + throw new TeamAppsComponentNotFoundException(sessionId, uiComponentId); } }); } 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 index b4a85283d..173c01b49 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -26,12 +26,14 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.teamapps.dto.UiNotification; +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; @@ -51,6 +53,7 @@ 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; public class SessionContextClientObjectGcTest { @@ -271,7 +274,7 @@ public void registrySizeStaysBoundedWhenContentIsReplacedRepeatedly() { } @Test - public void eventsAndQueriesForUnknownComponentsAreTolerated() { + public void eventsAndQueriesForUnknownComponentsAreToleratedWhenGcEnabled() { SessionContext sessionContext = createSessionContext(true); UxTestUtil.runWithSessionContext(sessionContext, @@ -293,6 +296,35 @@ public void eventsAndQueriesForUnknownComponentsAreTolerated() { 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); + } + private String renderThrowAwayComponent(SessionContext sessionContext) { AtomicReference id = new AtomicReference<>(); UxTestUtil.runWithSessionContext(sessionContext, () -> { From 6e78c04071a5a52bbce241b6ab64c177456b6125 Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:48:54 +0000 Subject: [PATCH 4/8] Fix onClosed listener leak on repeated Notification show/close SessionContext.showNotification() added a SelfDisposingEventListener to notification.onClosed on every call, but a server-initiated Notification.close() never fires onClosed, so repeated show/close cycles on the same Notification instance accumulated listeners. Move the pin/unpin bookkeeping into Notification itself: showNotification() pins via the idempotent pinUntilClosed(), and Notification unpins exactly once per cycle from either close path (close() or UI_NOTIFICATION_CLOSED), without registering any per-show listeners. Co-Authored-By: Claude Fable 5 --- .../component/notification/Notification.java | 30 ++++++++++-- .../teamapps/ux/session/SessionContext.java | 7 +-- .../SessionContextClientObjectGcTest.java | 46 +++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) 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 d7ed08791..0712e2575 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 @@ -37,6 +37,9 @@ public class Notification extends AbstractComponent { private boolean showing; + // whether this notification is currently strongly referenced (pinned) by the session context, see pinUntilClosed() + private boolean pinned; + private Color backgroundColor = null; private Spacing padding = null; private int displayTimeInMillis = 3000; @@ -90,17 +93,38 @@ public void handleUiEvent(UiEvent event) { } case UI_NOTIFICATION_CLOSED: { this.showing = false; + unpinIfPinned(); onClosed.fire(((UiNotification.ClosedEvent) event).getByUser()); break; } } } + /** + * Internal API, used by {@link org.teamapps.ux.session.SessionContext#showNotification}, do not call from application code! + *

+ * Strongly references this notification from the session context while it is showing, so it does not get garbage + * collected even if the application does not keep a reference to it. Idempotent: pins at most once until the next + * close, whether server-initiated ({@link #close()}) or client-initiated (UI_NOTIFICATION_CLOSED). + */ + public void pinUntilClosed() { + if (!pinned) { + pinned = true; + getSessionContext().pinClientObject(this); + } + } + + private void unpinIfPinned() { + if (pinned) { + pinned = false; + getSessionContext().unpinClientObject(this); + } + } + public void close() { // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the strong reference - // created in SessionContext.showNotification() here (the one-shot onClosed listener stays until fired once, - // which is harmless) - getSessionContext().unpinClientObject(this); + // created by pinUntilClosed() here + unpinIfPinned(); queueCommandIfRendered(() -> new UiNotification.CloseCommand(getId())); } 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 5f2dc9d10..3df3c4e6b 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 @@ -29,7 +29,6 @@ import org.teamapps.dto.*; import org.teamapps.event.Disposable; import org.teamapps.event.Event; -import org.teamapps.event.SelfDisposingEventListener; import org.teamapps.icons.Icon; import org.teamapps.icons.SessionIconProvider; import org.teamapps.server.UxServerContext; @@ -835,11 +834,7 @@ public void clearClientTokens() { public void showNotification(Notification notification, NotificationPosition position, EntranceAnimation entranceAnimation, ExitAnimation exitAnimation) { runWithContext(() -> { - pinClientObject(notification); - notification.onClosed.addListener((SelfDisposingEventListener) (byUser, disposable) -> { - unpinClientObject(notification); - disposable.dispose(); - }); + notification.pinUntilClosed(); // unpinned in Notification.close() or on UI_NOTIFICATION_CLOSED queueCommand(new UiRootPanel.ShowNotificationCommand(notification.createUiReference(), position.toUiNotificationPosition(), entranceAnimation.toUiEntranceAnimation(), exitAnimation.toUiExitAnimation())); }); 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 index 173c01b49..1238e7a60 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -223,6 +223,52 @@ public void shownNotificationIsPinnedAndCollectableAfterClientSideClose() { 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); From f85c854de1042510c21548060137f2702e949943 Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:50:20 +0000 Subject: [PATCH 5/8] Minor cleanups for client object garbage collection - SessionContext: reuse the uxJacksonSerializationTemplate field in onUiQuery instead of allocating a new template per query - Extend the lastContextMenuComponent field comment in Table, Tree, InfiniteItemView, InfiniteItemView2, MediaSoupV3WebRtcClient and ChatDisplay to document the deliberate retention semantics - TeamAppsSessionManager: note that client object counts may lag one housekeeping cycle behind the asynchronous drain - SessionContextClientObjectGcTest: make the pre-collection assertion robust against natural GC, shut down created session executors after each test, and document the System.gc() dependence - TeamAppsConfiguration: document that listener registrations on application-scoped objects keep components strongly reachable - ToolbarButton: document the drop-down component supplier caching Co-Authored-By: Claude Fable 5 --- .../config/TeamAppsConfiguration.java | 4 ++++ .../uisession/TeamAppsSessionManager.java | 2 ++ .../ux/component/chat/ChatDisplay.java | 5 ++++- .../infiniteitemview/InfiniteItemView.java | 5 ++++- .../infiniteitemview/InfiniteItemView2.java | 5 ++++- .../teamapps/ux/component/table/Table.java | 5 ++++- .../ux/component/toolbar/ToolbarButton.java | 8 +++++++ .../org/teamapps/ux/component/tree/Tree.java | 5 ++++- .../webrtc/MediaSoupV3WebRtcClient.java | 5 ++++- .../teamapps/ux/session/SessionContext.java | 4 ++-- .../SessionContextClientObjectGcTest.java | 22 +++++++++++++++++-- 11 files changed, 60 insertions(+), 10 deletions(-) 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 429749b71..49f821141 100644 --- a/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java +++ b/teamapps-ux/src/main/java/org/teamapps/config/TeamAppsConfiguration.java @@ -178,6 +178,10 @@ public class TeamAppsConfiguration { *

* 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; 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 6c9070ced..8830dbeff 100644 --- a/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java +++ b/teamapps-ux/src/main/java/org/teamapps/uisession/TeamAppsSessionManager.java @@ -124,6 +124,8 @@ public TeamAppsSessionManager(TeamAppsConfiguration config, ObjectMapper objectM } 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()); 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 947ad60d1..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,7 +44,10 @@ public class ChatDisplay extends AbstractComponent { private Icon deletedMessageIcon = MaterialIcon.DELETE.withStyle(MaterialIconStyles.OUTLINE_GREY_900); private Function contextMenuProvider = null; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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; 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 cc2b96dca..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,7 +69,10 @@ public class InfiniteItemView extends AbstractComponent { private final Consumer> modelOnRecordDeletedListener = x -> this.refresh(); private Function contextMenuProvider = null; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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; 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 acd459cf7..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,7 +59,10 @@ public class InfiniteItemView2 extends AbstractInfiniteListComponent contextMenuProvider = null; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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; 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 267b59b3d..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,7 +123,10 @@ public class Table extends AbstractInfiniteListComponent contextMenuProvider = null; private int lastSeenContextMenuRequestId; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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() { 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 06ce5faa9..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 @@ -295,6 +295,14 @@ public Supplier getDropDownComponentSupplier() { 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; 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 623fb12b6..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,7 +66,10 @@ public class Tree extends AbstractComponent { private boolean enforceSingleExpandedPath = false; private Function contextMenuProvider = null; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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; 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 f7a463c77..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,7 +69,10 @@ public class MediaSoupV3WebRtcClient extends AbstractComponent { private UiMediaSoupPlaybackParameters playbackParameters; private Supplier contextMenuProvider = null; - private Component lastContextMenuComponent; // strong reference — displayed context menus must not get garbage collected + // 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; 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 3df3c4e6b..782a5f47a 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 @@ -202,13 +202,13 @@ 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); - new UxJacksonSerializationTemplate(SessionContext.this).doWithUxJacksonSerializers(() -> { + uxJacksonSerializationTemplate.doWithUxJacksonSerializers(() -> { resultCallback.accept(null); }); } else { 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 index 1238e7a60..100ae78f2 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -22,6 +22,7 @@ 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; @@ -44,8 +45,10 @@ 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; @@ -56,16 +59,29 @@ 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, - Executors.newSingleThreadExecutor(), + sessionExecutor, clientInfo, SessionConfiguration.createForClientInfo(clientInfo), Mockito.mock(HttpSession.class), Mockito.mock(UxServerContext.class), Mockito.mock(SessionIconProvider.class), @@ -306,7 +322,9 @@ public void registrySizeStaysBoundedWhenContentIsReplacedRepeatedly() { for (int i = 0; i < 100; i++) { UxTestUtil.runWithSessionContext(sessionContext, () -> rootPanel.get().setContent(new Div(new Div()))); } - assertThat(sessionContext.getClientObjectCount()).isGreaterThanOrEqualTo(200); // the leak, before collection + // 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(() -> { From e808f4dc304090b7deed7629df37e2b10f209b2f Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:52:06 +0000 Subject: [PATCH 6/8] Make client-object pinning reference-counted and guard unregisterClientObject Pinning was set-based, so an object pinned for two independent reasons (e.g. a shown popup additionally pinned by application code) was fully released by a single unpin, allowing a still-displayed component to be garbage collected and destroyed client-side. Pins are now reference- counted in SessionContext (saturating at zero), and Window, Popup and Notification guard their show/close pin with a pinnedWhileShowing flag so each show/close cycle pins and unpins exactly once, even on double-close, close-without-show, or a racing client-side close event. The stacking one-shot onClosed listener in showNotification is replaced by direct unpinning in Notification.handleUiEvent. Also adds the missing CurrentSessionContext.throwIfNotSameAs guard to SessionContext.unregisterClientObject, which mutates the non-thread-safe pin structure and registry like the other guarded mutators. Co-Authored-By: Claude Fable 5 --- .../component/notification/Notification.java | 29 ++++--- .../teamapps/ux/component/popup/Popup.java | 20 ++++- .../teamapps/ux/component/window/Window.java | 17 ++++- .../teamapps/ux/session/SessionContext.java | 26 ++++--- .../SessionContextClientObjectGcTest.java | 75 +++++++++++++++++++ 5 files changed, 137 insertions(+), 30 deletions(-) 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 0712e2575..8ad671bef 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 @@ -36,9 +36,7 @@ public class Notification extends AbstractComponent { public final Event onClosed = new Event<>(); private boolean showing; - - // whether this notification is currently strongly referenced (pinned) by the session context, see pinUntilClosed() - private boolean pinned; + private boolean pinnedWhileShowing; private Color backgroundColor = null; private Spacing padding = null; @@ -93,7 +91,7 @@ public void handleUiEvent(UiEvent event) { } case UI_NOTIFICATION_CLOSED: { this.showing = false; - unpinIfPinned(); + unpinAfterClose(); onClosed.fire(((UiNotification.ClosedEvent) event).getByUser()); break; } @@ -103,28 +101,27 @@ public void handleUiEvent(UiEvent event) { /** * Internal API, used by {@link org.teamapps.ux.session.SessionContext#showNotification}, do not call from application code! *

- * Strongly references this notification from the session context while it is showing, so it does not get garbage - * collected even if the application does not keep a reference to it. Idempotent: pins at most once until the next - * close, whether server-initiated ({@link #close()}) or client-initiated (UI_NOTIFICATION_CLOSED). + * Pins this notification in its session context while it is showing, so it does not get garbage collected even if + * the application does not keep a reference to it. Idempotent until the notification closes and releases the pin, + * whether server-initiated ({@link #close()}) or client-initiated (UI_NOTIFICATION_CLOSED). */ - public void pinUntilClosed() { - if (!pinned) { - pinned = true; + public void pinWhileShowing() { + if (!pinnedWhileShowing) { + pinnedWhileShowing = true; getSessionContext().pinClientObject(this); } } - private void unpinIfPinned() { - if (pinned) { - pinned = false; + private void unpinAfterClose() { + if (pinnedWhileShowing) { + pinnedWhileShowing = false; getSessionContext().unpinClientObject(this); } } public void close() { - // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the strong reference - // created by pinUntilClosed() here - unpinIfPinned(); + // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the pin here + unpinAfterClose(); 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 45657fc13..9b3204c73 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 @@ -38,6 +38,7 @@ public class Popup extends AbstractComponent { private Color dimmingColor = new RgbaColor(0, 0, 0, .2f); private boolean closeOnEscape; // close if the user presses escape private boolean closeOnClickOutside; // close if the user clicks onto the area outside the window + private boolean pinnedWhileShowing; public Popup(Component contentComponent) { this.contentComponent = contentComponent; @@ -156,9 +157,24 @@ public void setCloseOnClickOutside(boolean closeOnClickOutside) { // queueCommandIfRendered(() -> new UiPopup.SetCloseOnClickOutsideCommand(getId(), closeOnClickOutside)); } + /** + * Pins this popup in its session context while it is showing (called by {@link org.teamapps.ux.session.SessionContext#showPopup(Popup)} + * and {@link org.teamapps.ux.session.SessionContext#showPopupAtCurrentMousePosition(Popup)}). + * Idempotent until {@link #close()} releases the pin. + */ + public void pinWhileShowing() { + if (!pinnedWhileShowing) { + pinnedWhileShowing = true; + getSessionContext().pinClientObject(this); + } + } + public void close() { - // release the strong reference created in SessionContext.showPopup*() - getSessionContext().unpinClientObject(this); + if (pinnedWhileShowing) { + // release the strong reference created when the popup was shown + pinnedWhileShowing = false; + getSessionContext().unpinClientObject(this); + } queueCommandIfRendered(() -> new UiPopup.CloseCommand(getId())); } } 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 b16719c68..ea47a2306 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 @@ -45,6 +45,7 @@ public class Window extends Panel { private boolean closeable; private boolean closeOnEscape; private boolean closeOnClickOutside; + private boolean pinnedWhileShowing; public Window() { } @@ -98,7 +99,7 @@ public void handleUiEvent(UiEvent event) { switch (event.getUiEventType()) { case UI_WINDOW_CLOSED -> { // user-initiated close (close button, escape, click outside) — release the strong reference created in show() - getSessionContext().unpinClientObject(this); + unpinAfterClose(); onClosed.fire(); } } @@ -196,7 +197,10 @@ public void show() { } public void show(int animationDuration) { - getSessionContext().pinClientObject(this); // shown windows must not get garbage collected; unpinned on close + if (!pinnedWhileShowing) { + pinnedWhileShowing = true; + getSessionContext().pinClientObject(this); // shown windows must not get garbage collected; unpinned on close + } render(); queueCommandIfRendered(() -> new UiWindow.ShowCommand(getId(), animationDuration)); } @@ -207,10 +211,17 @@ public void close() { public void close(int animationDuration) { // server-initiated close does not fire UI_WINDOW_CLOSED back, so unpin here - getSessionContext().unpinClientObject(this); + unpinAfterClose(); queueCommandIfRendered(() -> new UiWindow.CloseCommand(getId(), animationDuration)); } + private void unpinAfterClose() { + if (pinnedWhileShowing) { + pinnedWhileShowing = false; + getSessionContext().unpinClientObject(this); + } + } + public boolean isCloseable() { return closeable; } 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 782a5f47a..410028b80 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 @@ -130,9 +130,12 @@ private static class ClientObjectWeakReference extends WeakReference collectedClientObjectsQueue = new ReferenceQueue<>(); /** * Strong references to client objects that are displayed without necessarily being referenced by application code: - * shown windows, popups, notifications, context menus etc. Mutated only within the session context. + * 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 Set pinnedClientObjects = Collections.newSetFromMap(new IdentityHashMap<>()); + 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 @@ -628,12 +631,12 @@ private void sendConfigToClient() { } public void showPopupAtCurrentMousePosition(Popup popup) { - pinClientObject(popup); // unpinned in Popup.close() + popup.pinWhileShowing(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupAtCurrentMousePositionCommand(popup.createUiReference())); } public void showPopup(Popup popup) { - pinClientObject(popup); // unpinned in Popup.close() + popup.pinWhileShowing(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupCommand(popup.createUiReference())); } @@ -663,11 +666,12 @@ public void registerClientObject(ClientObject clientObject) { drainCollectedClientObjects(); clientObjectsById.put(clientObject.getId(), new ClientObjectWeakReference(clientObject, collectedClientObjectsQueue)); if (!clientObjectGarbageCollectionEnabled) { - pinnedClientObjects.add(clientObject); + pinnedClientObjects.putIfAbsent(clientObject, 1); } } public void unregisterClientObject(ClientObject clientObject) { + CurrentSessionContext.throwIfNotSameAs(this); ClientObjectWeakReference reference = clientObjectsById.get(clientObject.getId()); if (reference != null && reference.get() == clientObject) { clientObjectsById.remove(clientObject.getId()); @@ -685,15 +689,19 @@ public ClientObject getClientObject(String clientObjectId) { * (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.add(clientObject); + pinnedClientObjects.merge(clientObject, 1, Integer::sum); } /** - * Releases a strong reference created by {@link #pinClientObject(ClientObject)}. + * 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) { @@ -703,7 +711,7 @@ public void unpinClientObject(ClientObject clientObject) { // is destroyed (exact historical behavior) return; } - pinnedClientObjects.remove(clientObject); + pinnedClientObjects.computeIfPresent(clientObject, (o, count) -> count > 1 ? count - 1 : null); } /** @@ -834,7 +842,7 @@ public void clearClientTokens() { public void showNotification(Notification notification, NotificationPosition position, EntranceAnimation entranceAnimation, ExitAnimation exitAnimation) { runWithContext(() -> { - notification.pinUntilClosed(); // unpinned in Notification.close() or on UI_NOTIFICATION_CLOSED + notification.pinWhileShowing(); // unpinned when the notification closes (Notification.close() or client-side close event) queueCommand(new UiRootPanel.ShowNotificationCommand(notification.createUiReference(), position.toUiNotificationPosition(), entranceAnimation.toUiEntranceAnimation(), exitAnimation.toUiExitAnimation())); }); 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 index 100ae78f2..e881c32e9 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -389,6 +389,81 @@ public void eventForUnknownComponentDestroysSessionWhenGcDisabled() { 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, () -> { From 3cb8751fb8e7a51c813cdb59088fe0123853ad94 Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 18:52:28 +0000 Subject: [PATCH 7/8] Unpin popup on client-initiated close so it can be garbage collected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiPopup gains a `closed` DTO event, mirroring UiWindow's. The client UiPopup now actually implements closeOnEscape / closeOnClickOutside — these config flags existed and were transmitted to the client, but were dead there (no client-side close behavior at all). A user-initiated close (escape key or click outside the content) removes the popup and fires the new event; the server-side Popup handles UI_POPUP_CLOSED by unpinning itself from the SessionContext client-object registry (the pin created in SessionContext.showPopup*()) and firing its new public onClosed event. Without this, a client-closed popup would have stayed strongly referenced (pinned) until session death when client-object garbage collection is enabled. A server-initiated Popup.close() after a client-side close remains harmless: pinned client objects are held in a Set, so the double unpin is a no-op (covered by a new test). Note: the TypeScript changes could not be compiled or tested on this machine (the frontend yarn build is broken here by read-only bind mounts); they mirror the existing UiWindow.ts escape/click-outside implementation as closely as possible and should be smoke-tested on a machine with a working frontend build. Co-Authored-By: Claude Fable 5 --- teamapps-client/ts/modules/UiPopup.ts | 45 ++++++++++++++++++- teamapps-ui-api/src/main/dto/UiPopup.dto | 2 + .../teamapps/ux/component/popup/Popup.java | 22 ++++++++- .../SessionContextClientObjectGcTest.java | 45 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) 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/ux/component/popup/Popup.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/popup/Popup.java index 9b3204c73..0ffc7f18e 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; @@ -170,11 +174,25 @@ public void pinWhileShowing() { } public void close() { + unpinAfterClose(); + queueCommandIfRendered(() -> new UiPopup.CloseCommand(getId())); + } + + private void unpinAfterClose() { if (pinnedWhileShowing) { - // release the strong reference created when the popup was shown pinnedWhileShowing = false; getSessionContext().unpinClientObject(this); } - 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*() + unpinAfterClose(); + onClosed.fire(); + } + } } } 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 index e881c32e9..c1101bf48 100644 --- a/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java +++ b/teamapps-ux/src/test/java/org/teamapps/ux/session/SessionContextClientObjectGcTest.java @@ -27,6 +27,7 @@ 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; @@ -221,6 +222,50 @@ public void shownPopupIsPinnedAndCollectableAfterClose() { 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); From d684c402109276447903997c59e9d0087b81e5f6 Mon Sep 17 00:00:00 2001 From: Jan Vojt Date: Wed, 8 Jul 2026 21:03:23 +0000 Subject: [PATCH 8/8] Extract idempotent display pinning into AbstractComponent Window, Popup, and Notification each duplicated the same pinnedWhileShowing guard protecting the reference-counted pin registry from double-release (e.g. a server-side close racing the client's close event). Move the flag and the pin/release pair into AbstractComponent as pinWhileDisplayed() / releaseDisplayPin(), so the invariant lives in one place and custom component authors get a documented keep-alive facility for components referenced only by the client. Co-Authored-By: Claude Fable 5 --- .../ux/component/AbstractComponent.java | 26 +++++++++++++++++++ .../component/notification/Notification.java | 26 ++----------------- .../teamapps/ux/component/popup/Popup.java | 25 +++--------------- .../teamapps/ux/component/window/Window.java | 17 +++--------- .../teamapps/ux/session/SessionContext.java | 6 ++--- 5 files changed, 37 insertions(+), 63 deletions(-) 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/notification/Notification.java b/teamapps-ux/src/main/java/org/teamapps/ux/component/notification/Notification.java index 8ad671bef..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 @@ -36,7 +36,6 @@ public class Notification extends AbstractComponent { public final Event onClosed = new Event<>(); private boolean showing; - private boolean pinnedWhileShowing; private Color backgroundColor = null; private Spacing padding = null; @@ -91,37 +90,16 @@ public void handleUiEvent(UiEvent event) { } case UI_NOTIFICATION_CLOSED: { this.showing = false; - unpinAfterClose(); + releaseDisplayPin(); onClosed.fire(((UiNotification.ClosedEvent) event).getByUser()); break; } } } - /** - * Internal API, used by {@link org.teamapps.ux.session.SessionContext#showNotification}, do not call from application code! - *

- * Pins this notification in its session context while it is showing, so it does not get garbage collected even if - * the application does not keep a reference to it. Idempotent until the notification closes and releases the pin, - * whether server-initiated ({@link #close()}) or client-initiated (UI_NOTIFICATION_CLOSED). - */ - public void pinWhileShowing() { - if (!pinnedWhileShowing) { - pinnedWhileShowing = true; - getSessionContext().pinClientObject(this); - } - } - - private void unpinAfterClose() { - if (pinnedWhileShowing) { - pinnedWhileShowing = false; - getSessionContext().unpinClientObject(this); - } - } - public void close() { // server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the pin here - unpinAfterClose(); + 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 0ffc7f18e..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 @@ -42,7 +42,6 @@ public class Popup extends AbstractComponent { private Color dimmingColor = new RgbaColor(0, 0, 0, .2f); private boolean closeOnEscape; // close if the user presses escape private boolean closeOnClickOutside; // close if the user clicks onto the area outside the window - private boolean pinnedWhileShowing; public Popup(Component contentComponent) { this.contentComponent = contentComponent; @@ -161,36 +160,18 @@ public void setCloseOnClickOutside(boolean closeOnClickOutside) { // queueCommandIfRendered(() -> new UiPopup.SetCloseOnClickOutsideCommand(getId(), closeOnClickOutside)); } - /** - * Pins this popup in its session context while it is showing (called by {@link org.teamapps.ux.session.SessionContext#showPopup(Popup)} - * and {@link org.teamapps.ux.session.SessionContext#showPopupAtCurrentMousePosition(Popup)}). - * Idempotent until {@link #close()} releases the pin. - */ - public void pinWhileShowing() { - if (!pinnedWhileShowing) { - pinnedWhileShowing = true; - getSessionContext().pinClientObject(this); - } - } - public void close() { - unpinAfterClose(); + // release the strong reference created when the popup was shown + releaseDisplayPin(); queueCommandIfRendered(() -> new UiPopup.CloseCommand(getId())); } - private void unpinAfterClose() { - if (pinnedWhileShowing) { - pinnedWhileShowing = false; - getSessionContext().unpinClientObject(this); - } - } - @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*() - unpinAfterClose(); + releaseDisplayPin(); onClosed.fire(); } } 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 ea47a2306..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 @@ -45,7 +45,6 @@ public class Window extends Panel { private boolean closeable; private boolean closeOnEscape; private boolean closeOnClickOutside; - private boolean pinnedWhileShowing; public Window() { } @@ -99,7 +98,7 @@ public void handleUiEvent(UiEvent event) { switch (event.getUiEventType()) { case UI_WINDOW_CLOSED -> { // user-initiated close (close button, escape, click outside) — release the strong reference created in show() - unpinAfterClose(); + releaseDisplayPin(); onClosed.fire(); } } @@ -197,10 +196,7 @@ public void show() { } public void show(int animationDuration) { - if (!pinnedWhileShowing) { - pinnedWhileShowing = true; - getSessionContext().pinClientObject(this); // shown windows must not get garbage collected; unpinned on close - } + pinWhileDisplayed(); // shown windows must not get garbage collected; unpinned on close render(); queueCommandIfRendered(() -> new UiWindow.ShowCommand(getId(), animationDuration)); } @@ -211,17 +207,10 @@ public void close() { public void close(int animationDuration) { // server-initiated close does not fire UI_WINDOW_CLOSED back, so unpin here - unpinAfterClose(); + releaseDisplayPin(); queueCommandIfRendered(() -> new UiWindow.CloseCommand(getId(), animationDuration)); } - private void unpinAfterClose() { - if (pinnedWhileShowing) { - pinnedWhileShowing = false; - getSessionContext().unpinClientObject(this); - } - } - public boolean isCloseable() { return closeable; } 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 410028b80..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 @@ -631,12 +631,12 @@ private void sendConfigToClient() { } public void showPopupAtCurrentMousePosition(Popup popup) { - popup.pinWhileShowing(); // unpinned in Popup.close() + popup.pinWhileDisplayed(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupAtCurrentMousePositionCommand(popup.createUiReference())); } public void showPopup(Popup popup) { - popup.pinWhileShowing(); // unpinned in Popup.close() + popup.pinWhileDisplayed(); // unpinned in Popup.close() queueCommand(new UiRootPanel.ShowPopupCommand(popup.createUiReference())); } @@ -842,7 +842,7 @@ public void clearClientTokens() { public void showNotification(Notification notification, NotificationPosition position, EntranceAnimation entranceAnimation, ExitAnimation exitAnimation) { runWithContext(() -> { - notification.pinWhileShowing(); // unpinned when the notification closes (Notification.close() or client-side close event) + 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())); });