Skip to content

Commit

Permalink
Merge branch 'main' into bugfix/windows-ci
Browse files Browse the repository at this point in the history
  • Loading branch information
ashbaldry authored Sep 9, 2024
2 parents bf8484e + 48c7fa0 commit d108838
Show file tree
Hide file tree
Showing 14 changed files with 266 additions and 24 deletions.
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export(destroyModule)
export(makeModuleServerDestroyable)
export(makeModuleUIDestroyable)
export(removeInput)
export(removeOutput)
export(runDestroyExample)
import(shiny)
14 changes: 0 additions & 14 deletions R/destroyModule.R
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,3 @@ destroyModuleServer <- function(id, session = getDefaultReactiveDomain()) {

invisible(NULL)
}

#' Remove Output from Shiny Session
#'
#' The removal of the named output in a shiny session.
#'
#' @param id Output value name
#' @param session The Shiny session to remove the output from
#'
#' @noRd
destroyOutput <- function(id, session = getDefaultReactiveDomain()) {
session$defineOutput(id, NULL, NULL)
session$.__enclos_env__$private$.outputs[[id]] <- NULL
session$.__enclos_env__$private$.outputOptions[[id]] <- NULL
}
2 changes: 1 addition & 1 deletion R/removeInput.R
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#' An invisible `TRUE` value confirming that the input has been removed.
#'
#' @details
#' If the input is a standard `{shiny}` input e.g. `numericInput`, then to
#' If the input is a standard shiny input e.g. `numericInput`, then to
#' remove the label as well as the input, set the selector to be
#' `paste0(":has(> #", id, ")")`
#'
Expand Down
57 changes: 57 additions & 0 deletions R/removeOutput.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#' Remove Output from Shiny Session
#'
#' The removal of the named output in a shiny session.
#'
#' @param id Output value name
#' @param selector The HTML selector to remove the UI for. By default it is the
#' tag where the ID matches the output, but might need to be adjusted for
#' different inputs.
#' @param session The Shiny session to remove the output from
#'
#' @return
#' An invisible `TRUE` value confirming that the output has been removed.
#'
#' @examplesIf interactive()
#' library(shiny)
#' library(shiny.destroy)
#'
#' ui <- fluidPage(
#' numericInput("number", "Select number:", 5, 1, 10),
#' p("Selected number:", textOutput("number_out", inline = TRUE)),
#' actionButton("delete", "Remove output")
#' )
#'
#' server <- function(input, output, session) {
#' output$number_out <- renderText(input$number)
#'
#' observeEvent(
#' input$delete,
#' removeOutput("number_out")
#' )
#' }
#'
#' shinyApp(ui, server)
#'
#' @export
removeOutput <- function(id, selector = paste0("#", id), session = getDefaultReactiveDomain()) {
shiny::removeUI(selector, immediate = TRUE, session = session)

destroyOutput(id, session = session)
session$requestFlush()

invisible(TRUE)
}

#' Remove Output from Shiny Session
#'
#' The removal of the named output in a shiny session.
#'
#' @param id Output value name
#' @param session The Shiny session to remove the output from
#'
#' @noRd
destroyOutput <- function(id, session = getDefaultReactiveDomain()) {
session$defineOutput(id, NULL, NULL)
session$.__enclos_env__$private$.outputs[[id]] <- NULL
session$.__enclos_env__$private$.outputOptions[[id]] <- NULL
}
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,32 @@

<!-- badges: end -->

The aim of {shiny.destroy} is to allow dynamic shiny modules to be removed from both the UI and server.
The aim of {shiny.destroy} is to allow inputs and modules created in a shiny application to be removed without keeping any trace of them within the realm of the shiny application.

## Installation

Install the latest version of {shiny.destroy} on GitHub

```r
require("remotes")
remotes::install_github("ashbaldry/shiny.destroy")
```

## Usage

There are two types of objects that {shiny.destroy} handles: inputs and modules.

### Input

Use the function `removeInput` to remove the desired input. This is a wrapper around `shiny::removeUI`, but includes ways to reference the input server-side, and updates any reactives and/or outputs that depend on that input instantly.

### Module

Prior to the application loading.

## Example

![Example shiny.destroy application](/man/figures/example_app.gif)
![Example shiny.destroy application](./man/figures/example_app.gif)

The code for this example is available in the [examples directory](/inst/examples-shiny)

Expand Down
2 changes: 1 addition & 1 deletion man/removeInput.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions man/removeOutput.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions tests/testthat/test-remove_input.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
test_that("Input is fully removed from shiny application", {
ui <- fluidPage(
numericInput("number", "Choose a number", 5, 1, 10),
actionButton("destroy", "Destroy input"),
textOutput("number_input")
)

server <- function(input, output, session) {
output$number_input <- renderText(input$number)
observeEvent(input$destroy, removeInput("number"))
}

app <- shinytest2::AppDriver$new(shinyApp(ui, server), name = "basic_app")
on.exit(app$stop())

expect_equal(app$get_value(input = "number"), 5L, ignore_attr = TRUE)
expect_identical(app$get_value(output = "number_input"), "5")

app$click(input = "destroy")
expect_error(app$click(input = "number"))
expect_null(app$get_value(input = "number"))
expect_identical(app$get_value(output = "number_input"), "")
})
24 changes: 24 additions & 0 deletions tests/testthat/test-remove_output.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
test_that("Output is fully removed from shiny application", {
ui <- fluidPage(
numericInput("number", "Choose a number", 5, 1, 10),
actionButton("destroy", "Destroy input"),
textOutput("number_input")
)

server <- function(input, output, session) {
output$number_input <- renderText(input$number)
observeEvent(input$destroy, removeOutput("number_input"))
}

app <- shinytest2::AppDriver$new(shinyApp(ui, server), name = "basic_app")
on.exit(app$stop())

expect_equal(app$get_value(input = "number"), 5L, ignore_attr = TRUE)
expect_identical(app$get_value(output = "number_input"), "5")

app$click(input = "destroy", wait_ = FALSE)
expect_equal(app$get_value(input = "number"), 5L, ignore_attr = TRUE)
# If ever to fully remove an output, this test will update. For now,
# have to settle with validation error
expect_setequal(app$get_value(output = "number_input")$type, c("shiny.silent.error", "validation"))
})
103 changes: 97 additions & 6 deletions vignettes/introduction.Rmd
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "Removing Shiny Modules"
title: "Removing Shiny Objects"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Removing Shiny Modules}
%\VignetteIndexEntry{Removing Shiny Objects}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
Expand All @@ -15,9 +15,11 @@ knitr::opts_chunk$set(
)
```

In larger shiny applications, modules are beneficial to reduce the application down into smaller, reusable chunks. There are situations where these modules can be dynamically added to the application, but also want to be removed. By using `removeUI`, we can remove the HTML of the module, however there are still a load of server-side processes that will be running even with the module "removed". {shiny.destroy} makes sure that any trace of the module is no longer available within the application.
As shiny applications become larger and more complex, there are requirements to dynamically show inputs, results and even entire modules. Functionality exists to be able to dynamically update the UI and server, however when removing the UI, traces of the input are still available throughout the application which may cause some unexpected behaviour. The aim of {shiny.destroy} is to eliminate all traces of the removed UI in the shiny application, providing the assurance that only the required observers and outputs are rendered after removal.

## UI
## Inputs

The front-end part of shiny inputs can easily be removed by using the `removeUI` function, however this does not impact the server side, where the input is still accessible.

```{r basic_ui}
library(shiny)
Expand All @@ -37,7 +39,15 @@ server <- function(input, output, session) {
shinyApp(ui, server)
```

```{r destroy_ui, eval=FALSE}
![Shiny application removing the numeric input with removeUI](./introduction_clips/removeUI.gif)

With `removeInput`, both the front and back end remove the input, and any observer or output that references the removed input will be updated. Whilst the trick of using `.subset2(input, "impl")$remove(id)` is known to remove the input, it does not trigger anything. Within the `input` object, the names are all stored within the `.namesOrder` and needs to be removed from here too.

After the input has been removed, the session needs to be aware that the input has been removed, otherwise nothing will be updated. This is where the invalidation of the various values is required, referencing the names and value dependency environments within the input object.

With all this resolved, the input is now fully removed from the shiny instance.

```{r destroy_ui}
library(shiny)
library(shiny.destroy)
Expand All @@ -59,4 +69,85 @@ server <- function(input, output, session) {
shinyApp(ui, server)
```

## Server
![Shiny application removing the numeric input with removeInput](./introduction_clips/removeInput.gif)

## Output

Outputs can be removed in shiny applications by using a combination of `removeUI` and assigning `NULL` to the relevant output ID server-side; `removeOutput` is a wrapper for both operations. When assigning the output `NULL`, rather than removing the output entirely, it instead creates a reactive using `req(FALSE)` so that the output will never be updated.

```{r destroy_output}
library(shiny)
library(shiny.destroy)
ui <- fluidPage(
numericInput("bins", "Number of bins:", min = 1, max = 50, value = 30),
actionButton("delete", "Remove output"),
plotOutput("distPlot", height = "200px", width = "400px")
)
server <- function(input, output, session) {
output$distPlot <- renderPlot({
x <- faithful[, 2]
bins <- seq(min(x), max(x), length.out = input$bins + 1)
hist(
x,
breaks = bins,
col = "darkgray",
border = "white",
xlab = "Waiting time to next eruption (in mins)",
main = "Histogram of waiting times"
)
})
observeEvent(input$delete, removeOutput("distPlot"))
}
shinyApp(ui, server)
```

![Shiny application removing the plot output with removeOutput](./introduction_clips/removeOutput.gif)

## Module

In larger shiny applications, modules are beneficial to reduce the application down into smaller, reusable chunks. There are situations where these modules can be dynamically added to the application, but also want to be removed. By using `removeUI`, we can remove the HTML of the module, however there are still a load of server-side processes that will be running even with the module "removed". {shiny.destroy} makes sure that any trace of the module is no longer available within the application.

```{r module_example}
shiny::runExample(
"01_boxes",
package = "shiny.destroy"
)
```

<img src="../man/figures/example_app.gif" alt="Shiny application adding and removing box modules with destroyModule" width="100%"/>

The inputs and outputs are removed in the same way as an individual input or output, however additional objects in a module need to be addressed to properly destroy the module. `observe` and `observeEvent` are eager, and execute whenever any of the triggers are updated. If you assign an observer to a variable, you can see that it too is an R6 object with several methods. To prevent an observer from evaluating, you can use the `$destroy()` method.

```{r stop_observer}
library(shiny)
ui <- fluidPage(
actionButton("update", "Increase"),
actionButton("stop", "Stop increasing"),
p("Updates:", textOutput("number_out", inline = TRUE))
)
server <- function(input, output, session) {
clicks <- reactiveVal(0)
update_obs <- observeEvent(input$update, clicks(clicks() + 1))
observeEvent(input$stop, update_obs$destroy())
output$number_out <- renderText(clicks())
}
shinyApp(ui, server)
```

![Shiny application stopping the counter observer increase](./introduction_clips/stopObserver.gif)

When it comes to modules, these triggers can potentially be inputs or reactive values that are passed into the module. Removing a module from the shiny session won't disable these observers, so in order for `destroyModule` to remove these observers, the module function needs to be passed into `makeModuleServerDestroyable`. This assigns all observers to the user session data so that when the module is destroyed, the observers can be too.

### Sub-Modules

Whilst the module code can be updated so that the observers can be destroyed with a single click, it is hard to find sub-modules called within the module code. Therefore these should be included as arguments to the server-side module, using `makeModuleServerDestroyable`, so that these observers will be destroyed too.
Binary file added vignettes/introduction_clips/removeInput.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added vignettes/introduction_clips/removeOutput.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added vignettes/introduction_clips/removeUI.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added vignettes/introduction_clips/stopObserver.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit d108838

Please sign in to comment.