Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix memory leaks due to retained tagify, sortable or tooltipster instances #17483

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions src/module/actor/character/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,16 +685,18 @@ class CharacterSheetPF2e<TActor extends CharacterPF2e> extends CreatureSheetPF2e
const side = hoverEl.dataset.tooltipSide
?.split(",")
?.filter((t): t is (typeof allSides)[number] => tupleHasValue(allSides, t)) ?? ["right", "bottom"];
$(hoverEl).tooltipster({
trigger: "click",
arrow: false,
contentAsHTML: true,
debug: BUILD_MODE === "development",
interactive: true,
side,
theme: "crb-hover",
minWidth: 120,
});
this.ensureTooltipsterCleanup(
$(hoverEl).tooltipster({
trigger: "click",
arrow: false,
contentAsHTML: true,
debug: BUILD_MODE === "development",
interactive: true,
side,
theme: "crb-hover",
minWidth: 120,
}),
);
}

// SPELLCASTING
Expand Down Expand Up @@ -774,7 +776,9 @@ class CharacterSheetPF2e<TActor extends CharacterPF2e> extends CreatureSheetPF2e

navTitleArea.innerText = game.i18n.localize(activeTab.dataset.tooltip ?? "");
const manageTabsAnchor = htmlQuery<HTMLAnchorElement>(sheetNavigation, ":scope > a[data-action=manage-tabs]");
if (manageTabsAnchor) PCSheetTabManager.initialize(this.actor, manageTabsAnchor);
if (manageTabsAnchor) {
this.ensureDestroyableCleanup(PCSheetTabManager.initialize(this.actor, manageTabsAnchor));
}

sheetNavigation.addEventListener("click", (event) => {
const anchor = htmlClosest(event.target, "a[data-tab]");
Expand Down
11 changes: 8 additions & 3 deletions src/module/actor/character/tab-manager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { CharacterPF2e } from "./document.ts";

export class PCSheetTabManager {
#tooltipsterElement: JQuery | null = null;
constructor(
public actor: CharacterPF2e,
public link: HTMLAnchorElement,
) {
renderTemplate("systems/pf2e/templates/actors/character/manage-tabs.hbs").then((template) => {
$(this.link).tooltipster({
this.#tooltipsterElement = $(this.link).tooltipster({
content: template,
contentAsHTML: true,
delay: 250,
Expand All @@ -22,8 +23,8 @@ export class PCSheetTabManager {
});
}

static initialize(actor: CharacterPF2e, link: HTMLAnchorElement): void {
new this(actor, link);
static initialize(actor: CharacterPF2e, link: HTMLAnchorElement): PCSheetTabManager {
return new this(actor, link);
}

/** Set each checkbox to be checked according to its corresponding tab visibility */
Expand Down Expand Up @@ -84,4 +85,8 @@ export class PCSheetTabManager {
}
}
}

destroy(): void {
this.#tooltipsterElement?.tooltipster("destroy");
}
}
1 change: 1 addition & 0 deletions src/module/actor/hazard/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export class HazardSheetPF2e extends ActorSheetPF2e<HazardPF2e> {
const traitsEl = htmlQuery<HTMLTagifyTagsElement>(html, 'tagify-tags[name="system.traits.value"]');
if (traitsEl) {
const tags = tagify(traitsEl, { whitelist: CONFIG.PF2E.hazardTraits });
this.ensureDestroyableCleanup(tags);
const traitsPrepend = html.querySelector<HTMLTemplateElement>(".traits-extra");
if (traitsPrepend) {
tags.DOM.scope.prepend(traitsPrepend.content);
Expand Down
2 changes: 1 addition & 1 deletion src/module/actor/npc/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ abstract class AbstractNPCSheet extends CreatureSheetPF2e<NPCPF2e> {

// Tagify the traits selection
const traitsEl = htmlQuery<HTMLTagifyTagsElement>(html, 'tagify-tags[name="system.traits.value"]');
tagify(traitsEl, { whitelist: CONFIG.PF2E.creatureTraits });
this.ensureDestroyableCleanup(tagify(traitsEl, { whitelist: CONFIG.PF2E.creatureTraits }));
}

protected override activateClickListener(html: HTMLElement): SheetClickActionHandlers {
Expand Down
86 changes: 47 additions & 39 deletions src/module/actor/party/kingdom/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,14 @@ class KingdomSheetPF2e extends ActorSheetPF2e<PartyPF2e> {
if (vacantEl) {
const lines = vacantEl.title.split(/;\s*/).map((l) => createHTMLElement("li", { children: [l] }));
const content = createHTMLElement("ul", { children: lines });
$(vacantEl).tooltipster({
content,
contentAsHTML: true,
side: "right",
theme: "crb-hover",
});
this.ensureTooltipsterCleanup(
$(vacantEl).tooltipster({
content,
contentAsHTML: true,
side: "right",
theme: "crb-hover",
}),
);
}
}

Expand Down Expand Up @@ -374,16 +376,20 @@ class KingdomSheetPF2e extends ActorSheetPF2e<PartyPF2e> {
this.filterActions(filterButton.dataset.slug ?? null);
});

$html.find("[data-tooltip-content]").tooltipster({
trigger: "click",
arrow: false,
contentAsHTML: true,
debug: BUILD_MODE === "development",
interactive: true,
side: ["right", "bottom"],
theme: "crb-hover",
minWidth: 120,
});
const $tooltipContent = $html.find("[data-tooltip-content]");
if ($tooltipContent.length > 0)
this.ensureTooltipsterCleanup(
$tooltipContent.tooltipster({
trigger: "click",
arrow: false,
contentAsHTML: true,
debug: BUILD_MODE === "development",
interactive: true,
side: ["right", "bottom"],
theme: "crb-hover",
minWidth: 120,
}),
);

// Handle adding and inputting custom user submitted modifiers
for (const customModifierEl of htmlQueryAll(html, ".modifiers-tooltip")) {
Expand Down Expand Up @@ -471,29 +477,31 @@ class KingdomSheetPF2e extends ActorSheetPF2e<PartyPF2e> {
// Sort settlements
const settlementList = htmlQuery(html, ".settlement-list");
if (settlementList) {
Sortable.create(settlementList, {
...SORTABLE_BASE_OPTIONS,
handle: ".drag-handle",
onEnd: (event) => {
const settlements = this.kingdom.settlements as Record<string, KingdomSettlementData>;
const settlementsWithIds = Object.entries(settlements).map(([id, value]) => ({ id, ...value }));
const settlement = settlementsWithIds.find((s) => s.id === event.item.dataset.settlementId);
const newIndex = event.newDraggableIndex;
if (!settlement || newIndex === undefined) {
this.render();
return;
}

// Perform the resort and update
const siblings = R.sortBy(
settlementsWithIds.filter((s) => s !== settlement),
(s) => s.sort,
);
siblings.splice(newIndex, 0, settlement);
const updates = R.mapToObj(siblings, (s, index) => [`settlements.${s.id}.sort`, index]);
this.kingdom.update(updates);
},
});
this.ensureDestroyableCleanup(
Sortable.create(settlementList, {
...SORTABLE_BASE_OPTIONS,
handle: ".drag-handle",
onEnd: (event) => {
const settlements = this.kingdom.settlements as Record<string, KingdomSettlementData>;
const settlementsWithIds = Object.entries(settlements).map(([id, value]) => ({ id, ...value }));
const settlement = settlementsWithIds.find((s) => s.id === event.item.dataset.settlementId);
const newIndex = event.newDraggableIndex;
if (!settlement || newIndex === undefined) {
this.render();
return;
}

// Perform the resort and update
const siblings = R.sortBy(
settlementsWithIds.filter((s) => s !== settlement),
(s) => s.sort,
);
siblings.splice(newIndex, 0, settlement);
const updates = R.mapToObj(siblings, (s, index) => [`settlements.${s.id}.sort`, index]);
this.kingdom.update(updates);
},
}),
);
}
}

Expand Down
23 changes: 13 additions & 10 deletions src/module/actor/party/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ class PartySheetPF2e extends ActorSheetPF2e<PartyPF2e> {
const titleLabel = game.i18n.localize("PF2E.Actor.Party.MembersLabel");
const title = createHTMLElement("strong", { children: [titleLabel] });
const content = createHTMLElement("span", { children: [title, members] });
$(languageTag).tooltipster({ content });
this.ensureTooltipsterCleanup($(languageTag).tooltipster({ content }));
}

// Mouseover summary skill tooltips to show all actor modifiers
Expand All @@ -376,7 +376,7 @@ class PartySheetPF2e extends ActorSheetPF2e<PartyPF2e> {
});

const content = createHTMLElement("div", { children: labels });
$(skillTag).tooltipster({ content });
this.ensureTooltipsterCleanup($(skillTag).tooltipster({ content }));
}

// Mouseover tooltip for exploration activities
Expand All @@ -390,14 +390,16 @@ class PartySheetPF2e extends ActorSheetPF2e<PartyPF2e> {
classes: ["item-summary"],
innerHTML: await TextEditor.enrichHTML(document.description, { rollData }),
});
$(activityElem).tooltipster({
contentAsHTML: true,
content,
interactive: true,
maxWidth: 500,
side: "right",
theme: "crb-hover",
});
this.ensureTooltipsterCleanup(
$(activityElem).tooltipster({
contentAsHTML: true,
content,
interactive: true,
maxWidth: 500,
side: "right",
theme: "crb-hover",
}),
);
})();
}

Expand Down Expand Up @@ -481,6 +483,7 @@ class PartySheetPF2e extends ActorSheetPF2e<PartyPF2e> {
}

override render(force?: boolean, options?: PartySheetRenderOptions): this {
this.resetListeners();
if (options?.actors) {
this.getData().then(async (data) => {
this._saveScrollPositions(this.element);
Expand Down
50 changes: 49 additions & 1 deletion src/module/actor/sheet/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ abstract class ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActo
/** Implementation used to handle the toggling and rendering of item summaries */
itemRenderer: ItemSummaryRenderer<TActor, ActorSheetPF2e<TActor>> = new ItemSummaryRenderer(this);

/** Active destroyable resources. Have to be cleaned up to avoid memory leaks */
#destroyables: { destroy(): void }[] = [];

/** Elements with registered $.tooltipster instances. Have to be cleaned up to avoid memory leaks */
#tooltipsterElements: JQuery[] = [];

/** Is this sheet one in which the actor is not owned by the user, but the user can still take and deposit items? */
get isLootSheet(): boolean {
return !this.actor.isOwner && this.actor.isLootableBy(game.user);
Expand Down Expand Up @@ -462,6 +468,21 @@ abstract class ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActo
}
}

protected ensureDestroyableCleanup(destroyable: { destroy(): void } | null): void {
if (destroyable) this.#destroyables.push(destroyable);
}

protected ensureTooltipsterCleanup(element: JQuery): void {
this.#tooltipsterElements.push(element);
}

protected resetListeners(): void {
this.#destroyables.forEach((sortable) => sortable.destroy());
this.#destroyables = [];
this.#tooltipsterElements.forEach((element) => element.tooltipster("destroy"));
this.#tooltipsterElements = [];
}

/** Sheet-wide click listeners for elements selectable as `a[data-action]` */
protected activateClickListener(html: HTMLElement): SheetClickActionHandlers {
const inventoryItemFromDOM = (event: MouseEvent): PhysicalItemPF2e<TActor> => {
Expand Down Expand Up @@ -693,7 +714,7 @@ abstract class ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActo
onEnd: (event) => this.#onDropInventoryItem(event),
};

new Sortable(list, options);
this.ensureDestroyableCleanup(new Sortable(list, options));
}
}

Expand Down Expand Up @@ -1305,6 +1326,28 @@ abstract class ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActo
}
}

/**
* Overridden _replaceHTML to reset event listeners.
* It would be nicer to have the resetListeners call in activateListeners,
* but tooltipster instances have to be cleaned up before the registered html
* elements are removed from the dom, as the library throws errors when trying
* to call the destroy method otherwise. Even though the library throws errors
* when trying to destroy an instance that has been detached from the main
* document, some references are still registered internally which leads to the
* whole detached element tree (the whole actor sheet) being retained in memory
* indefinitely.
* This is why `resetListeners` has to be called just before the old html is
* replaced.
*/
protected override _replaceHTML(
element: JQuery,
html: JQuery | HTMLElement,
options: Record<string, unknown>,
): void {
this.resetListeners();
super._replaceHTML(element, html, options);
}

protected override _getSubmitData(updateData?: Record<string, unknown>): Record<string, unknown> {
const data = super._getSubmitData(updateData);

Expand Down Expand Up @@ -1333,6 +1376,11 @@ abstract class ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActo
});
return plugins;
}

override async close(options?: { force?: boolean }): Promise<void> {
this.resetListeners();
return super.close(options);
}
}

interface ActorSheetPF2e<TActor extends ActorPF2e> extends ActorSheet<TActor, ItemPF2e> {
Expand Down
16 changes: 15 additions & 1 deletion src/module/actor/sheet/popups/iwr-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class IWREditor<TActor extends ActorPF2e> extends DocumentSheet<TActor, IWREdito

types: Record<string, string>;

/** Active tagify instances. Have to be cleaned up to avoid memory leaks */
#tagifyInstances: Tagify<Record<"id" | "value", string>>[] = [];

constructor(actor: TActor, options: IWREditorConstructorOptions) {
super(actor, options);

Expand Down Expand Up @@ -145,10 +148,11 @@ class IWREditor<TActor extends ActorPF2e> extends DocumentSheet<TActor, IWREdito
}

override activateListeners($html: JQuery): void {
this.#resetListeners();
const html = $html[0];

for (const input of htmlQueryAll<HTMLInputElement>(html, "input[type=text]")) {
tagify(input, { whitelist: this.types, maxTags: 6 });
this.#tagifyInstances.push(tagify(input, { whitelist: this.types, maxTags: 6 }));
}

htmlQuery(html, "a[data-action=add]")?.addEventListener("click", (event) => {
Expand Down Expand Up @@ -178,6 +182,16 @@ class IWREditor<TActor extends ActorPF2e> extends DocumentSheet<TActor, IWREdito
});
}
}

#resetListeners(): void {
this.#tagifyInstances.forEach((tagified) => tagified.destroy());
this.#tagifyInstances = [];
}

override async close(options?: { force?: boolean | undefined }): Promise<void> {
this.#resetListeners();
return super.close(options);
}
}

interface IWREditorOptions extends DocumentSheetOptions {
Expand Down
Loading