Skip to content

Commit

Permalink
feat: Use {watcher} for autoreload file watching (#4185)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
gadenbuie authored Feb 21, 2025
1 parent 9646324 commit da9ca3c
Show file tree
Hide file tree
Showing 5 changed files with 53 additions and 50 deletions.
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ Imports:
glue (>= 1.3.2),
bslib (>= 0.6.0),
cachem (>= 1.1.0),
lifecycle (>= 0.2.0)
lifecycle (>= 0.2.0),
watcher
Suggests:
coro (>= 1.1.0),
datasets,
Expand Down
13 changes: 8 additions & 5 deletions R/shiny-options.R
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,19 @@ getShinyOption <- function(name, default = NULL) {
#' changes are detected, all connected Shiny sessions are reloaded. This
#' allows for fast feedback loops when tweaking Shiny UI.
#'
#' Since monitoring for changes is expensive (we simply poll for last
#' modified times), this feature is intended only for development.
#' Monitoring for changes is no longer expensive, thanks to the \pkg{watcher}
#' package, but this feature is still intended only for development.
#'
#' You can customize the file patterns Shiny will monitor by setting the
#' shiny.autoreload.pattern option. For example, to monitor only ui.R:
#' `options(shiny.autoreload.pattern = glob2rx("ui.R"))`
#'
#' The default polling interval is 500 milliseconds. You can change this
#' by setting e.g. `options(shiny.autoreload.interval = 2000)` (every
#' two seconds).}
#' Shiny no longer polls watched files for changes. Instead, using
#' \pkg{watcher}, Shiny is notified of file changes as they occur. These
#' changes are batched together within a customizable latency period. You can
#' adjust this period by setting `options(shiny.autoreload.interval = 2000)`
#' (in milliseconds). This value converted to seconds and passed to the
#' `latency` argument of [watcher::watch()]. The default latency is 250ms.}
#' \item{shiny.deprecation.messages (defaults to `TRUE`)}{This controls whether messages for
#' deprecated functions in Shiny will be printed. See
#' [shinyDeprecated()] for more information.}
Expand Down
39 changes: 20 additions & 19 deletions R/shinyapp.R
Original file line number Diff line number Diff line change
Expand Up @@ -296,30 +296,31 @@ initAutoReloadMonitor <- function(dir) {
)

lastValue <- NULL
observeLabel <- paste0("File Auto-Reload - '", basename(dir), "'")
obs <- observe(label = observeLabel, {
files <- sort_c(
list.files(dir, pattern = filePattern, recursive = TRUE, ignore.case = TRUE)
check_for_update <- function(paths) {
paths <- grep(
filePattern,
paths,
ignore.case = TRUE,
value = TRUE
)
times <- file.info(files)$mtime
names(times) <- files

if (is.null(lastValue)) {
# First run
lastValue <<- times
} else if (!identical(lastValue, times)) {
# We've changed!
lastValue <<- times
cachedAutoReloadMostRecentChange(times)
autoReloadCallbacks$invoke()

if (length(paths) == 0) {
return()
}

invalidateLater(getOption("shiny.autoreload.interval", 500))
})
cachedAutoReloadLastChanged$set()
autoReloadCallbacks$invoke()
}

onStop(obs$destroy)
# [garrick, 2025-02-20] Shiny <= v1.10.0 used `invalidateLater()` with an
# autoreload.interval in ms. {watcher} instead uses a latency parameter in
# seconds, which serves a similar purpose and that I'm keeping for backcompat.
latency <- getOption("shiny.autoreload.interval", 250) / 1000
watcher <- watcher::watcher(dir, check_for_update, latency = latency)
watcher$start()
onStop(watcher$stop)

obs$destroy
watcher
}

#' Load an app's supporting R files
Expand Down
35 changes: 15 additions & 20 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -770,26 +770,21 @@ formatNoSci <- function(x) {
format(x, scientific = FALSE, digits = 15)
}


# This function when called without arguments returns the mtime of the most
# recently changed file watched by the autoreload process. When given `value`, a
# (possibly) vector of file modification times, it stores the max time seen so
# far. This value is used by `cachedFuncWithFile()` when autoreload is enabled
# to reload app/ui/server files when watched supporting files are changed.
cachedAutoReloadMostRecentChange <- local({
# A simple getter/setting to track the last time the auto-reload process
# updated. This value is used by `cachedFuncWithFile()` when auto-reload is
# enabled to reload app/ui/server files when watched supporting files change.
cachedAutoReloadLastChanged <- local({
last_update <- 0

max_updated <- function(value) {
max(suppressWarnings(max(value, last_update, na.rm = TRUE)), 0)
}

function(value = NULL) {
if (!is.null(value) && !is.na(value) && length(value)) {
last_update <<- max_updated(value)
return(invisible(value))
list(
set = function() {
last_update <<- as.integer(Sys.time())
invisible(last_update)
},
get = function() {
last_update
}
last_update
}
)
})

# Returns a function that calls the given func and caches the result for
Expand All @@ -799,18 +794,18 @@ cachedFuncWithFile <- function(dir, file, func, case.sensitive = FALSE) {

value <- NULL
last_mtime_file <- NA
last_mtime_autoreload <- 0
last_autoreload <- 0

function(...) {
fname <- if (case.sensitive) file.path(dir, file) else
file.path.ci(dir, file)

now <- file.info(fname)$mtime
autoreload <- last_mtime_autoreload < cachedAutoReloadMostRecentChange()
autoreload <- last_autoreload < cachedAutoReloadLastChanged$get()
if (autoreload || !identical(last_mtime_file, now)) {
value <<- func(fname, ...)
last_mtime_file <<- now
last_mtime_autoreload <<- cachedAutoReloadMostRecentChange()
last_autoreload <<- cachedAutoReloadLastChanged$get()
}
value
}
Expand Down
13 changes: 8 additions & 5 deletions man/shinyOptions.Rd

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

0 comments on commit da9ca3c

Please sign in to comment.