diff --git a/.circleci/config.yml b/.circleci/config.yml
index 0501af6f..76ef866c 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -34,10 +34,16 @@ jobs:
command: |
python -m venv venv
. venv/bin/activate
- git clone --depth 1 https://github.com/plotly/dash.git
- cd dash && pip install -e .[testing] --quiet && cd ..
+ pip install -e git+https://github.com/plotly/dash.git#egg=dash[testing]
export PATH=$PATH:/home/circleci/.local/bin/
- pytest tests/integration/
+ export PERCY_ENABLE=0
+ pytest --log-cli-level DEBUG --junitxml=test-reports/dashr.xml tests/integration/
+ - store_artifacts:
+ path: test-reports
+ - store_test_results:
+ path: test-reports
+ - store_artifacts:
+ path: /tmp/dash_artifacts
- run:
name: 🔎 Unit tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c13b0dc7..9d67efef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,24 @@
# Change Log for Dash for R
All notable changes to this project will be documented in this file.
+## Unreleased
+### Added
+- Hot reloading now supported in debug mode [#127](https://github.com/plotly/dashR/pull/127)
+- Support for displaying Dash for R applications within RStudio's viewer pane when `use_viewer = TRUE`
+- Clientside callbacks written in JavaScript are now supported [#130](https://github.com/plotly/dashR/pull/130)
+- Multiple outputs are now supported [#119](https://github.com/plotly/dashR/pull/119)
+- Selective callback updates to properties now supported with `dashNoUpdate()` [#111](https://github.com/plotly/dashR/pull/111)
+- Additional line number context inserted when available within stack traces [#133](https://github.com/plotly/dashR/pull/133)
+- Integration and unit tests are now performed when commits are made to open pull requests
+
+### Changed
+- Dash for R no longer requires forked `reqres`, patch applied upstream [thomasp85/reqres#9](https://github.com/thomasp85/reqres/pull/9)
+- The `pruned_errors` parameter has been renamed to `dev_tools_prune_errors` [#113](https://github.com/plotly/dashR/pull/113)
+
+### Fixed
+- Patch for `reqres` package to handle cookies containing multiple "=" [#122](https://github.com/plotly/dashR/pull/122)
+- Handling for user-defined errors in callbacks implemented [#116](https://github.com/plotly/dashR/pull/116)
+
## [0.1.0] - 2019-07-10
### Added
- Initial release
diff --git a/DESCRIPTION b/DESCRIPTION
index 9a7f5c78..57db7519 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -37,6 +37,7 @@ Remotes: plotly/dash-html-components@17da1f4,
License: MIT + file LICENSE
Encoding: UTF-8
LazyData: true
+KeepSource: true
RoxygenNote: 6.1.1
Roxygen: list(markdown = TRUE)
URL: https://github.com/plotly/dashR
diff --git a/NAMESPACE b/NAMESPACE
index 717692bc..e5c7d9ec 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -2,9 +2,8 @@
S3method(print,dash_component)
export(Dash)
-export(dashNoUpdate)
-export(createCallbackId)
export(clientsideFunction)
+export(dashNoUpdate)
export(input)
export(output)
export(state)
diff --git a/R/dash.R b/R/dash.R
index 6eba02d9..b13ea8d4 100644
--- a/R/dash.R
+++ b/R/dash.R
@@ -12,7 +12,10 @@
#' assets_ignore = '',
#' serve_locally = TRUE,
#' routes_pathname_prefix = '/',
-#' requests_pathname_prefix = '/'
+#' requests_pathname_prefix = '/',
+#' external_scripts = NULL,
+#' external_stylesheets = NULL,
+#' suppress_callback_exceptions = FALSE
#' )
#'
#' @section Arguments:
@@ -39,10 +42,7 @@
#' `external_stylesheets` \tab \tab An optional list of valid URLs from which
#' to serve CSS for rendered pages.\cr
#' `suppress_callback_exceptions` \tab \tab Whether to relay warnings about
-#' possible layout mis-specifications when registering a callback. \cr
-#' `components_cache_max_age` \tab \tab An integer value specifying the time
-#' interval prior to expiring cached assets. The default is 2678400 seconds,
-#' or 31 calendar days.
+#' possible layout mis-specifications when registering a callback.
#' }
#'
#' @section Fields:
@@ -82,20 +82,40 @@
#' receive the results (via the [output] object). The events that
#' trigger the callback are then described by the [input] (and/or [state])
#' object(s) (which should reference layout components), which become
-#' argument values for R callback handlers defined in `func`.
-#'
-#' `func` may either be an anonymous R function, or a call to
-#' `clientsideFunction()`, which describes a locally served JavaScript
-#' function instead. The latter defines a "clientside callback", which
-#' updates components without passing data to and from the Dash backend.
-#' The latter may offer improved performance relative to callbacks written
-#' in R.
+#' argument values for R callback handlers defined in `func`. Here `func` may
+#' either be an anonymous R function, or a call to `clientsideFunction()`, which
+#' describes a locally served JavaScript function instead. The latter defines a
+#' "clientside callback", which updates components without passing data to and
+#' from the Dash backend. The latter may offer improved performance relative
+#' to callbacks written in R.
+#' }
+#' \item{`callback_context()`}{
+#' The `callback_context` method permits retrieving the inputs which triggered
+#' the firing of a given callback, and allows introspection of the input/state
+#' values given their names. It is only available from within a callback;
+#' attempting to use this method outside of a callback will result in a warning.
#' }
#' \item{`run_server(host = Sys.getenv('DASH_HOST', "127.0.0.1"),
#' port = Sys.getenv('DASH_PORT', 8050), block = TRUE, showcase = FALSE, ...)`}{
-#' Launch the application. If provided, `host`/`port` set
-#' the `host`/`port` fields of the underlying [fiery::Fire] web
-#' server. The `block`/`showcase`/`...` arguments are passed along
+#' The `run_server` method has 13 formal arguments, several of which are optional:
+#' \describe{
+#' \item{host}{Character. A string specifying a valid IPv4 address for the Fiery server, or `0.0.0.0` to listen on all addresses. Default is `127.0.0.1` Environment variable: `DASH_HOST`.}
+#' \item{port}{Integer. Specifies the port number on which the server should listen (default is `8050`). Environment variable: `DASH_PORT`.}
+#' \item{block}{Logical. Start the server while blocking console input? Default is `TRUE`.}
+#' \item{showcase}{Logical. Load the Dash application into the default web browser when server starts? Default is `FALSE`.}
+#' \item{use_viewer}{Logical. Load the Dash application into RStudio's viewer pane? Requires that `host` is either `127.0.0.1` or `localhost`, and that Dash application is started within RStudio; if `use_viewer = TRUE` and these conditions are not satsified, the user is warned and the app opens in the default browser instead. Default is `FALSE`.}
+#' \item{debug}{Logical. Enable/disable all the dev tools unless overridden by the arguments or environment variables. Default is `FALSE` when called via `run_server`. Environment variable: `DASH_DEBUG`.}
+#' \item{dev_tools_ui}{Logical. Show Dash's dev tools UI? Default is `TRUE` if `debug == TRUE`, `FALSE` otherwise. Environment variable: `DASH_UI`.}
+#' \item{dev_tools_hot_reload}{Logical. Activate hot reloading when app, assets, and component files change? Default is `TRUE` if `debug == TRUE`, `FALSE` otherwise. Requires that the Dash application is loaded using `source()`, so that `srcref` attributes are available for executed code. Environment variable: `DASH_HOT_RELOAD`.}
+#' \item{dev_tools_hot_reload_interval}{Numeric. Interval in seconds for the client to request the reload hash. Default is `3`. Environment variable: `DASH_HOT_RELOAD_INTERVAL`.}
+#' \item{dev_tools_hot_reload_watch_interval}{Numeric. Interval in seconds for the server to check asset and component folders for changes. Default `0.5`. Environment variable: `DASH_HOT_RELOAD_WATCH_INTERVAL`.}
+#' \item{dev_tools_hot_reload_max_retry}{Integer. Maximum number of failed reload hash requests before failing and displaying a pop up. Default `0.5`. Environment variable: `DASH_HOT_RELOAD_MAX_RETRY`.}
+#' \item{dev_tools_props_check}{Logical. Validate the types and values of Dash component properties? Default is `TRUE` if `debug == TRUE`, `FALSE` otherwise. Environment variable: `DASH_PROPS_CHECK`.}
+#' \item{dev_tools_prune_errors}{Logical. Reduce tracebacks to just user code, stripping out Fiery and Dash pieces? Only available with debugging. `TRUE` by default, set to `FALSE` to see the complete traceback. Environment variable: `DASH_PRUNE_ERRORS`.}
+#' \item{dev_tools_silence_routes_logging}{Logical. Replace Fiery's default logger with `dashLogger` instead (will remove all routes logging)? Enabled with debugging by default because hot reload hash checks generate a lot of requests.}
+#' }
+#' Starts the Fiery server in local mode. If a parameter can be set by an environment variable, that is listed too. Values provided here take precedence over environment variables.
+#' Launch the application. If provided, `host`/`port` set the `host`/`port` fields of the underlying [fiery::Fire] web server. The `block`/`showcase`/`...` arguments are passed along
#' to the `ignite()` method of the [fiery::Fire] server.
#' }
#' }
@@ -142,8 +162,7 @@ Dash <- R6::R6Class(
requests_pathname_prefix = NULL,
external_scripts = NULL,
external_stylesheets = NULL,
- suppress_callback_exceptions = FALSE,
- components_cache_max_age = 2678400) {
+ suppress_callback_exceptions = FALSE) {
# argument type checking
assertthat::assert_that(is.character(name))
@@ -160,6 +179,8 @@ Dash <- R6::R6Class(
private$assets_url_path <- sub("/$", "", assets_url_path)
private$assets_ignore <- assets_ignore
private$suppress_callback_exceptions <- suppress_callback_exceptions
+ private$app_root_path <- getAppPath()
+ private$app_launchtime <- as.integer(Sys.time())
# config options
self$config$routes_pathname_prefix <- resolve_prefix(routes_pathname_prefix, "DASH_ROUTES_PATHNAME_PREFIX")
@@ -181,17 +202,18 @@ Dash <- R6::R6Class(
call. = FALSE
)
} else if (dir.exists(private$assets_folder)) {
- private$asset_map <- private$walk_assets_directory(private$assets_folder)
- private$css <- private$asset_map$css
- private$scripts <- private$asset_map$scripts
- private$other <- private$asset_map$other
+ if (length(countEnclosingFrames("dash_nested_fiery_server")) == 0) {
+ private$refreshAssetMap()
+ private$last_refresh <- as.integer(Sys.time())
+ }
+ # fiery is attempting to launch a server within a server, do not refresh assets
}
}
# ------------------------------------------------------------------------
# Set a sensible default logger
# ------------------------------------------------------------------------
- server$set_logger(fiery::logger_console("{event}: {message}"))
+ server$set_logger(dashLogger)
server$access_log_format <- fiery::combined_log_format
# ------------------------------------------------------------------------
@@ -402,10 +424,6 @@ Dash <- R6::R6Class(
warn = FALSE,
encoding = "UTF-8")
response$status <- 200L
- response$set_header('Cache-Control',
- sprintf('public, max-age=%s',
- components_cache_max_age)
- )
response$type <- get_mimetype(filename)
}
@@ -459,10 +477,6 @@ Dash <- R6::R6Class(
close(file_handle)
}
- response$set_header('Cache-Control',
- sprintf('public, max-age=%s',
- components_cache_max_age)
- )
response$status <- 200L
}
TRUE
@@ -480,10 +494,6 @@ Dash <- R6::R6Class(
file.size(asset_path))
close(file_handle)
- response$set_header('Cache-Control',
- sprintf('public, max-age=%s',
- components_cache_max_age)
- )
response$type <- 'image/x-icon'
response$status <- 200L
TRUE
@@ -498,11 +508,55 @@ Dash <- R6::R6Class(
TRUE
})
+ dash_reload_hash <- paste0(self$config$routes_pathname_prefix, "_reload-hash")
+ route$add_handler("get", dash_reload_hash, function(request, response, keys, ...) {
+ modified_files <- private$modified_since_reload
+ hard <- TRUE
+
+ if (is.null(modified_files)) {
+ # dash-renderer requires that this element not be NULL
+ modified_files <- list()
+ }
+
+ resp <- list(files = modified_files,
+ hard = hard,
+ packages = c("dash_renderer",
+ unique(
+ vapply(
+ private$dependencies,
+ function(x) x[["name"]],
+ FUN.VALUE=character(1),
+ USE.NAMES = FALSE)
+ )
+ ),
+ reloadHash = self$config$reload_hash)
+ response$body <- to_JSON(resp)
+ response$status <- 200L
+ response$type <- 'json'
+ # reset the field for the next reloading operation
+ private$modified_since_reload <- list()
+ TRUE
+ })
+
router$add_route(route, "dashR-endpoints")
server$attach(router)
server$on("start", function(server, ...) {
+ private$generateReloadHash()
private$index()
+
+ use_viewer <- !(is.null(getOption("viewer"))) && (dynGet("use_viewer") == TRUE)
+ host <- dynGet("host")
+ port <- dynGet("port")
+
+ app_url <- paste0("http://", host, ":", port)
+
+ if (use_viewer && host %in% c("localhost", "127.0.0.1"))
+ rstudioapi::viewer(app_url)
+ else if (use_viewer) {
+ warning("\U{26A0} RStudio viewer not supported; ensure that host is 'localhost' or '127.0.0.1' and that you are using RStudio to run your app. Opening default browser...")
+ utils::browseURL(app_url)
+ }
})
# user-facing fields
@@ -573,32 +627,186 @@ Dash <- R6::R6Class(
# convenient fiery wrappers
# ------------------------------------------------------------------------
run_server = function(host = Sys.getenv('DASH_HOST', "127.0.0.1"),
- port = Sys.getenv('DASH_PORT', 8050),
+ port = Sys.getenv('DASH_PORT'),
block = TRUE,
showcase = FALSE,
+ use_viewer = FALSE,
dev_tools_prune_errors = TRUE,
- debug = FALSE,
- dev_tools_ui = NULL,
- dev_tools_props_check = NULL,
+ debug = Sys.getenv('DASH_DEBUG'),
+ dev_tools_ui = Sys.getenv('DASH_UI'),
+ dev_tools_props_check = Sys.getenv('DASH_PROPS_CHECK'),
+ dev_tools_hot_reload = Sys.getenv('DASH_HOT_RELOAD'),
+ dev_tools_hot_reload_interval = Sys.getenv('DASH_HOT_RELOAD_INTERVAL'),
+ dev_tools_hot_reload_watch_interval = Sys.getenv('DASH_HOT_RELOAD_WATCH_INTERVAL)'),
+ dev_tools_hot_reload_max_retry = Sys.getenv('DASH_HOT_RELOAD_MAX_RETRY'),
+ dev_tools_silence_routes_logging = NULL,
...) {
- self$server$host <- host
- self$server$port <- as.numeric(port)
+ if (exists("dash_nested_fiery_server", env=parent.frame(1))) {
+ # fiery is attempting to launch a server within a server, abort gracefully
+ return(NULL)
+ }
- if (is.null(dev_tools_ui) && debug || isTRUE(dev_tools_ui)) {
- self$config$ui <- TRUE
+ getServerParam <- function(value, type, default) {
+ if (length(value) == 0 || is.na(value))
+ return(default)
+ if (type %in% c("double", "integer") && value < 0)
+ return(default)
+ if (toupper(value) %in% c("TRUE", "FALSE") && type == "logical")
+ value <- as.logical(toupper(value))
+ if (type == "integer")
+ value <- as.integer(value)
+ if (type == "double")
+ value <- as.double(value)
+ if (value != "" && typeof(value) == type) {
+ return(value)
+ } else {
+ return(default)
+ }
+ }
+
+ debug <- getServerParam(debug, "logical", FALSE)
+ private$debug <- debug
+
+ self$server$host <- getServerParam(host, "character", "127.0.0.1")
+ self$server$port <- getServerParam(as.integer(port), "integer", 8050)
+
+ dev_tools_ui <- getServerParam(dev_tools_ui, "logical", debug)
+ dev_tools_props_check <- getServerParam(dev_tools_props_check, "logical", debug)
+ dev_tools_silence_routes_logging <- getServerParam(dev_tools_silence_routes_logging, "logical", debug)
+ dev_tools_hot_reload <- getServerParam(dev_tools_hot_reload, "logical", debug)
+
+ private$prune_errors <- getServerParam(dev_tools_prune_errors, "logical", TRUE)
+
+ if(getAppPath() != FALSE) {
+ source_dir <- dirname(getAppPath())
+ private$app_root_modtime <- modtimeFromPath(source_dir, recursive = TRUE, asset_path = private$assets_folder)
} else {
- self$config$ui <- FALSE
+ source_dir <- NULL
}
- if (is.null(dev_tools_props_check) && debug || isTRUE(dev_tools_props_check)) {
- self$config$props_check <- TRUE
+ # set the modtime to track state of the Dash app directory
+ # this calls getAppPath, which will try three approaches to
+ # identifying the local app path (depending on whether the app
+ # is invoked via script, source(), or executed dire ctly from console)
+ self$config$ui <- dev_tools_ui
+
+ if (dev_tools_hot_reload) {
+ hot_reload <- TRUE
+ hot_reload_interval <- getServerParam(dev_tools_hot_reload_interval, "double", 3)
+ hot_reload_watch_interval <- getServerParam(dev_tools_hot_reload_watch_interval, "double", 0.5)
+ hot_reload_max_retry <- getServerParam(as.integer(dev_tools_hot_reload_max_retry), "integer", 8)
+ # convert from seconds to msec as used by js `setInterval`
+ self$config$hot_reload <- list(interval = hot_reload_watch_interval * 1000, max_retry = hot_reload_max_retry)
} else {
- self$config$props_check <- FALSE
+ hot_reload <- FALSE
}
- private$prune_errors <- dev_tools_prune_errors
- private$debug <- debug
+ self$config$silence_routes_logging <- dev_tools_silence_routes_logging
+ self$config$props_check <- dev_tools_props_check
+
+ if (hot_reload == TRUE & !(is.null(source_dir))) {
+ self$server$on('cycle-end', function(server, ...) {
+ # handle case where assets are not present, since we can still hot reload the app itself
+ # private$last_refresh will get set after the asset_map is refreshed
+ # private$last_cycle will be set when the cycle-end handler terminates
+ if (!is.null(private$last_cycle) & !is.null(hot_reload_interval)) {
+ # determine if the time since last cycle end is equal to or longer than the requested check interval
+ permit_reload <- (as.integer(Sys.time()) - private$last_cycle) >= hot_reload_interval
+ } else {
+ permit_reload <- FALSE
+ }
+
+ if (permit_reload) {
+ if (dir.exists(private$assets_folder)) {
+ # by specifying asset_path, we can exclude assets from the root_modtime when recursive=TRUE
+ # otherwise modifying CSS assets will always trigger a hard reload
+ current_asset_modtime <- modtimeFromPath(private$assets_folder, recursive = TRUE)
+ current_root_modtime <- modtimeFromPath(source_dir, recursive = TRUE, asset_path = private$assets_folder)
+ updated_assets <- isTRUE(current_asset_modtime > private$asset_modtime)
+ updated_root <- isTRUE(current_root_modtime > private$app_root_modtime)
+ private$app_root_modtime <- current_root_modtime
+ } else {
+ # there is no assets folder, update the root modtime only
+ current_asset_modtime <- NULL
+ current_root_modtime <- modtimeFromPath(source_dir, recursive = TRUE)
+ updated_root <- isTRUE(current_root_modtime > private$app_root_modtime)
+ updated_assets <- FALSE
+ private$app_root_modtime <- current_root_modtime
+ }
+
+ if (!is.null(current_asset_modtime) && updated_assets) {
+ # refreshAssetMap silently returns a list of updated objects in the map
+ # we can use this to retrieve the modified files, and also determine if
+ # any are scripts or other non-CSS data
+ has_assets <- file.exists(file.path(source_dir, private$assets_folder))
+
+ if (length(has_assets) != 0 && has_assets) {
+ updated_files <- private$refreshAssetMap()
+ file_extensions <- tools::file_ext(updated_files$modified)
+
+ # if the vector of file_extensions is logical(0), this ensures
+ # we return FALSE instead of logical(0)
+ checkIfCSS <- function(extension) {
+ if (length(extension) == 0)
+ return(FALSE)
+ else
+ return(extension == "css")
+ }
+
+ all_updated <- c(updated_files$added, updated_files$modified)
+ private$modified_since_reload <- lapply(setNames(all_updated, NULL),
+ function(current_file) {
+ list(is_css = checkIfCSS(tools::file_ext(current_file)),
+ modified = modtimeFromPath(current_file),
+ url = paste(private$assets_url_path, basename(current_file), sep="/"))
+ })
+
+ private$asset_modtime <- current_asset_modtime
+ # update the hash passed back to the renderer, and bump the timestamp
+ # to match the current reloading event
+ other_changed <- any(tools::file_ext(updated_files$modified) != "css")
+ other_added <- any(tools::file_ext(updated_files$added) != "css")
+ other_deleted <- any(tools::file_ext(updated_files$deleted) != "css")
+ }
+ }
+ if (updated_assets || updated_root) {
+ self$config$reload_hash <- private$generateReloadHash()
+ flush.console()
+
+ # if any filetypes other than CSS are encountered in those which
+ # are modified or deleted, restart the server
+ hard_reload <- updated_root || (has_assets && (other_changed || other_added || other_deleted))
+
+ if (!hard_reload) {
+ # refresh the index but don't restart the server
+ private$index()
+ } else {
+ # if the server was started via Rscript or via source()
+ # then update the app object here
+ if (!(getAppPath() == FALSE)) {
+ app_env <- new.env(parent = .GlobalEnv)
+ # set the flag to automatically abort the server on execution
+ assign("dash_nested_fiery_server", TRUE, envir=app_env)
+ source(getAppPath(), app_env)
+ # set the layout and refresh the callback map
+ write(crayon::cyan$bold("\U{1F504} Changes to app or its assets detected, reloading ..."), stderr())
+ private$callback_map <- get("callback_map", envir=get("app", envir=app_env)$.__enclos_env__$private)
+ private$layout_ <- get("layout_", envir=get("app", envir=app_env)$.__enclos_env__$private)
+ private$index()
+ # tear down the temporary environment
+ rm(app_env)
+ }
+ }
+ }
+ }
+
+ # reset the timestamp so we're able to determine when the last cycle end occurred
+ private$last_cycle <- as.integer(Sys.time())
+ })
+ } else if (hot_reload == TRUE & is.null(source_dir)) {
+ message("\U{26A0} No source directory information available; hot reloading has been disabled.\nPlease ensure that you are loading your Dash for R application using source().\n")
+ }
self$server$ignite(block = block, showcase = showcase, ...)
}
),
@@ -618,7 +826,7 @@ Dash <- R6::R6Class(
scripts = NULL,
other = NULL,
- # initialize flags for debug mode and stack pruning,
+ # initialize flags for debug mode and stack pruning
debug = NULL,
prune_errors = NULL,
stack_message = NULL,
@@ -626,6 +834,16 @@ Dash <- R6::R6Class(
# callback context
callback_context_ = NULL,
+ # fields for setting modification times and paths to track state
+ asset_modtime = NULL,
+ app_launchtime = NULL,
+ app_root_path = NULL,
+ app_root_modtime = NULL,
+ last_reload = NULL,
+ last_refresh = NULL,
+ last_cycle = NULL,
+ modified_since_reload = NULL,
+
# fields for tracking HTML dependencies
dependencies = list(),
dependencies_user = list(),
@@ -718,6 +936,60 @@ Dash <- R6::R6Class(
layout_
},
+ refreshAssetMap = function() {
+ # if hot reloading, use canonical path to app as retrieved via getAppPath()
+ # this should be useful if the server is run in non-blocking mode while
+ # hot reloading is active, and the user decides to setwd() ...
+ if (getAppPath() != FALSE) {
+ private$asset_modtime <- modtimeFromPath(file.path(dirname(getAppPath()), private$assets_folder), recursive = TRUE)
+ } else {
+ private$asset_modtime <- modtimeFromPath(private$assets_folder, recursive = TRUE)
+ }
+
+ # before refreshing the asset map, temporarily store it for the
+ # comparison with the updated map
+ previous_map <- private$asset_map
+
+ # refresh the asset map
+ current_map <- private$walk_assets_directory(private$assets_folder)
+
+ # need to check whether the assets have actually been updated, since
+ # this function is also called to generate the asset map
+ if (private$asset_modtime > private$app_launchtime) {
+ # here we use mapply to make pairwise comparisons for each of the
+ # asset classes in the map -- before/after for css, js, and other
+ # assets; this returns a list whose subelements correspond to each
+ # class, and three vectors of updated objects for each (deleted,
+ # changed, and new files)
+ list_of_diffs <- mapply(changedAssets,
+ previous_map,
+ current_map,
+ SIMPLIFY=FALSE)
+
+ # these lines collapse the modified assets into vectors, and scrub
+ # duplicated NULL return values
+ deleted <- unlist(lapply(list_of_diffs, `[`, "deleted"))
+ changed <- unlist(lapply(list_of_diffs, `[`, "changed"))
+ new <- unlist(lapply(list_of_diffs, `[`, "new"))
+
+ # update the asset map
+ private$asset_map <- current_map
+
+ # when the asset map is refreshed, this function will invisibly
+ # return the vectors of updated assets, grouped by deleted,
+ # modified, and added files
+ private$last_refresh <- as.integer(Sys.time())
+
+ return(invisible(list(deleted=deleted,
+ modified=changed,
+ added=new)))
+ } else {
+ private$asset_map <- current_map
+ private$last_refresh <- as.integer(Sys.time())
+ return(NULL)
+ }
+ },
+
walk_assets_directory = function(assets_dir = private$assets_folder) {
# obtain the full canonical path
asset_path <- normalizePath(file.path(assets_dir))
@@ -804,9 +1076,13 @@ Dash <- R6::R6Class(
other_files_map <- NULL
}
- return(list(css = css_map,
- scripts = scripts_map,
- other = other_files_map))
+ # set attributes for the return object to include the file
+ # modification times for each entry in the asset_map
+ return(list(css = setModtimeAsAttr(css_map),
+ scripts = setModtimeAsAttr(scripts_map),
+ other = setModtimeAsAttr(other_files_map)
+ )
+ )
},
componentify = function(x) {
@@ -835,6 +1111,20 @@ Dash <- R6::R6Class(
# note discussion here https://github.com/plotly/dash/blob/d2ebc837/dash/dash.py#L279-L284
.index = NULL,
+ generateReloadHash = function() {
+ last_update_time <- max(as.integer(private$app_root_modtime),
+ as.integer(private$asset_modtime),
+ as.integer(private$app_launchtime),
+ na.rm=TRUE)
+
+ # update the timestamp to reflect the current reloading event
+ private$last_reload <- as.integer(Sys.time())
+
+ digest::digest(as.character(last_update_time),
+ "md5",
+ serialize = FALSE)
+ },
+
collect_resources = function() {
# Dash's own dependencies
# serve the dev version of dash-renderer when in debug mode
@@ -891,10 +1181,10 @@ Dash <- R6::R6Class(
prefix=self$config$requests_pathname_prefix)
# collect CSS assets from dependencies
- if (!(is.null(private$css))) {
- css_assets <- generate_css_dist_html(href = paste0(private$assets_url_path, names(private$css)),
+ if (!(is.null(private$asset_map$css))) {
+ css_assets <- generate_css_dist_html(href = paste0(private$assets_url_path, names(private$asset_map$css)),
local = TRUE,
- local_path = private$css,
+ local_path = private$asset_map$css,
prefix = self$config$requests_pathname_prefix)
}
else {
@@ -909,10 +1199,10 @@ Dash <- R6::R6Class(
# collect JS assets from dependencies
#
- if (!(is.null(private$scripts))) {
- scripts_assets <- generate_js_dist_html(href = paste0(private$assets_url_path, names(private$scripts)),
+ if (!(is.null(private$asset_map$scripts))) {
+ scripts_assets <- generate_js_dist_html(href = paste0(private$assets_url_path, names(private$asset_map$scripts)),
local = TRUE,
- local_path = private$scripts,
+ local_path = private$asset_map$scripts,
prefix = self$config$requests_pathname_prefix)
} else {
scripts_assets <- NULL
@@ -925,7 +1215,7 @@ Dash <- R6::R6Class(
# create tag for favicon, if present
# other_files_map[names(other_files_map) %in% "/favicon.ico"]
- if ("/favicon.ico" %in% names(private$other)) {
+ if ("/favicon.ico" %in% names(private$asset_map$other)) {
favicon <- sprintf("")
} else {
favicon <- ""
diff --git a/R/utils.R b/R/utils.R
index c69816aa..595ef4d7 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -684,7 +684,7 @@ encode_plotly <- function(layout_objs) {
# so that it is pretty printed to stderr()
printCallStack <- function(call_stack, header=TRUE) {
if (header) {
- write(crayon::yellow$bold(" ### DashR Traceback (most recent/innermost call last) ###"), stderr())
+ write(crayon::yellow$bold(" ### Dash for R Traceback (most recent/innermost call last) ###"), stderr())
}
write(
crayon::white(
@@ -694,7 +694,9 @@ printCallStack <- function(call_stack, header=TRUE) {
call_stack
),
": ",
- call_stack
+ call_stack,
+ " ",
+ lapply(call_stack, attr, "flineref")
)
),
stderr()
@@ -707,7 +709,7 @@ stackTraceToHTML <- function(call_stack,
if(is.null(call_stack)) {
return(NULL)
}
- header <- " ### DashR Traceback (most recent/innermost call last) ###"
+ header <- " ### Dash for R Traceback (most recent/innermost call last) ###"
formattedStack <- c(paste0(
" ",
@@ -716,6 +718,8 @@ stackTraceToHTML <- function(call_stack,
),
": ",
call_stack,
+ " ",
+ lapply(call_stack, attr, "lineref"),
collapse=" "
)
)
@@ -761,7 +765,19 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
}
functionsAsList <- lapply(calls, function(completeCall) {
- currentCall <- completeCall[[1]]
+ # avoid attempting to cast closures as strings, which will fail
+ # some calls in the stack are symbol (name) objects, while others
+ # are calls, which must be deparsed; the first element in the vector
+ # should be the function signature
+ if (is.name(completeCall[[1]]))
+ currentCall <- as.character(completeCall[[1]])
+ else if (is.call(completeCall[[1]]))
+ currentCall <- deparse(completeCall)[1]
+ else
+ currentCall <- completeCall[[1]]
+
+ attr(currentCall, "flineref") <- getLineWithError(completeCall, formatted=TRUE)
+ attr(currentCall, "lineref") <- getLineWithError(completeCall, formatted=FALSE)
if (is.function(currentCall) & !is.primitive(currentCall)) {
constructedCall <- paste0(" function(",
@@ -813,18 +829,16 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
functionsAsList <- removeHandlers(functionsAsList)
}
- # use deparse in case the call throwing the error is a symbol,
- # since this cannot be "printed" without deparsing the call
warning(call. = FALSE, immediate. = TRUE, sprintf("Execution error in %s: %s",
- deparse(functionsAsList[[length(functionsAsList)]]),
+ functionsAsList[[length(functionsAsList)]],
conditionMessage(e)))
stack_message <- stackTraceToHTML(functionsAsList,
- deparse(functionsAsList[[length(functionsAsList)]]),
+ functionsAsList[[length(functionsAsList)]],
conditionMessage(e))
assign("stack_message", value=stack_message,
- envir=sys.frame(1)$private)
+ envir=sys.frame(countEnclosingFrames("private"))$private)
printCallStack(functionsAsList)
}
@@ -836,8 +850,22 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
)
} else {
evalq(expr)
- }
}
+}
+
+getLineWithError <- function(currentCall, formatted=TRUE) {
+ srcref <- attr(currentCall, "srcref", exact = TRUE)
+ if (!is.null(srcref) & !(getAppPath()==FALSE)) {
+ # filename
+ srcfile <- attr(srcref, "srcfile", exact = TRUE)
+ # line number
+ context <- sprintf("-- %s, Line %s", srcfile$filename, srcref[[1]])
+ if (formatted)
+ context <- crayon::yellow$italic(context)
+ return(context)
+ } else
+ ""
+}
# This helper function drops error
# handling functions from the call
@@ -920,6 +948,190 @@ getIdProps <- function(output) {
return(list(ids=ids, props=props))
}
+modtimeFromPath <- function(path, recursive = FALSE, asset_path="") {
+ # ensure path is properly formatted
+ path <- normalizePath(path)
+
+ if (is.null(path)) {
+ return(NULL)
+ }
+
+ if (recursive) {
+ if (asset_path != "") {
+ all_files <- file.info(list.files(path, recursive = TRUE))
+ # need to exclude files which are in assets directory so we don't always hard reload
+ initpath <- vapply(strsplit(rownames(all_files), split = .Platform$file.sep), `[`, FUN.VALUE=character(1), 1)
+ # now subset the modtimes, and identify the most recently modified file
+ modtime <- as.integer(max(all_files$mtime[which(initpath != asset_path)], na.rm = TRUE))
+ } else {
+ # now identify the most recently modified file
+ all_files <- list.files(path, recursive = TRUE, full.names = TRUE)
+ modtime <- as.integer(max(file.info(all_files)$mtime, na.rm=TRUE))
+ }
+ } else {
+ # check if the path is for a directory or file, and handle accordingly
+ if (dir.exists(path))
+ modtime <- as.integer(max(file.info(list.files(path, full.names = TRUE))$mtime, na.rm=TRUE))
+ else
+ modtime <- as.integer(file.info(path)$mtime)
+ }
+
+ return(modtime)
+}
+
+getAppPath <- function() {
+ # attempt to retrieve path for Dash apps served via
+ # Rscript or source()
+ cmd_args <- commandArgs(trailingOnly = FALSE)
+ file_argument <- "--file="
+ matched_arg <- grep(file_argument, cmd_args)
+
+ # if app is instantiated via Rscript, cmd_args should contain path
+ if (length(matched_arg) > 0) {
+ # Rscript
+ return(normalizePath(sub(file_argument, "", cmd_args[matched_arg])))
+ }
+ # if app is instantiated via source(), sys.frames should contain path
+ else if (!is.null(sys.frames()[[1]]$ofile)) {
+ return(normalizePath(sys.frames()[[1]]$ofile))
+ }
+ else {
+ return(FALSE)
+ }
+}
+
+# this function enables Dash to set file modification times
+# as attributes on the vectors stored within the asset map
+#
+# this permits storing additional information on the object
+# without dramatically modifying the existing API, and makes
+# it somewhat trivial to request the set of modification times
+setModtimeAsAttr <- function(path) {
+ if (!is.null(path)) {
+ mtime <- modtimeFromPath(path)
+ attributes(path)$modtime <- mtime
+ return(path)
+ } else {
+ return(NULL)
+ }
+}
+
+countEnclosingFrames <- function(object) {
+ for (i in 1:sys.nframe()) {
+ objs <- ls(envir=sys.frame(i))
+ if (object %in% objs)
+ return(i)
+ }
+}
+
+changedAssets <- function(before, after) {
+ # identify files that used to exist in the asset map,
+ # but which have been removed
+ deletedElements <- before[which(is.na(match(before, after)))]
+
+ # identify files which were added since the last refresh
+ addedElements <- after[which(is.na(match(after, before)))]
+
+ # identify any items that have been updated since the last
+ # refresh based on modification time attributes set in map
+ #
+ # in R, attributes are discarded when subsetting, so it's
+ # necessary to subset the attributes being compared instead.
+ # here we only compare objects which overlap
+ before_modtimes <-attributes(before)$modtime[before %in% after]
+ after_modtimes <- attributes(after)$modtime[after %in% before]
+
+ changedElements <- after[which(after_modtimes > before_modtimes)]
+
+ if (length(deletedElements) == 0) {
+ deletedElements <- NULL
+ }
+ if (length(changedElements) == 0) {
+ changedElements <- NULL
+ }
+ if (length(addedElements) == 0) {
+ addedElements <- NULL
+ }
+ invisible(return(
+ list(deleted = deletedElements,
+ changed = changedElements,
+ new = addedElements)
+ )
+ )
+}
+
+dashLogger <- function(event = NULL,
+ message = NULL,
+ request = NULL,
+ time = Sys.time(),
+ ...) {
+ orange <- crayon::make_style("orange")
+
+ # dashLogger is being called from within fiery, and the Fire() object generator
+ # is called from a private method within the Dash() R6 class; this makes
+ # accessing variables set within Dash's private fields somewhat complicated
+ #
+ # the following line retrieves the value of the silence_route_logging parameter,
+ # which is nearly 20 frames up the stack; if it's not found, we'll assume FALSE
+ silence_routes_logging <- dynGet("self", ifnotfound = FALSE)$config$silence_routes_logging
+
+ if (!is.null(event)) {
+ msg <- sprintf("%s: %s", event, message)
+
+ msg <- switch(event, error = crayon::red(msg), warning = crayon::yellow(msg),
+ message = crayon::blue(msg), msg)
+
+ # assign the status group for color coding
+ if (event == "request") {
+ status_group <- as.integer(cut(request$respond()$status,
+ breaks = c(100, 200, 300, 400, 500, 600), right = FALSE))
+
+ msg <- switch(status_group, crayon::blue$bold(msg), crayon::green$bold(msg),
+ crayon::cyan$bold(msg), orange$bold(msg), crayon::red$bold(msg))
+ }
+
+ # if log messages are suppressed, report only server stop/start messages, errors, and warnings
+ # otherwise, print everything to console
+ if (event %in% c("start", "stop", "error", "warning") || !(silence_routes_logging)) {
+ cat(msg, file = stdout(), append = TRUE)
+ cat("\n", file = stdout(), append = TRUE)
+ }
+ }
+}
+
+#' Define a clientside callback
+#'
+#' Create a callback that updates the output by calling a clientside (JavaScript) function instead of an R function.
+#'
+#' @param namespace Character. Describes where the JavaScript function resides (Dash will look
+#' for the function at `window[namespace][function_name]`.)
+#' @param function_name Character. Provides the name of the JavaScript function to call.
+#'
+#' @details With this signature, Dash's front-end will call `window.my_clientside_library.my_function` with the current
+#' values of the `value` properties of the components `my-input` and `another-input` whenever those values change.
+#' Include a JavaScript file by including it your `assets/` folder. The file can be named anything but you'll need to
+#' assign the function's namespace to the `window`. For example, this file might look like:
+#' \preformatted{window.my_clientside_library = \{
+#' my_function: function(input_value_1, input_value_2) \{
+#' return (
+#' parseFloat(input_value_1, 10) +
+#' parseFloat(input_value_2, 10)
+#' );
+#' \}
+#'\}
+#'}
+#'
+#'
+#' @export
+#' @examples \dontrun{
+#' app$callback(
+#' output('output-clientside', 'children'),
+#' params=list(input('input', 'value')),
+#' clientsideFunction(
+#' namespace = 'my_clientside_library',
+#' function_name = 'my_function'
+#' )
+#' )}
clientsideFunction <- function(namespace, function_name) {
return(list(namespace=namespace, function_name=function_name))
}
diff --git a/man/Dash.Rd b/man/Dash.Rd
index a25254b6..3fcd2858 100644
--- a/man/Dash.Rd
+++ b/man/Dash.Rd
@@ -20,7 +20,10 @@ assets_url_path = '/assets',
assets_ignore = '',
serve_locally = TRUE,
routes_pathname_prefix = '/',
-requests_pathname_prefix = '/'
+requests_pathname_prefix = '/',
+external_scripts = NULL,
+external_stylesheets = NULL,
+suppress_callback_exceptions = FALSE
)
}
@@ -49,10 +52,7 @@ to serve JavaScript source for rendered pages.\cr
\code{external_stylesheets} \tab \tab An optional list of valid URLs from which
to serve CSS for rendered pages.\cr
\code{suppress_callback_exceptions} \tab \tab Whether to relay warnings about
-possible layout mis-specifications when registering a callback. \cr
-\code{components_cache_max_age} \tab \tab An integer value specifying the time
-interval prior to expiring cached assets. The default is 2678400 seconds,
-or 31 calendar days.
+possible layout mis-specifications when registering a callback.
}
}
@@ -90,18 +90,45 @@ The \code{callback} method has three formal arguments:
\describe{
\item{output}{a named list including a component \code{id} and \code{property}}
\item{params}{an unnamed list of \link{input} and \link{state} statements, each with defined \code{id} and \code{property}}
-\item{func}{any valid R function which generates \link{output} provided \link{input} and/or \link{state} arguments}
+\item{func}{any valid R function which generates \link{output} provided \link{input} and/or \link{state} arguments, or a call to \link{clientsideFunction} including \code{namespace} and \code{function_name} arguments for a locally served JavaScript function}
}
The \code{output} argument defines which layout component property should
receive the results (via the \link{output} object). The events that
trigger the callback are then described by the \link{input} (and/or \link{state})
object(s) (which should reference layout components), which become
-argument values for the callback handler defined in \code{func}.
-}
-\item{\code{run_server(host = Sys.getenv('DASH_HOST', "127.0.0.1"), port = Sys.getenv('DASH_PORT', 8050), block = TRUE, showcase = FALSE, ...)}}{
-Launch the application. If provided, \code{host}/\code{port} set
-the \code{host}/\code{port} fields of the underlying \link[fiery:Fire]{fiery::Fire} web
-server. The \code{block}/\code{showcase}/\code{...} arguments are passed along
+argument values for R callback handlers defined in \code{func}. Here \code{func} may
+either be an anonymous R function, or a call to \code{clientsideFunction()}, which
+describes a locally served JavaScript function instead. The latter defines a
+"clientside callback", which updates components without passing data to and
+from the Dash backend. The latter may offer improved performance relative
+to callbacks written in R.
+}
+\item{\code{callback_context()}}{
+The \code{callback_context} method permits retrieving the inputs which triggered
+the firing of a given callback, and allows introspection of the input/state
+values given their names. It is only available from within a callback;
+attempting to use this method outside of a callback will result in a warning.
+}
+\item{\code{run_server(host = Sys.getenv('DASH_HOST', "127.0.0.1"), port = Sys.getenv('DASH_PORT', 8050), block = TRUE, showcase = FALSE, ...)}}{
+The \code{run_server} method has 13 formal arguments, several of which are optional:
+\describe{
+\item{host}{Character. A string specifying a valid IPv4 address for the Fiery server, or \code{0.0.0.0} to listen on all addresses. Default is \code{127.0.0.1} Environment variable: \code{DASH_HOST}.}
+\item{port}{Integer. Specifies the port number on which the server should listen (default is \code{8050}). Environment variable: \code{DASH_PORT}.}
+\item{block}{Logical. Start the server while blocking console input? Default is \code{TRUE}.}
+\item{showcase}{Logical. Load the Dash application into the default web browser when server starts? Default is \code{FALSE}.}
+\item{use_viewer}{Logical. Load the Dash application into RStudio's viewer pane? Requires that \code{host} is either \code{127.0.0.1} or \code{localhost}, and that Dash application is started within RStudio; if \code{use_viewer = TRUE} and these conditions are not satsified, the user is warned and the app opens in the default browser instead. Default is \code{FALSE}.}
+\item{debug}{Logical. Enable/disable all the dev tools unless overridden by the arguments or environment variables. Default is \code{FALSE} when called via \code{run_server}. Environment variable: \code{DASH_DEBUG}.}
+\item{dev_tools_ui}{Logical. Show Dash's dev tools UI? Default is \code{TRUE} if \code{debug == TRUE}, \code{FALSE} otherwise. Environment variable: \code{DASH_UI}.}
+\item{dev_tools_hot_reload}{Logical. Activate hot reloading when app, assets, and component files change? Default is \code{TRUE} if \code{debug == TRUE}, \code{FALSE} otherwise. Requires that the Dash application is loaded using \code{source()}, so that \code{srcref} attributes are available for executed code. Environment variable: \code{DASH_HOT_RELOAD}.}
+\item{dev_tools_hot_reload_interval}{Numeric. Interval in seconds for the client to request the reload hash. Default is \code{3}. Environment variable: \code{DASH_HOT_RELOAD_INTERVAL}.}
+\item{dev_tools_hot_reload_watch_interval}{Numeric. Interval in seconds for the server to check asset and component folders for changes. Default \code{0.5}. Environment variable: \code{DASH_HOT_RELOAD_WATCH_INTERVAL}.}
+\item{dev_tools_hot_reload_max_retry}{Integer. Maximum number of failed reload hash requests before failing and displaying a pop up. Default \code{0.5}. Environment variable: \code{DASH_HOT_RELOAD_MAX_RETRY}.}
+\item{dev_tools_props_check}{Logical. Validate the types and values of Dash component properties? Default is \code{TRUE} if \code{debug == TRUE}, \code{FALSE} otherwise. Environment variable: \code{DASH_PROPS_CHECK}.}
+\item{dev_tools_prune_errors}{Logical. Reduce tracebacks to just user code, stripping out Fiery and Dash pieces? Only available with debugging. \code{TRUE} by default, set to \code{FALSE} to see the complete traceback. Environment variable: \code{DASH_PRUNE_ERRORS}.}
+\item{dev_tools_silence_routes_logging}{Logical. Replace Fiery's default logger with \code{dashLogger} instead (will remove all routes logging)? Enabled with debugging by default because hot reload hash checks generate a lot of requests.}
+}
+Starts the Fiery server in local mode. If a parameter can be set by an environment variable, that is listed too. Values provided here take precedence over environment variables.
+Launch the application. If provided, \code{host}/\code{port} set the \code{host}/\code{port} fields of the underlying \link[fiery:Fire]{fiery::Fire} web server. The \code{block}/\code{showcase}/\code{...} arguments are passed along
to the \code{ignite()} method of the \link[fiery:Fire]{fiery::Fire} server.
}
}
diff --git a/man/clientsideFunction.Rd b/man/clientsideFunction.Rd
new file mode 100644
index 00000000..a82438db
--- /dev/null
+++ b/man/clientsideFunction.Rd
@@ -0,0 +1,43 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/utils.R
+\name{clientsideFunction}
+\alias{clientsideFunction}
+\title{Define a clientside callback}
+\usage{
+clientsideFunction(namespace, function_name)
+}
+\arguments{
+\item{namespace}{Character. Describes where the JavaScript function resides (Dash will look
+for the function at \code{window[namespace][function_name]}.)}
+
+\item{function_name}{Character. Provides the name of the JavaScript function to call.}
+}
+\description{
+Create a callback that updates the output by calling a clientside (JavaScript) function instead of an R function.
+}
+\details{
+With this signature, Dash's front-end will call \code{window.my_clientside_library.my_function} with the current
+values of the \code{value} properties of the components \code{my-input} and \code{another-input} whenever those values change.
+Include a JavaScript file by including it your \code{assets/} folder. The file can be named anything but you'll need to
+assign the function's namespace to the \code{window}. For example, this file might look like:
+\preformatted{window.my_clientside_library = \{
+my_function: function(input_value_1, input_value_2) \{
+ return (
+ parseFloat(input_value_1, 10) +
+ parseFloat(input_value_2, 10)
+ );
+\}
+\}
+}
+}
+\examples{
+\dontrun{
+app$callback(
+ output('output-clientside', 'children'),
+ params=list(input('input', 'value')),
+ clientsideFunction(
+ namespace = 'my_clientside_library',
+ function_name = 'my_function'
+ )
+)}
+}
diff --git a/tests/integration/devtools/assets/hot_reload.css b/tests/integration/devtools/assets/hot_reload.css
new file mode 100644
index 00000000..7b6acbf5
--- /dev/null
+++ b/tests/integration/devtools/assets/hot_reload.css
@@ -0,0 +1,3 @@
+#hot-reload-content {
+ background-color: blue;
+}
diff --git a/tests/integration/devtools/hard_reload/app.R b/tests/integration/devtools/hard_reload/app.R
new file mode 100644
index 00000000..821bb5a3
--- /dev/null
+++ b/tests/integration/devtools/hard_reload/app.R
@@ -0,0 +1,26 @@
+
+library(dash)
+library(dashHtmlComponents)
+library(dashCoreComponents)
+app <- Dash$new()
+
+app$layout(htmlDiv(list(
+htmlH3("Test hard reloading (when modifying any non-CSS resources)"),
+dccInput(id='input'),
+htmlDiv(id='output-serverside')
+),
+id="hot-reload-content"
+)
+)
+
+app$callback(
+ output(id = "output-serverside", property = "children"),
+ params = list(
+ input(id = "input", property = "value")
+ ),
+ function(value) {
+ sprintf("Pre-reloading test output should be %s", value)
+ }
+)
+
+app$run_server(dev_tools_hot_reload=TRUE, dev_tools_hot_reload_interval=0.1, dev_tools_silence_routes_logging=TRUE)
diff --git a/tests/integration/devtools/test_hard_reload.py b/tests/integration/devtools/test_hard_reload.py
new file mode 100644
index 00000000..f6fe59b0
--- /dev/null
+++ b/tests/integration/devtools/test_hard_reload.py
@@ -0,0 +1,66 @@
+from selenium.webdriver.support.select import Select
+import time, os
+
+
+app = os.path.join(os.path.dirname(__file__), "hard_reload/app.R")
+changed_app = """
+library(dash)
+library(dashHtmlComponents)
+library(dashCoreComponents)
+app <- Dash$new()
+
+app$layout(htmlDiv(list(
+htmlH3("Test hard reloading (when modifying any non-CSS resources)"),
+dccInput(id='input'),
+htmlDiv(id='output-serverside')
+),
+id="hot-reload-content"
+)
+)
+
+app$callback(
+ output(id = "output-serverside", property = "children"),
+ params = list(
+ input(id = "input", property = "value")
+ ),
+ function(value) {
+ sprintf("Post-reloading test output should be %s", value)
+ }
+)
+
+app$run_server(dev_tools_hot_reload=TRUE, dev_tools_hot_reload_watch_interval=1, dev_tools_hot_reload_interval=0.1, dev_tools_silence_routes_logging=TRUE)
+"""
+
+
+def test_rsdv002_hard_reload(dashr):
+ dashr.start_server(app)
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Pre-reloading test output should be NULL"
+ )
+ input1 = dashr.find_element("#input")
+ dashr.clear_input(input1)
+ input1.send_keys("unchanged")
+ with open(app, "r+") as fp:
+ time.sleep(1) # ensure a new mod time
+ old_content = fp.read()
+ fp.truncate(0)
+ fp.seek(0)
+ fp.write(changed_app)
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Post-reloading test output should be NULL"
+ )
+ input1 = dashr.find_element("#input")
+ dashr.clear_input(input1)
+ input1.send_keys("different")
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Post-reloading test output should be different"
+ )
+ with open(app, "w") as f:
+ f.write(old_content)
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Pre-reloading test output should be NULL"
+ )
diff --git a/tests/integration/devtools/test_soft_reload.py b/tests/integration/devtools/test_soft_reload.py
new file mode 100644
index 00000000..043af190
--- /dev/null
+++ b/tests/integration/devtools/test_soft_reload.py
@@ -0,0 +1,81 @@
+from selenium.webdriver.support.select import Select
+import time, os
+
+
+RED_BG = """
+#hot-reload-content {
+ background-color: red;
+}
+"""
+
+
+app = """
+library(dash)
+library(dashHtmlComponents)
+library(dashCoreComponents)
+app <- Dash$new()
+
+app$layout(htmlDiv(list(
+htmlH3("Hot reload"),
+dccInput(id='input'),
+htmlDiv(id='output-serverside')
+),
+id="hot-reload-content"
+)
+)
+
+app$callback(
+ output(id = "output-serverside", property = "children"),
+ params = list(
+ input(id = "input", property = "value")
+ ),
+ function(value) {
+ sprintf("Test output should be %s", value)
+ }
+)
+
+app$run_server(dev_tools_hot_reload=TRUE, dev_tools_hot_reload_interval=0.1, dev_tools_silence_routes_logging=TRUE)
+"""
+
+
+def test_rsdv001_soft_reload(dashr):
+ dashr.start_server(app)
+ dashr.wait_for_style_to_equal(
+ "#hot-reload-content",
+ "background-color",
+ "rgba(0, 0, 255, 1)"
+ )
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Test output should be NULL"
+ )
+ input1 = dashr.find_element("#input")
+ dashr.clear_input(input1)
+ input1.send_keys("unchanged")
+ hot_reload_file = os.path.join(
+ dashr.server.tmp_app_path, "assets", "hot_reload.css"
+ )
+ print(hot_reload_file)
+ with open(hot_reload_file, "r+") as fp:
+ time.sleep(1) # ensure a new mod time
+ old_content = fp.read()
+ fp.truncate(0)
+ fp.seek(0)
+ fp.write(RED_BG)
+ dashr.wait_for_style_to_equal(
+ "#hot-reload-content",
+ "background-color",
+ "rgba(255, 0, 0, 1)"
+ )
+ dashr.wait_for_text_to_equal(
+ "#output-serverside",
+ "Test output should be unchanged"
+ )
+ with open(hot_reload_file, "w") as f:
+ f.write(old_content)
+ time.sleep(1)
+ dashr.wait_for_style_to_equal(
+ "#hot-reload-content",
+ "background-color",
+ "rgba(0, 0, 255, 1)"
+ )