Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5213c4b
docs: add session$destroy() design spec
schloerke Apr 16, 2026
c73e0bd
chore: add docs/ to .gitignore and .Rbuildignore
schloerke Apr 16, 2026
7cd36fc
feat: add destroyedReactiveError condition constructor
schloerke Apr 17, 2026
f295282
feat: add destroy() method and guards to ReactiveVal
schloerke Apr 17, 2026
ca4d1f9
feat: add destroy() method and guards to Observable
schloerke Apr 17, 2026
1dc8b36
feat: Observer auto-registers weak destroy callback with domain
schloerke Apr 17, 2026
faa8a65
feat: ReactiveVal and Observable auto-register weak destroy callbacks
schloerke Apr 17, 2026
f648d29
test: verify weakref GC behavior for destroy callbacks
schloerke Apr 17, 2026
2d7268a
feat: add _destroy(nsPrefix) method to ReactiveValues
schloerke Apr 17, 2026
f4cde30
feat: add destroy callback infrastructure to ShinySession and MockShi…
schloerke Apr 17, 2026
92391a4
feat: add onDestroy/destroy to session proxies via makeScope
schloerke Apr 17, 2026
5b24569
test: add integration tests for full module destroy flow
schloerke Apr 17, 2026
9cce3b9
docs: add roxygen for onDestroy/destroy, @seealso on removeUI
schloerke Apr 17, 2026
584c7ba
docs: add NEWS entry for session$destroy()
schloerke Apr 17, 2026
9ca13cc
`devtools::document()` (GitHub Actions)
schloerke Apr 17, 2026
1ab5519
docs: update NEWS PR number placeholder to #4372
schloerke Apr 17, 2026
e1a8e09
docs: replace broken ShinySession @seealso with usage snippet, add se…
schloerke Apr 17, 2026
b2c9c38
chore: add .context to .gitignore and .Rbuildignore
schloerke Apr 17, 2026
4ac85fd
fix: address PR review threads for session destroy
schloerke Apr 20, 2026
5b6c187
docs: add composability section to session, moduleServer, and removeU…
schloerke Apr 20, 2026
7603141
docs: polish NEWS entry to match tidyverse style and updated docs
schloerke Apr 20, 2026
4a13407
fix: improve destroy() error messages with actionable guidance
schloerke Apr 20, 2026
ba89579
refactor: use `..root` sentinel instead of `__root__` for destroy cal…
schloerke Apr 20, 2026
7e14d91
fix: deregister onDestroy handles in destroy(), fix doc examples and …
schloerke Apr 20, 2026
7aaf50e
fix: cancel invalidateLater() timers on module destroy
schloerke Apr 20, 2026
c7c31de
fix: clean up bookmark-exclude registration on module destroy
schloerke Apr 20, 2026
c14acc9
fix: use monotonic counter for bookmark-exclude IDs to prevent collision
schloerke Apr 20, 2026
09b2a7e
test: add bookmark-exclude support to MockShinySession for testing
schloerke Apr 20, 2026
0252fd4
`devtools::document()` (GitHub Actions)
schloerke Apr 20, 2026
ba9f721
fix: add onDestroy/destroy support to createMockDomain
schloerke Apr 21, 2026
0d44836
fix: reject reserved namespace '..root' in makeScope()
schloerke Apr 21, 2026
4361f2c
docs: fix moduleServer destroy example to show realistic pattern
schloerke Apr 21, 2026
936c15c
fix: observer onDestroy registration respects autoDestroy flag
schloerke Apr 21, 2026
b397602
fix: invalidateLater onEnded callback deregisters onDestroy handle
schloerke Apr 21, 2026
c000b88
feat: add destroy() and domain auto-registration to ReactiveValues
schloerke Apr 21, 2026
b00940a
docs: address PR review threads for removeUI example, onDestroy docs,…
schloerke Apr 22, 2026
c5c91a9
Merge branch 'main' into schloerke/py-shiny-2209-port
schloerke Apr 28, 2026
1387076
Merge remote-tracking branch 'origin/main' into schloerke/py-shiny-22…
schloerke May 25, 2026
f1c6880
`devtools::document()` (GitHub Actions)
schloerke May 25, 2026
44f24f4
Merge remote-tracking branch 'origin/main' into schloerke/py-shiny-22…
schloerke May 29, 2026
88b7d9a
Merge branch 'schloerke/py-shiny-2209-port' of https://github.com/rst…
schloerke May 29, 2026
4d34035
feat: session$destroy(id) tears down a child module scope by id
schloerke May 29, 2026
238d84f
docs: address plannotator review for session$destroy(id)
schloerke May 29, 2026
33c1088
docs: use backticks instead of \code{} in R6 destroy method docstrings
schloerke May 29, 2026
1cf4d10
docs: use backticks instead of \code{} in remaining R6 method docstrings
schloerke May 29, 2026
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
2 changes: 2 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@
^README-npm\.md$
^CRAN-SUBMISSION$
^LICENSE\.md$
^docs$
^\.context$
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ madge.svg
# GHA remotes installation
.github/r-depends.rds
.claude/settings.local.json
/docs/
.context
8 changes: 8 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## New features

* `session$destroy()` and `session$onDestroy()` are now available on
module session proxies to clean up "dangling reactivity" when dynamic
module UI is removed. Calling `session$destroy()` invokes all
registered `onDestroy()` callbacks for that scope and its descendants,
tearing down reactive values, expressions, and observers. A parent can
also destroy a child module scope by id with `session$destroy(id)`, so it
can tear down a module using the same id it used to insert the UI (#4372).

* New `startApp()` runs a Shiny app in non-blocking mode, returning a
`ShinyAppHandle` object with `stop()`, `status()`, `url()`, and `result()`
methods. When a new app is started, any previously running non-blocking app
Expand Down
34 changes: 34 additions & 0 deletions R/insert-ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,40 @@ insertUI <- function(selector,
}


#' @section Cleaning up module server-side state:
#' When `removeUI()` removes a module's UI, the server-side reactive objects
#' (observers, reactive values, etc.) created by that module continue to run.
#' The parent inserted the module's UI under an `id`, so it can tear down the
#' module's server-side state by that same `id`:
#'
#' ```
#' # In the parent server:
#' myModuleServer("my_module")
#' removeUI(selector = "#my_module")
#' session$destroy("my_module")
#' ```
#'
#' If teardown must happen somewhere that doesn't have the parent session or
#' `id`, a module can instead return its own `session$destroy` as a handle:
#'
#' ```
#' myModuleServer <- function(id) {
#' moduleServer(id, function(input, output, session) {
#' # ... module logic ...
#'
#' # Return a handle that the caller can invoke during teardown
#' list(destroy = session$destroy)
#' })
#' }
#'
#' mod <- myModuleServer("my_module")
#' removeUI(selector = "#my_module")
#' mod$destroy()
#' ```
#'
#' See the [session] help topic for details on composability and data ownership
#' patterns when using `session$destroy()` with dynamic modules.
#'
#' @rdname insertUI
#' @export
removeUI <- function(selector,
Expand Down
147 changes: 144 additions & 3 deletions R/mock-session.R
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ makeExtraMethods <- function() {
"doBookmark",
"exportTestValues",
"flushOutput",
"getBookmarkExclude",
"getTestSnapshotUrl",
"incrementBusyCount",
"manageHiddenOutputs",
Expand Down Expand Up @@ -159,7 +158,6 @@ makeExtraMethods <- function() {
"sendProgress",
"sendRemoveTab",
"sendRemoveUI",
"setBookmarkExclude",
"setShowcase",
"showProgress",
"updateQueryString"
Expand Down Expand Up @@ -255,6 +253,7 @@ MockShinySession <- R6Class(
private$flushCBs <- Callbacks$new()
private$flushedCBs <- Callbacks$new()
private$endedCBs <- Callbacks$new()
private$destroyCallbacksByNs <- Map$new()

private$file_generators <- fastmap()

Expand Down Expand Up @@ -317,6 +316,30 @@ MockShinySession <- R6Class(
onEnded = function(sessionEndedCallback) {
private$endedCBs$register(sessionEndedCallback)
},
#' @description Registers a callback to be invoked when the session scope
#' is destroyed. Returns a function that can be called to unregister the
#' callback.
#' @param callback The callback to invoke on destroy.
onDestroy = function(callback) {
# Use sentinel key since fastmap disallows empty string keys
ns <- "..root"
if (!private$destroyCallbacksByNs$containsKey(ns)) {
private$destroyCallbacksByNs$set(ns, Callbacks$new())
}
private$destroyCallbacksByNs$get(ns)$register(callback)
Comment thread
schloerke marked this conversation as resolved.
},
#' @description Destroys a module session scope. On the root session, an
#' `id` is required: `session$destroy(id)` tears down the child module
#' scope of that `id`. Calling `destroy()` with no `id` on the root
#' session is an error.
#' @param id Optional module `id` whose scope should be destroyed.
destroy = function(id = NULL) {
if (is.null(id)) {
stop("`$destroy()` cannot be called on the root session without an `id`. Pass a module `id` to tear down that scope (e.g. `session$destroy(\"my_module\")`), or call `$destroy()` on a module session.")
}
validateDestroyId(id)
self$makeScope(id)$destroy()
},

#' @description Returns `FALSE` if the session has not yet been closed
isEnded = function(){ private$was_closed },
Expand All @@ -330,9 +353,23 @@ MockShinySession <- R6Class(
withReactiveDomain(self, {
private$endedCBs$invoke(onError = printError, ..stacktraceon = TRUE)
})
private$invokeDestroyCallbacks("")
private$was_closed <- TRUE
},

#' @description Set input names to be excluded from bookmarking.
#' @param names Character vector of input names.
setBookmarkExclude = function(names) {
private$bookmarkExclude <- names
},
#' @description Returns the set of input names to be excluded from bookmarking,
#' including those registered by module scopes.
getBookmarkExclude = function() {
scopedExcludes <- lapply(private$getBookmarkExcludeFuns, function(f) f())
scopedExcludes <- unlist(scopedExcludes)
c(private$bookmarkExclude, scopedExcludes)
},

#FIXME: this is wrong. Will need to be more complex.
#' @description Unsophisticated mock implementation that merely invokes
# the given callback immediately.
Expand Down Expand Up @@ -517,17 +554,54 @@ MockShinySession <- R6Class(
#' @param namespace Character vector indicating a namespace.
#' @return A new session proxy.
makeScope = function(namespace) {
if (identical(namespace, "..root")) {
stop(
"The module namespace '..root' is reserved for internal use.",
call. = FALSE
)
}
ns <- NS(namespace)
createSessionProxy(

bookmarkExclude <- character(0)

scope <- createSessionProxy(
self,
input = .createReactiveValues(private$.input, readonly = TRUE, ns = ns),
output = structure(.createOutputWriter(self, ns = ns), class = "shinyoutput"),
makeScope = function(namespace) self$makeScope(ns(namespace)),
ns = function(namespace) ns(namespace),
setInputs = function(...) {
self$setInputs(!!!mapNames(ns, rlang::dots_list(..., .homonyms = "error")))
},
setBookmarkExclude = function(names) {
bookmarkExclude <<- names
},
getBookmarkExclude = function() {
bookmarkExclude
},
onDestroy = function(callback) {
private$getOrCreateDestroyCallbacks(namespace)$register(callback)
},
destroy = function(id = NULL) {
if (is.null(id)) {
private$invokeDestroyCallbacks(namespace)
} else {
validateDestroyId(id)
self$makeScope(ns(id))$destroy()
}
}
)

unsub_exclude <- private$registerBookmarkExclude(function() {
excluded <- scope$getBookmarkExclude()
ns(excluded)
})

scope$onDestroy(function() {
if (is.function(unsub_exclude)) unsub_exclude()
})

scope
},
#' @description Set the environment associated with a testServer() call, but
#' only if it has not previously been set. This ensures that only the
Expand Down Expand Up @@ -643,6 +717,26 @@ MockShinySession <- R6Class(
flushedCBs = NULL,
# @field endedCBs `Callbacks` called when session ends.
endedCBs = NULL,
# @field destroyCallbacksByNs Map of namespace -> Callbacks for destroy.
destroyCallbacksByNs = NULL,
# @field bookmarkExclude Character vector of input names to exclude from bookmarking.
bookmarkExclude = character(0),
# @field getBookmarkExcludeFuns List of functions returning exclude names (from scopes).
getBookmarkExcludeFuns = list(),
# @field getBookmarkExcludeFunsNextId Monotonic counter for exclude fun IDs.
getBookmarkExcludeFunsNextId = 0L,

# @description Register a function that returns input names to exclude from
# bookmarking. Returns an unsubscribe function.
# @param fun A function that returns a character vector of namespaced names.
registerBookmarkExclude = function(fun) {
private$getBookmarkExcludeFunsNextId <- private$getBookmarkExcludeFunsNextId + 1L
id <- as.character(private$getBookmarkExcludeFunsNextId)
private$getBookmarkExcludeFuns[[id]] <- fun
function() {
private$getBookmarkExcludeFuns[[id]] <- NULL
}
},
# @field unhandledErrorCallbacks `Callbacks` called when an unhandled error
# occurs.
unhandledErrorCallbacks = Callbacks$new(),
Expand All @@ -665,6 +759,53 @@ MockShinySession <- R6Class(
#' output, or `NULL` if no output is currently executing.
currentOutputName = NULL,

# @description Get or create a Callbacks object for the given namespace.
# @param ns The namespace key.
# @return A Callbacks object.
getOrCreateDestroyCallbacks = function(ns) {
if (!nzchar(ns)) ns <- "..root"
if (!private$destroyCallbacksByNs$containsKey(ns)) {
private$destroyCallbacksByNs$set(ns, Callbacks$new())
}
private$destroyCallbacksByNs$get(ns)
},

# @description Invoke destroy callbacks for the given namespace prefix
# and all child namespaces, deepest-first.
# @param nsPrefix The namespace prefix to match.
invokeDestroyCallbacks = function(nsPrefix = "") {
allNs <- private$destroyCallbacksByNs$keys()
isRoot <- !nzchar(nsPrefix)

if (!isRoot) {
nsPrefixWithSep <- paste0(nsPrefix, ns.sep)
matching <- allNs[allNs == nsPrefix | startsWith(allNs, nsPrefixWithSep)]
} else {
matching <- allNs
}

if (length(matching) > 0L) {
# Sort deepest-first (most separators first); root sentinel always last
depths <- nchar(gsub(paste0("[^", ns.sep, "]"), "", matching))
isRootSentinel <- matching == "..root"
matching <- matching[order(-depths, isRootSentinel, matching)]

for (ns in matching) {
cbs <- private$destroyCallbacksByNs$get(ns)
if (!is.null(cbs)) {
cbs$invoke(onError = printError)
}
private$destroyCallbacksByNs$remove(ns)
}
}

# Clean up namespaced inputs
if (!isRoot) {
nsPrefixWithSep <- paste0(nsPrefix, ns.sep)
private$.input$destroyByPrefix(nsPrefixWithSep)
}
},

# @description Writes a downloadable file to disk. If the `content` function
# associated with a download handler does not write a file, an error is
# signaled. Created files are deleted upon session close.
Expand Down
38 changes: 37 additions & 1 deletion R/modules.R
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,43 @@ find_ancestor_session <- function(x, depth = 20) {
#' almost always be used).
#'
#' @return The return value, if any, from executing the module server function
#' @seealso <https://shiny.posit.co/articles/modules.html>
#'
#' @section Destroying module reactivity:
#' When module UI is added and removed dynamically (e.g. via [insertUI()] and
#' [removeUI()]), the server-side reactive objects created by `moduleServer()`
#' continue to run after the UI is removed. The parent inserted the module's
#' UI under an `id`, so it can tear down all reactive values, expressions, and
#' observers in that scope by that same `id`:
#'
#' ```
#' # In parent server:
#' myModuleServer("myid")
#' removeUI(selector = "#myid")
#' session$destroy("myid")
#' ```
#'
#' If teardown must happen somewhere that doesn't have the parent session or
#' `id`, a module can instead return its own `session$destroy` as a handle:
#'
#' ```
#' myModuleServer <- function(id) {
#' moduleServer(id, function(input, output, session) {
#' # ... module logic ...
#'
#' # Return a cleanup function for the caller to invoke
#' list(result = ..., destroy = session$destroy)
#' })
#' }
#'
#' mod <- myModuleServer("myid")
#' removeUI(selector = "#myid")
#' mod$destroy()
#' ```
#'
#' See the [session] help topic for details on composability and data ownership
#' patterns when using `session$destroy()`.
#'
#' @seealso [session], [removeUI()], <https://shiny.posit.co/articles/modules.html>
#'
#' @examples
#' # Define the UI for a module
Expand Down
13 changes: 13 additions & 0 deletions R/reactive-domains.R
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ NULL
## ------------------------------------------------------------------------
createMockDomain <- function() {
callbacks <- Callbacks$new()
destroyCBs <- Callbacks$new()
ended <- FALSE
destroyed <- FALSE
domain <- new.env(parent = emptyenv())
domain$ns <- function(id) id
domain$token <- "mock-domain"
Expand All @@ -53,12 +55,23 @@ createMockDomain <- function() {
domain$isEnded <- function() {
ended
}
domain$onDestroy <- function(callback) {
return(destroyCBs$register(callback))
}
domain$destroy <- function() {
if (!destroyed) {
destroyed <<- TRUE
destroyCBs$invoke()
}
invisible()
}
domain$reactlog <- function(logEntry) NULL
domain$end <- function() {
if (!ended) {
ended <<- TRUE
callbacks$invoke()
}
domain$destroy()
invisible()
}
domain$incrementBusyCount <- function() NULL
Expand Down
Loading
Loading