Skip to content

Commit f79a22b

Browse files
authored
feat: Fully reload ui/server when autoreload occurs (#4184)
* feat: Fully reload ui/server when autoreload occurs * chore: remove stray empty line * chore: clean up function names and add comments * docs: Add news item * feat: Use {watcher} for autoreload file watching (#4185) * feat: Use {watcher} * chore: shikokuchuo/watcher@dev * chore: watcher is on CRAN now * chore: Undo air format changes * feat: Use `shiny.autoreload.interval` for watcher latency * chore: Simply track last time auto-reload changed * docs: rewrite options docs for clarity * chore: code style * docs: global.R changes are not applied * feat(ui/server): Autoreload also reloads global and R support files * chore: remove outdated comment * chore: safer comparisons * chore: Restore legacy autoreload watcher if {watcher} not installed * rename: autoload_r_support_if_needed() * chore: use `rlang::is_false()` * chore: use_build_ignore("_dev")
1 parent 83219e3 commit f79a22b

File tree

7 files changed

+145
-50
lines changed

7 files changed

+145
-50
lines changed

.Rbuildignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@
3636
^\.browserslistrc$
3737
^\.eslintrc\.yml$
3838
^\.yarnrc\.yml$
39+
^_dev$

DESCRIPTION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ Suggests:
111111
dygraphs,
112112
ragg,
113113
showtext,
114-
sass
114+
sass,
115+
watcher
115116
URL: https://shiny.posit.co/,
116117
https://github.com/rstudio/shiny
117118
BugReports: https://github.com/rstudio/shiny/issues

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
* `textInput()`, `textAreaInput()`, `numericInput()` and `passwordInput()` all gain an `updateOn` option. `updateOn = "change"` is the default and previous behavior, where the input value updates immediately whenever the value changes. With `updateOn = "blur"`, the input value will update only when the text input loses focus or when the user presses Enter (or Cmd/Ctrl + Enter for `textAreaInput()`). (#4183)
1515

16+
* When auto-reload is enabled, Shiny now reloads the entire app when support files, like Shiny modules, additional script files, or web assets, change. To enable auto-reload, call `devmode(TRUE)` to enable Shiny's developer mode, or set `options(shiny.autoreload = TRUE)` to specifically enable auto-reload. You can choose which files are watched for changes with the `shiny.autoreload.pattern` option. (#4184)
17+
1618
## Bug fixes
1719

1820
* Fixed a bug with modals where calling `removeModal()` too quickly after `showModal()` would fail to remove the modal if the remove modal message was received while the modal was in the process of being revealed. (#4173)

R/shiny-options.R

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,20 @@ getShinyOption <- function(name, default = NULL) {
6565
#' changes are detected, all connected Shiny sessions are reloaded. This
6666
#' allows for fast feedback loops when tweaking Shiny UI.
6767
#'
68-
#' Since monitoring for changes is expensive (we simply poll for last
69-
#' modified times), this feature is intended only for development.
68+
#' Monitoring for changes is no longer expensive, thanks to the \pkg{watcher}
69+
#' package, but this feature is still intended only for development.
7070
#'
7171
#' You can customize the file patterns Shiny will monitor by setting the
72-
#' shiny.autoreload.pattern option. For example, to monitor only ui.R:
73-
#' `options(shiny.autoreload.pattern = glob2rx("ui.R"))`
72+
#' shiny.autoreload.pattern option. For example, to monitor only `ui.R`:
73+
#' `options(shiny.autoreload.pattern = glob2rx("ui.R"))`.
7474
#'
75-
#' The default polling interval is 500 milliseconds. You can change this
76-
#' by setting e.g. `options(shiny.autoreload.interval = 2000)` (every
77-
#' two seconds).}
75+
#' As mentioned above, Shiny no longer polls watched files for changes.
76+
#' Instead, using \pkg{watcher}, Shiny is notified of file changes as they
77+
#' occur. These changes are batched together within a customizable latency
78+
#' period. You can adjust this period by setting
79+
#' `options(shiny.autoreload.interval = 2000)` (in milliseconds). This value
80+
#' converted to seconds and passed to the `latency` argument of
81+
#' [watcher::watcher()]. The default latency is 250ms.}
7882
#' \item{shiny.deprecation.messages (defaults to `TRUE`)}{This controls whether messages for
7983
#' deprecated functions in Shiny will be printed. See
8084
#' [shinyDeprecated()] for more information.}

R/shinyapp.R

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,29 @@ shinyAppDir_serverR <- function(appDir, options=list()) {
162162
sharedEnv <- globalenv()
163163
}
164164

165+
# To enable hot-reloading of support files, this function is called
166+
# whenever the UI or Server func source is updated. To avoid loading
167+
# support files 2x, we follow the last cache update trigger timestamp.
168+
autoload_r_support_if_needed <- local({
169+
autoload_last_loaded <- -1
170+
function() {
171+
if (!isTRUE(getOption("shiny.autoload.r", TRUE))) return()
172+
173+
last_cache_trigger <- cachedAutoReloadLastChanged$get()
174+
if (identical(autoload_last_loaded, last_cache_trigger)) return()
175+
176+
loadSupport(appDir, renv = sharedEnv, globalrenv = globalenv())
177+
178+
autoload_last_loaded <<- last_cache_trigger
179+
}
180+
})
181+
165182
# uiHandlerSource is a function that returns an HTTP handler for serving up
166183
# ui.R as a webpage. The "cachedFuncWithFile" call makes sure that the closure
167184
# we're creating here only gets executed when ui.R's contents change.
168185
uiHandlerSource <- cachedFuncWithFile(appDir, "ui.R", case.sensitive = FALSE,
169186
function(uiR) {
187+
autoload_r_support_if_needed()
170188
if (file.exists(uiR)) {
171189
# If ui.R contains a call to shinyUI (which sets .globals$ui), use that.
172190
# If not, then take the last expression that's returned from ui.R.
@@ -197,6 +215,7 @@ shinyAppDir_serverR <- function(appDir, options=list()) {
197215

198216
serverSource <- cachedFuncWithFile(appDir, "server.R", case.sensitive = FALSE,
199217
function(serverR) {
218+
autoload_r_support_if_needed()
200219
# If server.R contains a call to shinyServer (which sets .globals$server),
201220
# use that. If not, then take the last expression that's returned from
202221
# server.R.
@@ -232,10 +251,9 @@ shinyAppDir_serverR <- function(appDir, options=list()) {
232251
onStart <- function() {
233252
oldwd <<- getwd()
234253
setwd(appDir)
235-
# TODO: we should support hot reloading on global.R and R/*.R changes.
236254
if (getOption("shiny.autoload.r", TRUE)) {
237-
loadSupport(appDir, renv=sharedEnv, globalrenv=globalenv())
238-
} else {
255+
autoload_r_support_if_needed()
256+
} else {
239257
if (file.exists(file.path.ci(appDir, "global.R")))
240258
sourceUTF8(file.path.ci(appDir, "global.R"))
241259
}
@@ -290,33 +308,77 @@ initAutoReloadMonitor <- function(dir) {
290308
return(function(){})
291309
}
292310

293-
filePattern <- getOption("shiny.autoreload.pattern",
294-
".*\\.(r|html?|js|css|png|jpe?g|gif)$")
311+
filePattern <- getOption(
312+
"shiny.autoreload.pattern",
313+
".*\\.(r|html?|js|css|png|jpe?g|gif)$"
314+
)
295315

296-
lastValue <- NULL
297-
observeLabel <- paste0("File Auto-Reload - '", basename(dir), "'")
298-
obs <- observe(label = observeLabel, {
299-
files <- sort_c(
300-
list.files(dir, pattern = filePattern, recursive = TRUE, ignore.case = TRUE)
301-
)
302-
times <- file.info(files)$mtime
303-
names(times) <- files
304-
305-
if (is.null(lastValue)) {
306-
# First run
307-
lastValue <<- times
308-
} else if (!identical(lastValue, times)) {
309-
# We've changed!
310-
lastValue <<- times
316+
317+
if (is_installed("watcher")) {
318+
check_for_update <- function(paths) {
319+
paths <- grep(
320+
filePattern,
321+
paths,
322+
ignore.case = TRUE,
323+
value = TRUE
324+
)
325+
326+
if (length(paths) == 0) {
327+
return()
328+
}
329+
330+
cachedAutoReloadLastChanged$set()
311331
autoReloadCallbacks$invoke()
312332
}
333+
334+
# [garrick, 2025-02-20] Shiny <= v1.10.0 used `invalidateLater()` with an
335+
# autoreload.interval in ms. {watcher} instead uses a latency parameter in
336+
# seconds, which serves a similar purpose and that I'm keeping for backcompat.
337+
latency <- getOption("shiny.autoreload.interval", 250) / 1000
338+
watcher <- watcher::watcher(dir, check_for_update, latency = latency)
339+
watcher$start()
340+
onStop(watcher$stop)
341+
} else {
342+
# Fall back to legacy observer behavior
343+
if (!is_false(getOption("shiny.autoreload.legacy_warning", TRUE))) {
344+
cli::cli_warn(
345+
c(
346+
"Using legacy autoreload file watching. Please install {.pkg watcher} for a more performant autoreload file watcher.",
347+
"i" = "Set {.run options(shiny.autoreload.legacy_warning = FALSE)} to suppress this warning."
348+
),
349+
.frequency = "regularly",
350+
.frequency_id = "shiny.autoreload.legacy_warning"
351+
)
352+
}
313353

314-
invalidateLater(getOption("shiny.autoreload.interval", 500))
315-
})
316-
317-
onStop(obs$destroy)
354+
lastValue <- NULL
355+
observeLabel <- paste0("File Auto-Reload - '", basename(dir), "'")
356+
watcher <- observe(label = observeLabel, {
357+
files <- sort_c(
358+
list.files(dir, pattern = filePattern, recursive = TRUE, ignore.case = TRUE)
359+
)
360+
times <- file.info(files)$mtime
361+
names(times) <- files
362+
363+
if (is.null(lastValue)) {
364+
# First run
365+
lastValue <<- times
366+
} else if (!identical(lastValue, times)) {
367+
# We've changed!
368+
lastValue <<- times
369+
cachedAutoReloadLastChanged$set()
370+
autoReloadCallbacks$invoke()
371+
}
372+
373+
invalidateLater(getOption("shiny.autoreload.interval", 500))
374+
})
375+
376+
onStop(watcher$destroy)
377+
378+
watcher$destroy
379+
}
318380

319-
obs$destroy
381+
invisible(watcher)
320382
}
321383

322384
#' Load an app's supporting R files
@@ -421,8 +483,6 @@ shinyAppDir_appR <- function(fileName, appDir, options=list())
421483
wasDir <- setwd(appDir)
422484
on.exit(setwd(wasDir))
423485

424-
# TODO: we should support hot reloading on R/*.R changes.
425-
# In an upcoming version of shiny, this option will go away.
426486
if (getOption("shiny.autoload.r", TRUE)) {
427487
# Create a child env which contains all the helpers and will be the shared parent
428488
# of the ui.R and server.R load.

R/utils.R

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -770,22 +770,45 @@ formatNoSci <- function(x) {
770770
format(x, scientific = FALSE, digits = 15)
771771
}
772772

773+
# A simple getter/setting to track the last time the auto-reload process
774+
# updated. This value is used by `cachedFuncWithFile()` when auto-reload is
775+
# enabled to reload app/ui/server files when watched supporting files change.
776+
cachedAutoReloadLastChanged <- local({
777+
last_update <- 0
778+
779+
list(
780+
set = function() {
781+
last_update <<- as.integer(Sys.time())
782+
invisible(last_update)
783+
},
784+
get = function() {
785+
last_update
786+
}
787+
)
788+
})
789+
773790
# Returns a function that calls the given func and caches the result for
774791
# subsequent calls, unless the given file's mtime changes.
775792
cachedFuncWithFile <- function(dir, file, func, case.sensitive = FALSE) {
776-
dir <- normalizePath(dir, mustWork=TRUE)
777-
mtime <- NA
793+
dir <- normalizePath(dir, mustWork = TRUE)
794+
778795
value <- NULL
796+
last_mtime_file <- NA
797+
last_autoreload <- 0
798+
779799
function(...) {
780-
fname <- if (case.sensitive)
781-
file.path(dir, file)
782-
else
800+
fname <- if (case.sensitive) {
801+
file.path(dir, file)
802+
} else {
783803
file.path.ci(dir, file)
804+
}
784805

785806
now <- file.info(fname)$mtime
786-
if (!identical(mtime, now)) {
807+
autoreload <- last_autoreload < cachedAutoReloadLastChanged$get()
808+
if (autoreload || !identical(last_mtime_file, now)) {
787809
value <<- func(fname, ...)
788-
mtime <<- now
810+
last_mtime_file <<- now
811+
last_autoreload <<- cachedAutoReloadLastChanged$get()
789812
}
790813
value
791814
}

man/shinyOptions.Rd

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

0 commit comments

Comments
 (0)