Skip to content

Avoiding Property Leaks

Dispose and Memory Management covers the general rule: dynamically created objects must be disposed. This page is the specific failure mode that rule is guarding against for Property-based code — a link/lazyLink call is a strong reference in both directions, so a forgotten unlink doesn't just leak a listener, it can keep an entire object graph alive indefinitely, invisibly, with no error and no crash.

How a Property leak actually happens

ts
// A per-item Node created and destroyed as the list changes.
class ItemNode extends Node {
  public constructor( item: Item, sharedTotalProperty: NumberProperty ) {
    super();

    // sharedTotalProperty now holds a reference to this listener, which closes
    // over `this` (the ItemNode) - as long as sharedTotalProperty exists, so does this ItemNode.
    sharedTotalProperty.link( total => {
      this.opacity = total > 100 ? 0.5 : 1;
    } );
  }
  // No dispose() override - the listener above is never removed.
}
ts
const itemNode = new ItemNode( item, model.totalProperty );
itemsLayer.addChild( itemNode );

// Later, the item is removed from the list...
itemsLayer.removeChild( itemNode );
itemNode.dispose(); // Node.dispose() runs, but the closure above is still linked to
                     // model.totalProperty, which lives for the sim's lifetime - itemNode
                     // (and everything it references) is kept alive forever.

itemNode.dispose() disposes the Node's own built-in Properties, but it does nothing about a listener itemNode's constructor registered on someone else's Property — that link has to be undone explicitly, by the object that created it.

ts
class ItemNode extends Node {

  private readonly disposeItemNode: () => void;

  public constructor( item: Item, sharedTotalProperty: NumberProperty ) {
    super();

    const totalListener = ( total: number ) => {
      this.opacity = total > 100 ? 0.5 : 1;
    };
    sharedTotalProperty.link( totalListener );

    this.disposeItemNode = () => sharedTotalProperty.unlink( totalListener );
  }

  public override dispose(): void {
    this.disposeItemNode();
    super.dispose();
  }
}

The { disposer } option is the same fix with less boilerplate, when the removal should happen automatically whenever this is disposed:

ts
sharedTotalProperty.link( total => {
  this.opacity = total > 100 ? 0.5 : 1;
}, { disposer: this } ); // unlinked automatically when this ItemNode is disposed - no unlink() call needed

The three shapes this takes

ConstructWhat leaks if not cleaned upCleanup
property.link(listener) / lazyLink(listener)listener and everything it closes over, kept alive by propertyproperty.unlink(listener), or { disposer: this } on the link call
DerivedProperty/new Multilink(...) created locallyThe DerivedProperty/Multilink itself holds listeners on its dependencies, keeping the whole chain alivederivedProperty.dispose(); Multilink.unmultilink(multilink) — see The Multilink Pattern
Emitter.addListener(listener)Same shape as link — the Emitter, not the Property, holds the referenceemitter.removeListener(listener)

Every one of these is invisible in normal testing — the object looks garbage-collectable, the sim doesn't crash, memory just grows slowly across many create/destroy cycles until a long play session degrades. That's what makes this class of bug worth a dedicated discipline rather than "fix it when it shows up."

A leak only exists when lifetimes differ

None of this applies to a link call where the listener and the Property it's listening to share the same lifetime (a model linking its own Properties in its own constructor, live for the sim's duration) — see Dispose and Memory Management's table. The risk is specifically when a shorter-lived object (a popup, a list item, anything created after startup and removed before the sim closes) links to a longer-lived Property. Audit every link/addListener call in a dynamically-created class by asking "does what I'm linking to outlive me?" — if yes, it needs an explicit unlink in dispose().