feat: Add session$destroy() to clean up dangling reactivity#4372
feat: Add session$destroy() to clean up dangling reactivity#4372
Conversation
Port of posit-dev/py-shiny#2209 — adds session$destroy() and session$onDestroy() to clean up dangling reactivity when dynamic module UI is removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests that verify weakref GC semantics work correctly with destroy callbacks for ReactiveVal, Observable, and Observer. Also fix two bugs discovered by the tests: 1. Inline closures in initialize() captured the entire enclosing environment (including `self`/`private`), preventing GC. Fixed by extracting to make_weak_destroy_wrapper() helper. 2. R's lazy evaluation meant the `wr` argument to the helper was a promise retaining a reference to initialize()'s execution env (which holds `self`). Fixed by adding force(wr). 3. Storing self$destroy as the weakref value created a strong reference cycle. Fixed by using wref_key() instead of wref_value() and calling obj$destroy() on the retrieved key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes all keys matching a namespace prefix from the reactive values store, invalidates their dependents, and notifies names/list watchers. This enables cleanup of module inputs when a module scope is destroyed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nySession Adds destroyCallbacksByNs (Map of namespace -> Callbacks) to both ShinySession and MockShinySession, with public onDestroy()/destroy() methods and private helpers for namespace-scoped callback invocation and resource cleanup (inputs, outputs, downloads, files). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ssion docs for onDestroy/destroy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| depths <- nchar(gsub("[^-]", "", matching)) | ||
| matching <- matching[order(-depths, matching)] | ||
|
|
||
| for (ns in matching) { |
There was a problem hiding this comment.
MockShinySession only invokes destroy callbacks here; it does not remove namespaced inputs/outputs the way ShinySession does. That divergence makes destroy/recreate tests less faithful and can leave stale mock state behind across module lifecycles.
| onDestroy = function(callback) { | ||
| private$getOrCreateDestroyCallbacks(namespace)$register(callback) | ||
| }, | ||
| destroy = function() { |
There was a problem hiding this comment.
The proxy gets destroy() / onDestroy(), but it still inherits onSessionEnded() from the root session. So session$onSessionEnded() or onStop(..., session = scope) inside a module will not run on scope$destroy(); they only run when the whole session closes. There is already a note near onStop() calling out that stopped modules need their own onSessionEnded() implementation.
| outputNames <- names(private$.outputs) | ||
| if (!isRoot) { | ||
| nsPrefixWithSep <- paste0(nsPrefix, "-") | ||
| matchingOutputs <- outputNames[startsWith(outputNames, nsPrefixWithSep)] |
There was a problem hiding this comment.
names(private$.outputs) is NULL until a module has defined an output, so startsWith(outputNames, nsPrefixWithSep) can error with non-character object(s). That makes session$destroy() fail for modules that never created outputs. Coercing outputNames to character(0) before filtering would cover that edge case.
| if (!isRoot) { | ||
| nsPrefixWithSep <- paste0(nsPrefix, "-") | ||
| private$.input$`_destroy`(nsPrefixWithSep) | ||
| private$.clientData$`_destroy`(nsPrefixWithSep) |
There was a problem hiding this comment.
This only removes clientData keys that start with the module namespace prefix (for example mod1-). Output-related clientData entries are named like output_mod1-plot_hidden / _width / _height, so they survive destroy and can leak stale metadata into a re-created module with the same id.
There was a problem hiding this comment.
Pull request overview
Adds an explicit module-scope teardown API to Shiny sessions to eliminate “dangling reactivity” when dynamic module UI is removed, with reactive primitives participating automatically via weakly-registered destroy hooks.
Changes:
- Introduces
session$destroy()/session$onDestroy()for module session proxies, backed by namespace-keyed destroy callback infrastructure on the root session. - Adds destroy semantics/guards to core reactive primitives (
ReactiveVal,Observable,Observer) plusReactiveValues$_destroy()for namespace cleanup. - Adds comprehensive destroy-focused test coverage and documentation/NEWS updates.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
R/shiny.R |
Implements destroy callback registry on ShinySession, adds proxy onDestroy()/destroy(), and invokes destroy on websocket close. |
R/reactives.R |
Adds destroyed error condition + destroy methods and weak onDestroy registration for reactive primitives; adds ReactiveValues$_destroy(). |
R/mock-session.R |
Adds destroy callback infra and proxy destroy support for MockShinySession to enable testing. |
tests/testthat/test-destroy.R |
New unit tests covering destroyed guards, idempotency, weakref behavior, and module-scope teardown ordering. |
man/session.Rd |
Documents session$onDestroy() and session$destroy(). |
man/insertUI.Rd |
Documents how session$destroy() relates to removeUI() for module cleanup. |
R/insert-ui.R |
Adds roxygen section describing module cleanup with session$destroy(). |
man/MockShinySession.Rd |
Documents new MockShinySession$onDestroy() / $destroy(). |
NEWS.md |
Announces new session destroy APIs and behavior. |
.gitignore |
Ignores docs/ and .context. |
.Rbuildignore |
Excludes docs and .context from package builds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Sort deepest-first (most hyphens first) | ||
| depths <- nchar(gsub("[^-]", "", matching)) | ||
| matching <- matching[order(-depths, matching)] |
There was a problem hiding this comment.
When destroying the root session (nsPrefix = ""), the sentinel key "root" is treated as depth 0 and then tie-broken lexicographically, which can cause root-level onDestroy callbacks to fire before top-level module namespaces. If root callbacks are meant to be the shallowest scope, adjust the sort to always run the root sentinel last (or assign it a depth of -Inf / explicitly move it to the end).
| # Sort deepest-first (most hyphens first) | |
| depths <- nchar(gsub("[^-]", "", matching)) | |
| matching <- matching[order(-depths, matching)] | |
| # Sort deepest-first (most hyphens first), but always run the root | |
| # sentinel last so root-level callbacks are invoked after top-level | |
| # module namespaces during full-session teardown. | |
| depths <- nchar(gsub("[^-]", "", matching)) | |
| isRootSentinel <- matching == "__root__" | |
| matching <- matching[order(-depths, isRootSentinel, matching)] |
| if (!isRoot) { | ||
| nsPrefixWithSep <- paste0(nsPrefix, "-") | ||
| # Match the namespace itself and any children | ||
| matching <- allNs[allNs == nsPrefix | startsWith(allNs, nsPrefixWithSep)] | ||
| } else { | ||
| matching <- allNs | ||
| } | ||
|
|
||
| if (length(matching) == 0L) return(invisible()) | ||
|
|
||
| # Sort deepest-first (most hyphens first) | ||
| depths <- nchar(gsub("[^-]", "", matching)) | ||
| matching <- matching[order(-depths, matching)] |
There was a problem hiding this comment.
Namespace matching and depth calculation are hard-coded to use "-" (e.g., paste0(nsPrefix, "-") and counting hyphens). Since Shiny already defines ns.sep, consider using that constant instead so the destroy logic stays correct/consistent if the separator ever changes.
| # Invoke destroy callbacks for all namespaces | ||
| allNs <- private$destroyCallbacksByNs$keys() | ||
| for (ns in allNs) { | ||
| cbs <- private$destroyCallbacksByNs$get(ns) | ||
| if (!is.null(cbs)) { | ||
| cbs$invoke(onError = printError) | ||
| } | ||
| private$destroyCallbacksByNs$remove(ns) | ||
| } |
There was a problem hiding this comment.
MockShinySession$close() invokes destroy callbacks by iterating over Map keys without applying the same deepest-namespace-first ordering used by invokeDestroyCallbacks(). This makes ordering nondeterministic and can diverge from real ShinySession behavior; consider delegating to private$invokeDestroyCallbacks("") (or replicating its sorting) here.
| # Invoke destroy callbacks for all namespaces | |
| allNs <- private$destroyCallbacksByNs$keys() | |
| for (ns in allNs) { | |
| cbs <- private$destroyCallbacksByNs$get(ns) | |
| if (!is.null(cbs)) { | |
| cbs$invoke(onError = printError) | |
| } | |
| private$destroyCallbacksByNs$remove(ns) | |
| } | |
| # Invoke destroy callbacks for all namespaces using the same | |
| # deepest-namespace-first ordering as the main destroy path. | |
| private$invokeDestroyCallbacks("") |
| test_that("weakref value (self$destroy) does not prevent GC of key (self)", { | ||
| domain <- createMockDomain() | ||
| wrs <- list() | ||
| domain$onDestroy <- function(callback) { | ||
| wrs[[length(wrs) + 1L]] <<- callback |
There was a problem hiding this comment.
This test name mentions a weakref "value (self$destroy)", but the implementation uses rlang::new_weakref(rv_impl) without setting a value. Consider renaming the test description to reflect what’s actually being validated (that the weakref key can be GC’d).
Summary
Port of posit-dev/py-shiny#2209 — adds
session$destroy()andsession$onDestroy()to R Shiny.session$destroy()on module session proxies to destroy all reactive state (values, expressions, observers, inputs, outputs) for a module scope and its descendantssession$onDestroy(callback)to register cleanup callbacks that fire on destroyshiny.destroyed.errorKey design decisions
rlang::new_weakref()allow reactive objects to be GC'd before explicitdestroy()— matching py-shiny semanticsShinySession$destroy()throws an error — only module session proxies can be destroyed; root session usesclose()Mapkeyed by namespace stringmake_weak_destroy_wrapper()helper avoids closure environment leaks (discovered via GC tests)Files changed
R/reactives.RdestroyedReactiveError,ReactiveVal$destroy(),Observable$destroy(), Observer weak registration,ReactiveValues$_destroy(),make_weak_destroy_wrapper()R/shiny.RShinySessiondestroy infrastructure,makeScope()proxy overrides,wsClosed()integrationR/mock-session.RMockShinySessiondestroy support for testingR/insert-ui.R@seealsoonremoveUItests/testthat/test-destroy.RTest plan
🤖 Generated with Claude Code