Skip to content

Commit 1d1da60

Browse files
schloerkeclaude
andcommitted
feat: warn when session$destroy(id) targets a non-existent module scope
session$destroy(id) now emits a warning (class "shiny.destroy.unknown_id") when the id names a scope that was never created or has already been destroyed, helping catch typos and stale ids. It's a warning rather than an error so it doesn't break otherwise-working code, matching how Shiny treats duplicate output ids (always warn). Adds a private scopeExists() check to ShinySession and MockShinySession that runs before makeScope(id) (which would otherwise register its own callbacks and mask the check). Addresses @khusmann's request on #4372. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1cf4d10 commit 1d1da60

5 files changed

Lines changed: 78 additions & 3 deletions

File tree

NEWS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
registered `onDestroy()` callbacks for that scope and its descendants,
99
tearing down reactive values, expressions, and observers. A parent can
1010
also destroy a child module scope by id with `session$destroy(id)`, so it
11-
can tear down a module using the same id it used to insert the UI (#4372).
11+
can tear down a module using the same id it used to insert the UI.
12+
Destroying an id with no live module scope (a typo or an already-destroyed
13+
scope) emits a warning rather than an error (#4372).
1214

1315
* New `startApp()` runs a Shiny app in non-blocking mode, returning a
1416
`ShinyAppHandle` object with `stop()`, `status()`, `url()`, and `result()`

R/mock-session.R

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ MockShinySession <- R6Class(
338338
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.")
339339
}
340340
validateDestroyId(id)
341+
if (!private$scopeExists(id)) warnDestroyUnknownId(id)
341342
self$makeScope(id)$destroy()
342343
},
343344

@@ -587,6 +588,7 @@ MockShinySession <- R6Class(
587588
private$invokeDestroyCallbacks(namespace)
588589
} else {
589590
validateDestroyId(id)
591+
if (!private$scopeExists(ns(id))) warnDestroyUnknownId(ns(id))
590592
self$makeScope(ns(id))$destroy()
591593
}
592594
}
@@ -770,6 +772,14 @@ MockShinySession <- R6Class(
770772
private$destroyCallbacksByNs$get(ns)
771773
},
772774

775+
# @description Does a module scope exist for this fully-qualified namespace?
776+
# See `ShinySession$scopeExists()`.
777+
scopeExists = function(fullNs) {
778+
keys <- private$destroyCallbacksByNs$keys()
779+
prefix <- paste0(fullNs, ns.sep)
780+
any(keys == fullNs | startsWith(keys, prefix))
781+
},
782+
773783
# @description Invoke destroy callbacks for the given namespace prefix
774784
# and all child namespaces, deepest-first.
775785
# @param nsPrefix The namespace prefix to match.

R/shiny.R

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ workerId <- local({
172172
#'
173173
#' On the root session an `id` is required; `session$destroy()` with no
174174
#' `id` is an error (the root session is torn down via `close()`).
175+
#'
176+
#' Destroying an `id` that has no module scope (because it was never
177+
#' created or has already been destroyed) emits a warning rather than an
178+
#' error, to help catch typos and stale ids.
175179
#' }
176180
#' \item{onEnded(callback)}{
177181
#' Synonym for `onSessionEnded`.
@@ -449,6 +453,20 @@ validateDestroyId <- function(id) {
449453
invisible(id)
450454
}
451455

456+
# Warn (rather than error) when `session$destroy(id)` targets a module scope
457+
# that doesn't exist -- either never created or already destroyed. Helps catch
458+
# typos and stale ids without breaking otherwise-working code. `fullNs` is the
459+
# fully-qualified namespace of the target scope.
460+
warnDestroyUnknownId <- function(fullNs) {
461+
rlang::warn(
462+
paste0(
463+
"`session$destroy()`: no module scope found for id '", fullNs,
464+
"'. It may have already been destroyed, or the id may be a typo."
465+
),
466+
class = "shiny.destroy.unknown_id"
467+
)
468+
}
469+
452470
#' @include utils.R
453471
ShinySession <- R6Class(
454472
'ShinySession',
@@ -822,6 +840,16 @@ ShinySession <- R6Class(
822840
}
823841
private$destroyCallbacksByNs$get(key)
824842
},
843+
# Does a module scope exist for this fully-qualified namespace? A scope is
844+
# "alive" if it has destroy callbacks registered for itself or a descendant
845+
# (every `moduleServer()`/`makeScope()` registers at least one). Returns
846+
# FALSE for scopes that were never created or have already been destroyed
847+
# (their callbacks are removed by `invokeDestroyCallbacks()`).
848+
scopeExists = function(fullNs) {
849+
keys <- private$destroyCallbacksByNs$keys()
850+
prefix <- paste0(fullNs, ns.sep)
851+
any(keys == fullNs | startsWith(keys, prefix))
852+
},
825853
invokeDestroyCallbacks = function(nsPrefix = "") {
826854
allNs <- private$destroyCallbacksByNs$keys()
827855
isRoot <- !nzchar(nsPrefix)
@@ -1089,6 +1117,7 @@ ShinySession <- R6Class(
10891117
private$invokeDestroyCallbacks(namespace)
10901118
} else {
10911119
validateDestroyId(id)
1120+
if (!private$scopeExists(ns(id))) warnDestroyUnknownId(ns(id))
10921121
self$makeScope(ns(id))$destroy()
10931122
}
10941123
}
@@ -1293,6 +1322,7 @@ ShinySession <- R6Class(
12931322
stop("`$destroy()` cannot be called on the root ShinySession 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.")
12941323
}
12951324
validateDestroyId(id)
1325+
if (!private$scopeExists(id)) warnDestroyUnknownId(id)
12961326
self$makeScope(id)$destroy()
12971327
},
12981328
onInputReceived = function(callback) {

man/session.Rd

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-destroy.R

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,9 +481,38 @@ test_that("module session$destroy(id) tears down a nested child scope", {
481481
expect_false(parent_called)
482482
})
483483

484-
test_that("session$destroy(id) on an unknown id is a harmless no-op", {
484+
test_that("session$destroy(id) warns (non-fatally) on an unknown id", {
485485
session <- MockShinySession$new()
486-
expect_no_error(session$destroy("never_created"))
486+
expect_warning(
487+
session$destroy("never_created"),
488+
class = "shiny.destroy.unknown_id"
489+
)
490+
})
491+
492+
test_that("session$destroy(id) does not warn for a live scope", {
493+
session <- MockShinySession$new()
494+
session$makeScope("mod1")
495+
expect_no_warning(session$destroy("mod1"))
496+
})
497+
498+
test_that("session$destroy(id) warns when destroying an already-destroyed scope", {
499+
session <- MockShinySession$new()
500+
session$makeScope("mod1")
501+
expect_no_warning(session$destroy("mod1"))
502+
# Second destroy targets a stale id
503+
expect_warning(
504+
session$destroy("mod1"),
505+
class = "shiny.destroy.unknown_id"
506+
)
507+
})
508+
509+
test_that("module session$destroy(id) warns on an unknown child id", {
510+
session <- MockShinySession$new()
511+
parent <- session$makeScope("parent")
512+
expect_warning(
513+
parent$destroy("nope"),
514+
class = "shiny.destroy.unknown_id"
515+
)
487516
})
488517

489518
test_that("session$destroy(id) validates the id argument", {

0 commit comments

Comments
 (0)