Skip to content

bug: Dangling reactivity - reactive objects persist after dynamic UI is removed #2207

@schloerke

Description

@schloerke

Summary

Hat tip to @khusmann for clearly articulating this problem.

When dynamic UI is removed (via remove_ui(), @render.ui re-rendering, or toggling visibility), the server-side reactive objects associated with that UI are not cleaned up. This creates three types of dangling state:

  1. Dangling effects -- reactive.effect objects created for dynamic UI keep firing even after the UI is removed from the DOM.
  2. Dangling calcs -- reactive.calc objects persist in memory with stale values. Calc_ has no destroy() method at all.
  3. Dangling input values -- input.foo() still returns the last value for inputs that no longer exist in the DOM.

Reproduction

Image

Open the demo app in Shinylive

  1. Click Create Panel to add 2-3 panels. Each creates a module with an auto-incrementing reactive.effect, a reactive.calc, and a dynamic input_text.
  2. Uncheck Show dynamic UI on a panel -- the sidebar monitor shows the effect count still incrementing and the input value still present.
  3. Click Remove this panel -- the entry turns red and shows [REMOVED], but the effect count keeps going up and the input/calc values persist.

Root Cause

The server has no concept of reactive object ownership or scoping tied to UI lifecycle:

  • insert_ui() / remove_ui() are purely client-side DOM operations. The server sends a message and forgets about it.
  • Module servers create reactive objects that register with the session, but nothing tracks which module "owns" which reactive objects.
  • There is no "reactive scope" that groups reactive objects and can destroy them as a unit.
  • Calc_ lacks basic lifecycle management -- no destroy(), no session.on_ended() callback.

What IS cleaned up today

Mechanism How
Output effects when re-registered with same ID Outputs.remove() calls effect.destroy()
All Effect_ on session end Each registers session.on_ended(self.destroy)
Client-side input/output bindings shinyUnbindAll(el) before DOM replacement
Output suspension when hidden _manage_hidden() suspends (but does NOT destroy)

What is NOT cleaned up

Mechanism
reactive.effect from module server after remove_ui
reactive.calc (no destroy() method exists)
Server-side input.* values for removed DOM inputs
Any server-side state tracking for insert_ui / remove_ui

Prior Art

py-shiny

R Shiny (rstudio/shiny)

This is a long-standing problem in R Shiny as well:

  • rstudio/shiny#2281 -- The canonical issue: insertUI/callModule to add, removeUI/??? to remove. No mechanism to deactivate a module server instance.
  • rstudio/shiny#825 -- "Reactive subDomains" proposal (2016). Proposes createSubDomain() where ending the subdomain destroys all reactive objects created within it.
  • rstudio/shiny#2374 -- Request to delete server-side input values on removeUI().
  • rstudio/shiny#3812 -- Add hooks around dynamic UI lifecycle.

Current Workaround

Users must manually track every reactive.effect and call .destroy() on removal. This doesn't scale, doesn't work for reactive.calc (no destroy method), and doesn't clean up input values.

Possible Directions (not prescriptive)

  • Add destroy() to Calc_
  • Reactive scoping / ownership tracking (a la Proposal: reactive subDomains rstudio/shiny#825 subDomains)
  • Server-side awareness of which inputs/outputs belong to which dynamic UI context
  • Coordination between remove_ui / @render.ui and server-side cleanup
  • Module-level teardown mechanism

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions