Skip to content
45 changes: 43 additions & 2 deletions teamapps-client/ts/modules/UiPopup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UiPopupConfig> implements UiPopupCommandHandler {
export class UiPopup extends AbstractUiComponent<UiPopupConfig> implements UiPopupCommandHandler, UiPopupEventSource {

public readonly onClosed: TeamAppsEvent<UiPopup_ClosedEvent> = 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);

Expand All @@ -47,6 +54,34 @@ export class UiPopup extends AbstractUiComponent<UiPopupConfig> 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 {
Expand Down Expand Up @@ -112,8 +147,14 @@ export class UiPopup extends AbstractUiComponent<UiPopupConfig> implements UiPop
}

close(): void {
this.removeEventListeners();
this.getMainElement().remove();
}

public destroy(): void {
super.destroy();
this.removeEventListeners();
}
}

TeamAppsUiComponentRegistry.registerComponentClass("UiPopup", UiPopup);
2 changes: 2 additions & 0 deletions teamapps-ui-api/src/main/dto/UiPopup.dto
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ class UiPopup extends UiComponent {
command setDimensions(int width, int height);

command close();

event closed();
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ public class TeamAppsConfiguration {
*/
private String navigationPathPrefix = "";

/**
* If enabled, client objects (components) are only weakly referenced from the session's registry,
* so components that are neither displayed (reachable from a root panel, shown window, popup, etc.)
* nor referenced by application code get garbage collected during the session's lifetime.
* Their client-side counterparts are destroyed automatically.
* <p>
* If disabled (default), components stay strongly referenced by the session until it is destroyed
* (historical behavior — long-lived sessions accumulate all components ever rendered).
* <p>
* Note: components remain strongly reachable through listener registrations they (or their event handlers)
* hold on application-scoped objects such as shared models or application-wide events. For such components
* to become collectable, the application must dispose/deregister those listeners when discarding the component.
*/
private boolean clientObjectGarbageCollectionEnabled = false;

public TeamAppsConfiguration() {
}

Expand Down Expand Up @@ -371,4 +386,18 @@ public String getNavigationPathPrefix() {
public void setNavigationPathPrefix(String navigationPathPrefix) {
this.navigationPathPrefix = navigationPathPrefix;
}

/**
* @see #clientObjectGarbageCollectionEnabled
*/
public boolean isClientObjectGarbageCollectionEnabled() {
return clientObjectGarbageCollectionEnabled;
}

/**
* @see #clientObjectGarbageCollectionEnabled
*/
public void setClientObjectGarbageCollectionEnabled(boolean clientObjectGarbageCollectionEnabled) {
this.clientObjectGarbageCollectionEnabled = clientObjectGarbageCollectionEnabled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,21 @@ public TeamAppsSessionManager(TeamAppsConfiguration config, ObjectMapper objectM
this.houseKeepingScheduledExecutor.scheduleAtFixedRate(
() -> {
try {
sessionsById.values().forEach(s -> s.getUiSession().updateStats());
sessionsById.values().forEach(s -> {
try {
// destroy the client-side counterparts of garbage collected client objects
// (also covers idle sessions, which do not trigger the opportunistic drain)
s.getSessionContext().drainCollectedClientObjects();
} catch (Exception e) {
LOGGER.error("Exception while draining collected client objects for session " + s.getUiSession().getSessionId() + "!", e);
}
// Note: the drain above updates the registry asynchronously within the session context,
// so these counts may lag behind by one housekeeping cycle. That is acceptable for statistics.
s.getUiSession().getStatistics().updateClientObjectCounts(
s.getSessionContext().getClientObjectCount(),
s.getSessionContext().getCollectedClientObjectsCount());
s.getUiSession().updateStats();
});
onStatsUpdated.fire(new SessionStatsUpdatedEventData(getAllSessions(), getClosedSessionsStatistics()));
} catch (Exception e) {
LOGGER.error("Exception while flushing stats!", e);
Expand Down Expand Up @@ -301,7 +315,8 @@ public SessionContext createSessionContext(UiSession uiSession, UiClientInfo uiC
httpSession,
uxServerContext,
new SessionIconProvider(iconProvider),
navigationPathPrefix, new ParameterConverterProvider()
navigationPathPrefix, new ParameterConverterProvider(),
config.isClientObjectGarbageCollectionEnabled()
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -181,4 +187,14 @@ public SumStats getSentDataStats() {
public SumStats getReceivedDataStats() {
return receivedDataStats;
}

@Override
public long getClientObjectCount() {
return clientObjectCount;
}

@Override
public long getCollectedClientObjectCount() {
return collectedClientObjectCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ private enum RenderingState {
private final Map<String, CssStyles> stylesBySelector = new HashMap<>(0);
private final Map<String, Map<String, String>> attributesBySelector = new HashMap<>(0);

private boolean pinnedWhileDisplayed;

public AbstractComponent() {
this.sessionContext = CurrentSessionContext.get();
id = getClass().getSimpleName() + "-" + UUID.randomUUID().toString();
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public class ChatDisplay extends AbstractComponent {
private Icon<?, ?> deletedMessageIcon = MaterialIcon.DELETE.withStyle(MaterialIconStyles.OUTLINE_GREY_900);

private Function<ChatMessage, Component> contextMenuProvider = null;
// Strong reference — displayed context menus must not get garbage collected.
// Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu();
// a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive).
private Component lastContextMenuComponent;

public ChatDisplay(ChatDisplayModel model) {
this.model = model;
Expand Down Expand Up @@ -98,6 +102,7 @@ public Object handleUiQuery(UiQuery query) {
ChatMessage chatMessage = model.getChatMessageById(q.getChatMessageId());
if (chatMessage != null) {
Component component = contextMenuProvider.apply(chatMessage);
lastContextMenuComponent = component; // strong reference while the context menu is displayed
return component != null ? component.createUiReference() : null;
}
}
Expand Down Expand Up @@ -175,6 +180,7 @@ public void setContextMenuProvider(Function<ChatMessage, Component> contextMenuP
}

public void closeContextMenu() {
lastContextMenuComponent = null;
queueCommandIfRendered(() -> new UiChatDisplay.CloseContextMenuCommand(getId()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ public class InfiniteItemView<RECORD> extends AbstractComponent {
private final Consumer<RecordsRemovedEvent<RECORD>> modelOnRecordDeletedListener = x -> this.refresh();

private Function<RECORD, Component> contextMenuProvider = null;
// Strong reference — displayed context menus must not get garbage collected.
// Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu();
// a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive).
private Component lastContextMenuComponent;
private int lastSeenContextMenuRequestId;

private int displayedRangeStart = 0;
Expand Down Expand Up @@ -148,6 +152,7 @@ public void handleUiEvent(UiEvent event) {
RECORD record = itemCache.getRecordByClientId(e.getRecordId());
if (record != null && contextMenuProvider != null) {
Component contextMenuContent = contextMenuProvider.apply(record);
lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed
if (contextMenuContent != null) {
queueCommandIfRendered(() -> new UiInfiniteItemView.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference()));
} else {
Expand Down Expand Up @@ -311,6 +316,7 @@ public void setContextMenuProvider(Function<RECORD, Component> contextMenuProvid
}

public void closeContextMenu() {
lastContextMenuComponent = null;
queueCommandIfRendered(() -> new UiInfiniteItemView.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ public class InfiniteItemView2<RECORD> extends AbstractInfiniteListComponent<REC
private int clientRecordIdCounter = 0;

private Function<RECORD, Component> contextMenuProvider = null;
// Strong reference — displayed context menus must not get garbage collected.
// Deliberately retained until replaced by the next context menu or explicitly closed via closeContextMenu();
// a client-side dismissal does not clear it (bounded: at most one menu subtree is kept alive).
private Component lastContextMenuComponent;
private int lastSeenContextMenuRequestId;

private boolean selectionEnabled;
Expand Down Expand Up @@ -131,6 +135,7 @@ public void handleUiEvent(UiEvent event) {
RECORD record = renderedRecords.getRecord(e.getRecordId());
if (record != null) {
Component contextMenuContent = contextMenuProvider.apply(record);
lastContextMenuComponent = contextMenuContent; // strong reference while the context menu is displayed
if (contextMenuContent != null) {
queueCommandIfRendered(() -> new UiInfiniteItemView2.SetContextMenuContentCommand(getId(), e.getRequestId(), contextMenuContent.createUiReference()));
} else {
Expand Down Expand Up @@ -186,6 +191,7 @@ public void setContextMenuProvider(Function<RECORD, Component> contextMenuProvid
}

public void closeContextMenu() {
lastContextMenuComponent = null;
queueCommandIfRendered(() -> new UiInfiniteItemView2.CloseContextMenuCommand(getId(), this.lastSeenContextMenuRequestId));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,16 @@ public void handleUiEvent(UiEvent event) {
}
case UI_NOTIFICATION_CLOSED: {
this.showing = false;
releaseDisplayPin();
onClosed.fire(((UiNotification.ClosedEvent) event).getByUser());
break;
}
}
}

public void close() {
// server-initiated close does not fire UI_NOTIFICATION_CLOSED back, so release the pin here
releaseDisplayPin();
queueCommandIfRendered(() -> new UiNotification.CloseCommand(getId()));
}

Expand Down
Loading
Loading