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)" + )