mirror of
https://github.com/rstudio/shiny.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
40 Commits
fix/condit
...
non-blocki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c91f62a1 | ||
|
|
88b4facb8f | ||
|
|
957b50d3b6 | ||
|
|
da50bf2249 | ||
|
|
72636ef4a0 | ||
|
|
02a5e0b40f | ||
|
|
0ff93d411f | ||
|
|
bd250962e4 | ||
|
|
3a0e8627a4 | ||
|
|
b813adec56 | ||
|
|
f29fa65af9 | ||
|
|
36e7a330d6 | ||
|
|
6d984266f9 | ||
|
|
c27c186c0f | ||
|
|
2907e83c42 | ||
|
|
45985690b2 | ||
|
|
ce11abe46d | ||
|
|
7e8903f754 | ||
|
|
664cbe2858 | ||
|
|
63af3649c8 | ||
|
|
3cb928e894 | ||
|
|
8e63d08d8a | ||
|
|
1db26f60af | ||
|
|
1432920a7e | ||
|
|
3882d1e4c3 | ||
|
|
532c17081a | ||
|
|
329bc979c6 | ||
|
|
0456847883 | ||
|
|
6bbe29a390 | ||
|
|
c8bfa93747 | ||
|
|
27134d9c66 | ||
|
|
48540283a4 | ||
|
|
49b76badcc | ||
|
|
935de77aee | ||
|
|
8b53c6d2fd | ||
|
|
3ccbad7a70 | ||
|
|
13812b45a7 | ||
|
|
08680d9566 | ||
|
|
620e5a277b | ||
|
|
bb26c0f4d3 |
@@ -126,6 +126,7 @@ Encoding: UTF-8
|
||||
Roxygen: list(markdown = TRUE)
|
||||
RoxygenNote: 7.3.3
|
||||
Collate:
|
||||
'app-handle.R'
|
||||
'globals.R'
|
||||
'app-state.R'
|
||||
'app_template.R'
|
||||
|
||||
@@ -276,6 +276,7 @@ export(snapshotPreprocessInput)
|
||||
export(snapshotPreprocessOutput)
|
||||
export(span)
|
||||
export(splitLayout)
|
||||
export(startApp)
|
||||
export(stopApp)
|
||||
export(strong)
|
||||
export(submitButton)
|
||||
|
||||
28
NEWS.md
28
NEWS.md
@@ -1,35 +1,23 @@
|
||||
# shiny (development version)
|
||||
|
||||
## Bug fixes
|
||||
## New features
|
||||
|
||||
* `conditionalPanel()` no longer briefly flashes its contents on app start
|
||||
when the condition is initially `FALSE`. (#3505)
|
||||
|
||||
## Improvements
|
||||
|
||||
* Output resize/visibility detection now uses native browser observers
|
||||
(`ResizeObserver`, `IntersectionObserver`) instead of relying on jQuery
|
||||
`shown`/`hidden` events and `window.resize`. This makes Shiny's client-side
|
||||
output-info pipeline (image/plot sizing, hidden-state tracking, theme
|
||||
reporting) work automatically in any layout — including CSS-only show/hide,
|
||||
third-party tab components, and non-Bootstrap frameworks — without requiring
|
||||
custom event hooks. (#3682)
|
||||
* New `startApp()` runs a Shiny app in non-blocking mode, returning a
|
||||
`ShinyAppHandle` object with `stop()`, `status()`, `url()`, and `result()`
|
||||
methods. When a new app is started, any previously running non-blocking app
|
||||
is automatically stopped.
|
||||
|
||||
# shiny 1.13.0
|
||||
|
||||
## New features
|
||||
|
||||
* Shiny now supports interactive breakpoints when used with Ark (e.g. in
|
||||
Positron). (#4352)
|
||||
* Shiny now supports interactive breakpoints when used with Ark (e.g. in Positron). (#4352)
|
||||
|
||||
## Bug fixes and minor improvements
|
||||
|
||||
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`)
|
||||
now hide internal Shiny rendering pipeline frames, making error messages
|
||||
cleaner and more focused on user code. (#4358)
|
||||
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`) now hide internal Shiny rendering pipeline frames, making error messages cleaner and more focused on user code. (#4358)
|
||||
|
||||
* Fixed an issue with `actionLink()` that extended the link underline to
|
||||
whitespace around the text. (#4348)
|
||||
* Fixed an issue with `actionLink()` that extended the link underline to whitespace around the text. (#4348)
|
||||
|
||||
|
||||
# shiny 1.12.1
|
||||
|
||||
73
R/app-handle.R
Normal file
73
R/app-handle.R
Normal file
@@ -0,0 +1,73 @@
|
||||
# Handle returned by startApp()
|
||||
ShinyAppHandle <- R6::R6Class("ShinyAppHandle",
|
||||
cloneable = FALSE,
|
||||
|
||||
public = list(
|
||||
initialize = function(appUrl, cleanupFn) {
|
||||
private$appUrl <- appUrl
|
||||
private$cleanupFn <- cleanupFn
|
||||
|
||||
reg.finalizer(self, function(e) {
|
||||
tryCatch(e$stop(), error = function(cnd) NULL)
|
||||
}, onexit = TRUE)
|
||||
},
|
||||
|
||||
stop = function() {
|
||||
if (self$status() != "running") {
|
||||
return(invisible(self))
|
||||
}
|
||||
private$stopped <- TRUE
|
||||
private$captureResult()
|
||||
private$cleanupFn()
|
||||
private$cleanupFn <- NULL
|
||||
invisible(self)
|
||||
},
|
||||
|
||||
url = function() private$appUrl,
|
||||
|
||||
status = function() {
|
||||
if (!private$stopped) {
|
||||
"running"
|
||||
} else if (!is.null(private$resultError)) {
|
||||
"error"
|
||||
} else {
|
||||
"success"
|
||||
}
|
||||
},
|
||||
|
||||
result = function() {
|
||||
if (self$status() == "running") {
|
||||
stop("App is still running. Use status() to check if the app has stopped.")
|
||||
}
|
||||
if (!is.null(private$resultError)) {
|
||||
stop(private$resultError)
|
||||
}
|
||||
private$resultValue
|
||||
},
|
||||
|
||||
print = function(...) {
|
||||
cat("Shiny app handle\n")
|
||||
cat(" URL: ", private$appUrl, "\n", sep = "")
|
||||
cat(" Status:", self$status(), "\n")
|
||||
invisible(self)
|
||||
}
|
||||
),
|
||||
|
||||
private = list(
|
||||
appUrl = NULL,
|
||||
cleanupFn = NULL,
|
||||
# Whether this handle has been stopped. Distinct from .globals$stopped
|
||||
# which tracks whether a stop was requested (set by stopApp() or stop()).
|
||||
stopped = FALSE,
|
||||
resultValue = NULL,
|
||||
resultError = NULL,
|
||||
|
||||
captureResult = function() {
|
||||
if (isTRUE(.globals$reterror)) {
|
||||
private$resultError <- .globals$retval
|
||||
} else if (!is.null(.globals$retval)) {
|
||||
private$resultValue <- .globals$retval$value
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
305
R/runapp.R
305
R/runapp.R
@@ -46,6 +46,12 @@
|
||||
#' only used for recording or running automated tests. Defaults to the
|
||||
#' `shiny.testmode` option, or FALSE if the option is not set.
|
||||
#'
|
||||
#' @return The value passed to [stopApp()], or throws an error if the app was
|
||||
#' stopped with an error.
|
||||
#'
|
||||
#' @seealso [startApp()] for non-blocking mode, [stopApp()] to stop a running
|
||||
#' app.
|
||||
#'
|
||||
#' @examples
|
||||
#' \dontrun{
|
||||
#' # Start app in the current working directory
|
||||
@@ -93,18 +99,14 @@ runApp <- function(
|
||||
display.mode=c("auto", "normal", "showcase"),
|
||||
test.mode=getOption('shiny.testmode', FALSE)
|
||||
) {
|
||||
|
||||
# * Wrap **all** execution of the app inside the otel promise domain
|
||||
# * While this could be done at a lower level, it allows for _anything_ within
|
||||
# shiny's control to allow for the opportunity to have otel active spans be
|
||||
# reactivated upon promise domain restoration
|
||||
promises::local_otel_promise_domain()
|
||||
|
||||
on.exit({
|
||||
handlerManager$clear()
|
||||
}, add = TRUE)
|
||||
|
||||
if (isRunning()) {
|
||||
# Check for nested blocking runApp() before sourcing app code
|
||||
if (isRunning() && is.null(.globals$runningHandle)) {
|
||||
stop("Can't call `runApp()` from within `runApp()`. If your ",
|
||||
"application code contains `runApp()`, please remove it.")
|
||||
}
|
||||
@@ -116,14 +118,13 @@ runApp <- function(
|
||||
warn = max(1, getOption("warn", default = 1)),
|
||||
pool.scheduler = scheduleTask
|
||||
)
|
||||
on.exit(options(ops), add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# Global onStart/onStop callbacks
|
||||
# ============================================================================
|
||||
# Invoke user-defined onStop callbacks, before the application's internal
|
||||
# onStop callbacks.
|
||||
on.exit({
|
||||
# Ensure options are restored and onStop callbacks fire even if
|
||||
# as.shiny.appobj() errors. Once .setupShinyApp() succeeds, the returned
|
||||
# cleanup function takes over and this guard becomes a no-op.
|
||||
setupComplete <- FALSE
|
||||
on.exit(if (!setupComplete) {
|
||||
options(ops)
|
||||
.globals$onStopCallbacks$invoke()
|
||||
.globals$onStopCallbacks <- Callbacks$new()
|
||||
}, add = TRUE)
|
||||
@@ -135,32 +136,140 @@ runApp <- function(
|
||||
# ============================================================================
|
||||
appParts <- as.shiny.appobj(appDir)
|
||||
|
||||
# ============================================================================
|
||||
# Initialize app state object
|
||||
# ============================================================================
|
||||
# This is so calls to getCurrentAppState() can be used to find (A) whether an
|
||||
# app is running and (B), get options and data associated with the app.
|
||||
initCurrentAppState(appParts)
|
||||
on.exit(clearCurrentAppState(), add = TRUE)
|
||||
# Any shinyOptions set after this point will apply to the current app only
|
||||
# (and will not persist after the app stops).
|
||||
result <- .setupShinyApp(
|
||||
appDir, appParts, port, launch.browser, host,
|
||||
workerId, quiet, display.mode, test.mode, ops = ops
|
||||
)
|
||||
setupComplete <- TRUE
|
||||
on.exit(result$cleanup(), add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# shinyOptions
|
||||
# Run event loop via httpuv
|
||||
# ============================================================================
|
||||
# A unique identifier associated with this run of this application. It is
|
||||
# shared across sessions.
|
||||
shinyOptions(appToken = createUniqueId(8))
|
||||
# Top-level ..stacktraceoff..; matches with ..stacktraceon in observe(),
|
||||
# reactive(), Callbacks$invoke(), and others
|
||||
..stacktraceoff..(
|
||||
captureStackTraces({
|
||||
while (!.globals$stopped) {
|
||||
..stacktracefloor..(serviceApp())
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
# Set up default cache for app.
|
||||
if (is.null(getShinyOption("cache", default = NULL))) {
|
||||
shinyOptions(cache = cachem::cache_mem(max_size = 200 * 1024^2))
|
||||
if (isTRUE(.globals$reterror)) {
|
||||
stop(.globals$retval)
|
||||
} else if (.globals$retval$visible) {
|
||||
.globals$retval$value
|
||||
} else {
|
||||
invisible(.globals$retval$value)
|
||||
}
|
||||
}
|
||||
|
||||
# Extract appOptions (which is a list) and store them as shinyOptions, for
|
||||
# this app. (This is the only place we have to store settings that are
|
||||
# accessible both the UI and server portion of the app.)
|
||||
applyCapturedAppOptions(appParts$appOptions)
|
||||
#' Start Shiny Application (Non-Blocking)
|
||||
#'
|
||||
#' Starts a Shiny application in non-blocking mode, returning a
|
||||
#' `ShinyAppHandle` immediately while the app runs in the background.
|
||||
#' The `later` event loop services the app, so the R console remains
|
||||
#' available for interaction.
|
||||
#'
|
||||
#' @inheritParams runApp
|
||||
#'
|
||||
#' @return A `ShinyAppHandle` object with methods `stop()`, `status()`,
|
||||
#' `url()`, and `result()`. The `status()` method returns `"running"`,
|
||||
#' `"success"`, or `"error"`. The `result()` method throws an error if called
|
||||
#' while running, or re-throws the error if the app stopped with an error.
|
||||
#'
|
||||
#' @examples
|
||||
#' \dontrun{
|
||||
#' # Start app in the background
|
||||
#' handle <- startApp("myapp")
|
||||
#'
|
||||
#' # Check status
|
||||
#' handle$status()
|
||||
#' handle$url()
|
||||
#'
|
||||
#' # Stop the app
|
||||
#' handle$stop()
|
||||
#' }
|
||||
#'
|
||||
#' @seealso [runApp()] for blocking mode, [stopApp()] to stop a running app.
|
||||
#' @export
|
||||
startApp <- function(
|
||||
appDir = getwd(),
|
||||
port = getOption("shiny.port"),
|
||||
launch.browser = getOption("shiny.launch.browser", interactive()),
|
||||
host = getOption("shiny.host", "127.0.0.1"),
|
||||
workerId = "",
|
||||
quiet = FALSE,
|
||||
display.mode = c("auto", "normal", "showcase"),
|
||||
test.mode = getOption("shiny.testmode", FALSE)
|
||||
) {
|
||||
# OTEL: `local_otel_promise_domain()` ties its lifetime to this frame,
|
||||
# which exits as soon as the handle is returned — before any request is
|
||||
# served. A persistent global install would instead leak into unrelated
|
||||
# user promises between ticks. Wrap the synchronous setup below (covers
|
||||
# onStart) and each service iteration in `serviceNonBlocking()` (covers
|
||||
# handlers and observers). The domain is dormant between ticks, so it
|
||||
# stays out of user promises created at the console.
|
||||
|
||||
# Make warnings print immediately
|
||||
# Set pool.scheduler to support pool package
|
||||
ops <- options(
|
||||
# Raise warn level to 1, but don't lower it
|
||||
warn = max(1, getOption("warn", default = 1)),
|
||||
pool.scheduler = scheduleTask
|
||||
)
|
||||
|
||||
# Ensure options are restored and onStop callbacks fire even if
|
||||
# as.shiny.appobj() errors. See matching guard in runApp().
|
||||
setupComplete <- FALSE
|
||||
on.exit(if (!setupComplete) {
|
||||
options(ops)
|
||||
.globals$onStopCallbacks$invoke()
|
||||
.globals$onStopCallbacks <- Callbacks$new()
|
||||
}, add = TRUE)
|
||||
|
||||
require(shiny)
|
||||
|
||||
result <- promises::with_otel_promise_domain({
|
||||
appParts <- as.shiny.appobj(appDir)
|
||||
.setupShinyApp(
|
||||
appDir, appParts, port, launch.browser, host,
|
||||
workerId, quiet, display.mode, test.mode, ops = ops
|
||||
)
|
||||
})
|
||||
setupComplete <- TRUE
|
||||
|
||||
handle <- ShinyAppHandle$new(result$appUrl, result$cleanup)
|
||||
.globals$runningHandle <- handle
|
||||
serviceNonBlocking(handle, .globals$serviceGeneration)
|
||||
handle
|
||||
}
|
||||
|
||||
# Shared initialization for runApp() and startApp().
|
||||
# Handles all app setup: options, state, httpuv server, browser launch, etc.
|
||||
# Returns list(appUrl, cleanup) where cleanup() tears down the app.
|
||||
# On setup failure, internal on.exit handlers clean up partial state.
|
||||
.setupShinyApp <- function(appDir, appParts, port, launch.browser, host,
|
||||
workerId, quiet, display.mode, test.mode, ops,
|
||||
caller = parent.frame()) {
|
||||
# Guard on.exit handlers with this flag so they only fire on setup failure.
|
||||
# On success, cleanup responsibility is handed to the caller via the
|
||||
# returned cleanup function.
|
||||
cleanupOnExit <- TRUE
|
||||
|
||||
on.exit(if (cleanupOnExit) handlerManager$clear(), add = TRUE)
|
||||
|
||||
if (isRunning()) {
|
||||
if (!is.null(.globals$runningHandle)) {
|
||||
message("Stopping running Shiny app.")
|
||||
.globals$runningHandle$stop()
|
||||
} else {
|
||||
stop("Can't start a new app while another is running. ",
|
||||
"If your application code contains `runApp()` or `startApp()`, remove it. ",
|
||||
"Otherwise, stop the current app first with stopApp().")
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# runApp options set via shinyApp(options = list(...))
|
||||
@@ -182,25 +291,55 @@ runApp <- function(
|
||||
# | no | yes | use runApp | if it's not missing (runApp specifies), use those |
|
||||
# | yes | yes | use runApp | if it's not missing (runApp specifies), use those |
|
||||
#
|
||||
# I tried to make this as compact and intuitive as possible,
|
||||
# given that there are four distinct possibilities to check
|
||||
# `missing()` runs in the caller's frame: with defaults on the outer
|
||||
# formals, arguments are no longer missing by the time they reach here.
|
||||
appOps <- appParts$options
|
||||
findVal <- function(arg, default) {
|
||||
if (arg %in% names(appOps)) appOps[[arg]] else default
|
||||
}
|
||||
if (evalq(missing(port), caller)) port <- findVal("port", port)
|
||||
if (evalq(missing(launch.browser), caller)) launch.browser <- findVal("launch.browser", launch.browser)
|
||||
if (evalq(missing(host), caller)) host <- findVal("host", host)
|
||||
if (evalq(missing(quiet), caller)) quiet <- findVal("quiet", quiet)
|
||||
if (evalq(missing(display.mode), caller)) display.mode <- findVal("display.mode", display.mode)
|
||||
if (evalq(missing(test.mode), caller)) test.mode <- findVal("test.mode", test.mode)
|
||||
|
||||
if (missing(port))
|
||||
port <- findVal("port", port)
|
||||
if (missing(launch.browser))
|
||||
launch.browser <- findVal("launch.browser", launch.browser)
|
||||
if (missing(host))
|
||||
host <- findVal("host", host)
|
||||
if (missing(quiet))
|
||||
quiet <- findVal("quiet", quiet)
|
||||
if (missing(display.mode))
|
||||
display.mode <- findVal("display.mode", display.mode)
|
||||
if (missing(test.mode))
|
||||
test.mode <- findVal("test.mode", test.mode)
|
||||
on.exit(if (cleanupOnExit) options(ops), add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# Global onStart/onStop callbacks
|
||||
# ============================================================================
|
||||
on.exit(if (cleanupOnExit) {
|
||||
.globals$onStopCallbacks$invoke()
|
||||
.globals$onStopCallbacks <- Callbacks$new()
|
||||
}, add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# Initialize app state object
|
||||
# ============================================================================
|
||||
# This is so calls to getCurrentAppState() can be used to find (A) whether an
|
||||
# app is running and (B), get options and data associated with the app.
|
||||
initCurrentAppState(appParts)
|
||||
on.exit(if (cleanupOnExit) clearCurrentAppState(), add = TRUE)
|
||||
# Any shinyOptions set after this point will apply to the current app only
|
||||
# (and will not persist after the app stops).
|
||||
|
||||
# ============================================================================
|
||||
# shinyOptions
|
||||
# ============================================================================
|
||||
# A unique identifier associated with this run of this application. It is
|
||||
# shared across sessions.
|
||||
shinyOptions(appToken = createUniqueId(8))
|
||||
|
||||
# Set up default cache for app.
|
||||
if (is.null(getShinyOption("cache", default = NULL))) {
|
||||
shinyOptions(cache = cachem::cache_mem(max_size = 200 * 1024^2))
|
||||
}
|
||||
|
||||
# Extract appOptions (which is a list) and store them as shinyOptions, for
|
||||
# this app. (This is the only place we have to store settings that are
|
||||
# accessible both the UI and server portion of the app.)
|
||||
applyCapturedAppOptions(appParts$appOptions)
|
||||
|
||||
if (is.null(host) || is.na(host)) host <- '0.0.0.0'
|
||||
|
||||
@@ -286,7 +425,7 @@ runApp <- function(
|
||||
|
||||
# If display mode is specified as an argument, apply it (overriding the
|
||||
# value specified in DESCRIPTION, if any).
|
||||
display.mode <- match.arg(display.mode)
|
||||
display.mode <- match.arg(display.mode, c("auto", "normal", "showcase"))
|
||||
if (display.mode == "normal") {
|
||||
setShowcaseDefault(0)
|
||||
}
|
||||
@@ -340,24 +479,21 @@ runApp <- function(
|
||||
# onStart/onStop callbacks
|
||||
# ============================================================================
|
||||
# Set up the onStop before we call onStart, so that it gets called even if an
|
||||
# error happens in onStart.
|
||||
# error happens in onStart or later during startup.
|
||||
if (!is.null(appParts$onStop))
|
||||
on.exit(appParts$onStop(), add = TRUE)
|
||||
on.exit(if (cleanupOnExit) appParts$onStop(), add = TRUE)
|
||||
if (!is.null(appParts$onStart))
|
||||
appParts$onStart()
|
||||
|
||||
# ============================================================================
|
||||
# Start/stop httpuv app
|
||||
# Start httpuv app
|
||||
# ============================================================================
|
||||
server <- startApp(appParts, port, host, quiet)
|
||||
server <- startHttpuvApp(appParts, port, host, quiet)
|
||||
|
||||
# Make the httpuv server object accessible. Needed for calling
|
||||
# addResourcePath while app is running.
|
||||
shinyOptions(server = server)
|
||||
|
||||
on.exit({
|
||||
stopServer(server)
|
||||
}, add = TRUE)
|
||||
on.exit(if (cleanupOnExit) stopServer(server), add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# Launch web browser
|
||||
@@ -388,39 +524,52 @@ runApp <- function(
|
||||
# Application hooks
|
||||
# ============================================================================
|
||||
callAppHook("onAppStart", appUrl)
|
||||
on.exit({
|
||||
callAppHook("onAppStop", appUrl)
|
||||
}, add = TRUE)
|
||||
on.exit(if (cleanupOnExit) callAppHook("onAppStop", appUrl), add = TRUE)
|
||||
|
||||
# ============================================================================
|
||||
# Run event loop via httpuv
|
||||
# ============================================================================
|
||||
# Initialize globals used by the event loop and stopApp()
|
||||
.globals$reterror <- NULL
|
||||
.globals$retval <- NULL
|
||||
.globals$stopped <- FALSE
|
||||
# Top-level ..stacktraceoff..; matches with ..stacktraceon in observe(),
|
||||
# reactive(), Callbacks$invoke(), and others
|
||||
..stacktraceoff..(
|
||||
captureStackTraces({
|
||||
while (!.globals$stopped) {
|
||||
..stacktracefloor..(serviceApp())
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (isTRUE(.globals$reterror)) {
|
||||
stop(.globals$retval)
|
||||
# Invalidate any stale non-blocking service loops from a previous app.
|
||||
# Each app launch gets a fresh generation so old callbacks become no-ops.
|
||||
.globals$serviceGeneration <- (.globals$serviceGeneration %||% 0L) + 1L
|
||||
|
||||
# Setup complete - disable on.exit cleanup, hand off to caller
|
||||
cleanupOnExit <- FALSE
|
||||
|
||||
list(
|
||||
appUrl = appUrl,
|
||||
cleanup = .createCleanup(server, appParts, appUrl, ops)
|
||||
)
|
||||
}
|
||||
|
||||
# Consolidated cleanup function for app teardown
|
||||
.createCleanup <- function(server, appParts, appUrl, ops) {
|
||||
cleanedUp <- FALSE
|
||||
function() {
|
||||
if (cleanedUp) return()
|
||||
cleanedUp <<- TRUE
|
||||
|
||||
.globals$stopped <- TRUE
|
||||
.globals$runningHandle <- NULL
|
||||
handlerManager$clear()
|
||||
options(ops)
|
||||
.globals$onStopCallbacks$invoke()
|
||||
.globals$onStopCallbacks <- Callbacks$new()
|
||||
clearCurrentAppState()
|
||||
if (!is.null(appParts$onStop)) appParts$onStop()
|
||||
stopServer(server)
|
||||
callAppHook("onAppStop", appUrl)
|
||||
}
|
||||
else if (.globals$retval$visible)
|
||||
.globals$retval$value
|
||||
else
|
||||
invisible(.globals$retval$value)
|
||||
}
|
||||
|
||||
#' Stop the currently running Shiny app
|
||||
#'
|
||||
#' Stops the currently running Shiny app, returning control to the caller of
|
||||
#' [runApp()].
|
||||
#' [runApp()]. Despite the similar names, `stopApp()` is not the
|
||||
#' counterpart of [startApp()] — it is the counterpart of [runApp()],
|
||||
#' controlling its return value via `returnValue`.
|
||||
#'
|
||||
#' @param returnValue The value that should be returned from
|
||||
#' [runApp()].
|
||||
|
||||
56
R/server.R
56
R/server.R
@@ -387,7 +387,7 @@ removeSubApp <- function(path) {
|
||||
handlerManager$removeWSHandler(path)
|
||||
}
|
||||
|
||||
startApp <- function(appObj, port, host, quiet) {
|
||||
startHttpuvApp <- function(appObj, port, host, quiet) {
|
||||
appHandlers <- createAppHandlers(appObj$httpHandler, appObj$serverFuncSource)
|
||||
handlerManager$addHandler(appHandlers$http, "/", tail = TRUE)
|
||||
handlerManager$addWSHandler(appHandlers$ws, "/", tail = TRUE)
|
||||
@@ -479,9 +479,12 @@ startApp <- function(appObj, port, host, quiet) {
|
||||
}
|
||||
}
|
||||
|
||||
# Run an application that was created by \code{\link{startApp}}. This
|
||||
# Run an application that was created by \code{\link{startHttpuvApp}}. This
|
||||
# function should normally be called in a \code{while(TRUE)} loop.
|
||||
serviceApp <- function() {
|
||||
serviceApp <- function(
|
||||
# rely on lazy evaluation for maximum efficiency
|
||||
timeout = max(1, min(maxTimeout, timerCallbacks$timeToNextEvent(), later::next_op_secs()))
|
||||
) {
|
||||
timerCallbacks$executeElapsed()
|
||||
|
||||
flushReact()
|
||||
@@ -491,13 +494,58 @@ serviceApp <- function() {
|
||||
# to keep the session responsive to user input
|
||||
maxTimeout <- ifelse(interactive(), 100, 1000)
|
||||
|
||||
timeout <- max(1, min(maxTimeout, timerCallbacks$timeToNextEvent(), later::next_op_secs()))
|
||||
service(timeout)
|
||||
|
||||
flushReact()
|
||||
flushPendingSessions()
|
||||
}
|
||||
|
||||
# Non-blocking service loop using later callbacks.
|
||||
# Uses 1ms delay between iterations to yield CPU for console interaction.
|
||||
# The generation token (incremented on every runApp() call) ensures that when
|
||||
# a new app starts, any stale service loop from a previous non-blocking app
|
||||
# exits cleanly instead of continuing to run.
|
||||
# Each iteration wraps `serviceApp()` in `with_otel_promise_domain()` so the
|
||||
# OTEL domain is active while Shiny processes its own work — handlers,
|
||||
# later callbacks, promise fulfillments — all executed synchronously inside
|
||||
# `serviceApp()`. Span wrapping is attached at promise-registration time, so
|
||||
# callbacks registered inside an iteration stay instrumented when they fire
|
||||
# later. The domain is dormant between ticks, keeping it out of unrelated
|
||||
# user promises created while the console is interactive.
|
||||
serviceNonBlocking <- function(handle, generation) {
|
||||
serviceLoop <- function() {
|
||||
if (!identical(.globals$serviceGeneration, generation)) {
|
||||
return(invisible())
|
||||
}
|
||||
if (!.globals$stopped) {
|
||||
promises::with_otel_promise_domain(
|
||||
..stacktraceoff..(
|
||||
captureStackTraces(
|
||||
tryCatch(
|
||||
..stacktracefloor..(serviceApp(.shinyServiceDelaySecs * 1000)),
|
||||
error = function(e) {
|
||||
.globals$stopped <- TRUE
|
||||
.globals$retval <- e
|
||||
.globals$reterror <- TRUE
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!identical(.globals$serviceGeneration, generation)) {
|
||||
return(invisible())
|
||||
}
|
||||
if (!.globals$stopped) {
|
||||
later::later(serviceLoop, delay = .shinyServiceDelaySecs)
|
||||
} else {
|
||||
handle$stop()
|
||||
}
|
||||
}
|
||||
later::later(serviceLoop, delay = .shinyServiceDelaySecs)
|
||||
}
|
||||
|
||||
.shinyServiceDelaySecs <- 0.001
|
||||
.shinyServerMinVersion <- '0.3.4'
|
||||
|
||||
#' Check whether a Shiny application is running
|
||||
|
||||
@@ -41,7 +41,7 @@ export default [{
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.eslint.json"],
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -230,10 +230,6 @@
|
||||
this.args = args;
|
||||
this.$invoke();
|
||||
}
|
||||
cancel() {
|
||||
this.$clearTimer();
|
||||
this.args = null;
|
||||
}
|
||||
isPending() {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
@@ -254,7 +250,7 @@
|
||||
};
|
||||
function debounce(threshold, func) {
|
||||
let timerId = null;
|
||||
const debounced = function thisFunc(...args) {
|
||||
return function thisFunc(...args) {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
@@ -265,13 +261,6 @@
|
||||
func.apply(thisFunc, args);
|
||||
}, threshold);
|
||||
};
|
||||
debounced.cancel = function() {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// srcts/src/time/invoke.ts
|
||||
@@ -342,58 +331,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
// srcts/src/shiny/sendOutputInfo.ts
|
||||
var _pendingObserverCallbacks;
|
||||
var SendOutputInfo = class {
|
||||
constructor() {
|
||||
__privateAdd(this, _pendingObserverCallbacks, /* @__PURE__ */ new Set());
|
||||
}
|
||||
setSendMethod(inputBatchSender, doSendOutputInfo) {
|
||||
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
|
||||
// srcts/src/shiny/sendImageSize.ts
|
||||
var SendImageSize = class {
|
||||
setImageSend(inputBatchSender, doSendImageSize) {
|
||||
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
|
||||
this.regular = function() {
|
||||
sendOutputInfoDebouncer.normalCall();
|
||||
sendImageSizeDebouncer.normalCall();
|
||||
};
|
||||
inputBatchSender.lastChanceCallback.push(() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).forEach((callback) => callback.flush());
|
||||
if (sendOutputInfoDebouncer.isPending())
|
||||
sendOutputInfoDebouncer.immediateCall();
|
||||
inputBatchSender.lastChanceCallback.push(function() {
|
||||
if (sendImageSizeDebouncer.isPending())
|
||||
sendImageSizeDebouncer.immediateCall();
|
||||
});
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
return sendOutputInfoDebouncer;
|
||||
}
|
||||
createObserverCallback(delayMs, callback) {
|
||||
const debouncer = new Debouncer(
|
||||
null,
|
||||
() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
callback();
|
||||
},
|
||||
delayMs
|
||||
);
|
||||
const observerCallback = Object.assign(
|
||||
() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).add(observerCallback);
|
||||
debouncer.normalCall();
|
||||
},
|
||||
{
|
||||
cancel: () => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
debouncer.cancel();
|
||||
},
|
||||
flush: () => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
if (debouncer.isPending()) {
|
||||
debouncer.immediateCall();
|
||||
}
|
||||
},
|
||||
isPending: () => debouncer.isPending()
|
||||
}
|
||||
);
|
||||
return observerCallback;
|
||||
return sendImageSizeDebouncer;
|
||||
}
|
||||
};
|
||||
_pendingObserverCallbacks = new WeakMap();
|
||||
var sendOutputInfoFns = new SendOutputInfo();
|
||||
var sendImageSizeFns = new SendImageSize();
|
||||
|
||||
// srcts/src/shiny/singletons.ts
|
||||
var import_jquery4 = __toESM(require_jquery());
|
||||
@@ -590,7 +543,7 @@
|
||||
$head.append(newStyle);
|
||||
oldStyle.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendOutputInfoFns.transitioned();
|
||||
sendImageSizeFns.transitioned();
|
||||
};
|
||||
xhr.send();
|
||||
};
|
||||
@@ -625,7 +578,7 @@
|
||||
$dummyEl.one("transitionend", () => {
|
||||
$dummyEl.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendOutputInfoFns.transitioned();
|
||||
sendImageSizeFns.transitioned();
|
||||
});
|
||||
(0, import_jquery5.default)(document.body).append($dummyEl);
|
||||
const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
|
||||
@@ -858,15 +811,6 @@
|
||||
}
|
||||
return x2;
|
||||
}
|
||||
function isVisible(el) {
|
||||
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (getStyle(el, "display") === "none") {
|
||||
return false;
|
||||
}
|
||||
return el.parentElement ? isVisible(el.parentElement) : true;
|
||||
}
|
||||
function padZeros(n4, digits) {
|
||||
let str = n4.toString();
|
||||
while (str.length < digits) str = "0" + str;
|
||||
@@ -5805,7 +5749,12 @@ ${duplicateIdMsg}`;
|
||||
}
|
||||
return inputItems;
|
||||
}
|
||||
async function bindOutputs({ outputBindings, outputIsRecalculating }, scope = document.documentElement) {
|
||||
async function bindOutputs({
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
outputBindings,
|
||||
outputIsRecalculating
|
||||
}, scope = document.documentElement) {
|
||||
const $scope = (0, import_jquery35.default)(scope);
|
||||
const bindings = outputBindings.getBindings();
|
||||
for (let i5 = 0; i5 < bindings.length; i5++) {
|
||||
@@ -5820,6 +5769,7 @@ ${duplicateIdMsg}`;
|
||||
if ($el.hasClass("shiny-bound-output")) {
|
||||
continue;
|
||||
}
|
||||
maybeAddThemeObserver(el);
|
||||
const bindingAdapter = new OutputBindingAdapter(el, binding);
|
||||
await shinyAppBindOutput(id, bindingAdapter);
|
||||
$el.data("shiny-output-binding", bindingAdapter);
|
||||
@@ -5837,7 +5787,8 @@ ${duplicateIdMsg}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
}
|
||||
function unbindInputs(scope = document.documentElement, includeSelf = false) {
|
||||
const inputs = (0, import_jquery35.default)(scope).find(".shiny-bound-input").toArray();
|
||||
@@ -5860,7 +5811,7 @@ ${duplicateIdMsg}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
function unbindOutputs(scope = document.documentElement, includeSelf = false) {
|
||||
function unbindOutputs({ sendOutputHiddenState }, scope = document.documentElement, includeSelf = false) {
|
||||
const outputs = (0, import_jquery35.default)(scope).find(".shiny-bound-output").toArray();
|
||||
if (includeSelf && (0, import_jquery35.default)(scope).hasClass("shiny-bound-output")) {
|
||||
outputs.push(scope);
|
||||
@@ -5874,20 +5825,6 @@ ${duplicateIdMsg}`;
|
||||
bindingsRegistry.removeBinding(id, "output");
|
||||
$el.removeClass("shiny-bound-output");
|
||||
$el.removeData("shiny-output-binding");
|
||||
for (const prefix of [
|
||||
"shiny-resize-observer",
|
||||
"shiny-intersection-observer",
|
||||
"shiny-mutate-observer"
|
||||
]) {
|
||||
const observer = $el.data(prefix);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
$el.removeData(prefix);
|
||||
}
|
||||
const callback = $el.data(prefix + "-callback");
|
||||
callback?.cancel?.();
|
||||
$el.removeData(prefix + "-callback");
|
||||
}
|
||||
$el.trigger({
|
||||
type: "shiny:unbound",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
@@ -5895,7 +5832,8 @@ ${duplicateIdMsg}`;
|
||||
bindingType: "output"
|
||||
});
|
||||
}
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
}
|
||||
async function _bindAll(shinyCtx, scope) {
|
||||
await bindOutputs(shinyCtx, scope);
|
||||
@@ -5903,9 +5841,9 @@ ${duplicateIdMsg}`;
|
||||
bindingsRegistry.checkValidity(scope);
|
||||
return currentInputs;
|
||||
}
|
||||
function unbindAll(scope, includeSelf = false) {
|
||||
function unbindAll(shinyCtx, scope, includeSelf = false) {
|
||||
unbindInputs(scope, includeSelf);
|
||||
unbindOutputs(scope, includeSelf);
|
||||
unbindOutputs(shinyCtx, scope, includeSelf);
|
||||
}
|
||||
async function bindAll(shinyCtx, scope) {
|
||||
const currentInputItems = await _bindAll(shinyCtx, scope);
|
||||
@@ -6295,7 +6233,6 @@ ${duplicateIdMsg}`;
|
||||
var messageHandlers = {};
|
||||
var customMessageHandlerOrder = [];
|
||||
var customMessageHandlers = {};
|
||||
var conditionalShownClass = "shiny-conditional--shown";
|
||||
function addMessageHandler(type, handler) {
|
||||
if (messageHandlers[type]) {
|
||||
throw 'handler for message of type "' + type + '" already added.';
|
||||
@@ -6768,15 +6705,15 @@ ${duplicateIdMsg}`;
|
||||
const nsPrefix = el.attr("data-ns-prefix");
|
||||
const nsScope = this._narrowScope(scope, nsPrefix);
|
||||
const show3 = Boolean(condFunc(nsScope));
|
||||
const showing = el.hasClass(conditionalShownClass);
|
||||
const showing = el.css("display") !== "none";
|
||||
if (show3 !== showing) {
|
||||
if (show3) {
|
||||
el.trigger("show");
|
||||
el.addClass(conditionalShownClass);
|
||||
el.show();
|
||||
el.trigger("shown");
|
||||
} else {
|
||||
el.trigger("hide");
|
||||
el.removeClass(conditionalShownClass);
|
||||
el.hide();
|
||||
el.trigger("hidden");
|
||||
}
|
||||
}
|
||||
@@ -7351,6 +7288,8 @@ ${duplicateIdMsg}`;
|
||||
return {
|
||||
inputs,
|
||||
inputsRate,
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
inputBindings,
|
||||
outputBindings,
|
||||
initDeferredIframes,
|
||||
@@ -7361,7 +7300,7 @@ ${duplicateIdMsg}`;
|
||||
await bindAll(shinyBindCtx(), scope);
|
||||
};
|
||||
this.unbindAll = function(scope, includeSelf = false) {
|
||||
unbindAll(scope, includeSelf);
|
||||
unbindAll(shinyBindCtx(), scope, includeSelf);
|
||||
};
|
||||
function initializeInputs(scope = document.documentElement) {
|
||||
const bindings = inputBindings.getBindings();
|
||||
@@ -7383,178 +7322,230 @@ ${duplicateIdMsg}`;
|
||||
function getIdFromEl(el) {
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
const bindingAdapter = $el.data("shiny-output-binding");
|
||||
return bindingAdapter ? bindingAdapter.getId() : null;
|
||||
if (!bindingAdapter) return null;
|
||||
else return bindingAdapter.getId();
|
||||
}
|
||||
initializeInputs(document.documentElement);
|
||||
const initialValues = mapValues(
|
||||
await _bindAll(shinyBindCtx(), document.documentElement),
|
||||
(x2) => x2.value
|
||||
);
|
||||
function setInput(name, value, initial = false) {
|
||||
if (initial) {
|
||||
initialValues[name] = value;
|
||||
} else {
|
||||
inputs.setInput(name, value);
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function() {
|
||||
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
|
||||
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
function doSendSize(el, initial = false) {
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
const rect = getBoundingClientSizeBeforeZoom(el);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
|
||||
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
|
||||
);
|
||||
function getComputedBgColor(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
if (!bgColor) return bgColor;
|
||||
const m2 = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
|
||||
);
|
||||
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
}
|
||||
return bgColor;
|
||||
}
|
||||
function doTriggerResize(el) {
|
||||
const $el = (0, import_jquery40.default)(el), binding = $el.data("shiny-output-binding");
|
||||
if (!binding) return;
|
||||
$el.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: isVisible(el),
|
||||
binding
|
||||
});
|
||||
binding.onResize();
|
||||
function getComputedFont(el) {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize
|
||||
};
|
||||
}
|
||||
function doSendTheme(el, initial = false) {
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function() {
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
initialValues[".clientdata_output_" + id + "_bg"] = getComputedBgColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
|
||||
el,
|
||||
"color"
|
||||
);
|
||||
initialValues[".clientdata_output_" + id + "_accent"] = getComputedLinkColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_font"] = getComputedFont(el);
|
||||
maybeAddThemeObserver(el);
|
||||
}
|
||||
);
|
||||
function maybeAddThemeObserver(el) {
|
||||
if (!window.MutationObserver) {
|
||||
return;
|
||||
}
|
||||
const cl = el.classList;
|
||||
const reportTheme = cl.contains("shiny-image-output") || cl.contains("shiny-plot-output") || cl.contains("shiny-report-theme");
|
||||
if (!reportTheme) {
|
||||
return;
|
||||
}
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
if ($el.data("shiny-theme-observer")) {
|
||||
return;
|
||||
}
|
||||
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
|
||||
const observer = new MutationObserver(
|
||||
() => observerCallback.normalCall()
|
||||
);
|
||||
const config = { attributes: true, attributeFilter: ["style", "class"] };
|
||||
observer.observe(el, config);
|
||||
$el.data("shiny-theme-observer", observer);
|
||||
}
|
||||
function doSendTheme(el) {
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
}
|
||||
function getComputedBgColor(el2) {
|
||||
if (!el2) {
|
||||
return null;
|
||||
}
|
||||
const bgColor = getStyle(el2, "background-color");
|
||||
if (!bgColor) return bgColor;
|
||||
const m2 = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
|
||||
);
|
||||
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
|
||||
const bgImage = getStyle(el2, "background-image");
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el2.parentElement);
|
||||
const id = getIdFromEl(el);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el)
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color")
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el)
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el)
|
||||
);
|
||||
}
|
||||
function doSendImageSize() {
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function() {
|
||||
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_height",
|
||||
rect.height
|
||||
);
|
||||
}
|
||||
}
|
||||
return bgColor;
|
||||
}
|
||||
function getComputedFont(el2) {
|
||||
const fontFamily = getStyle(el2, "font-family");
|
||||
const fontSize = getStyle(el2, "font-size");
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize
|
||||
};
|
||||
}
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
initial
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
initial
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function() {
|
||||
doSendTheme(this);
|
||||
}
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
initial
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
initial
|
||||
);
|
||||
}
|
||||
const visibleOutputs = /* @__PURE__ */ new Set();
|
||||
function doSendHiddenState(el, initial = false) {
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
const hidden = !isVisible(el);
|
||||
if (hidden) {
|
||||
visibleOutputs.delete(id);
|
||||
} else {
|
||||
visibleOutputs.add(id);
|
||||
}
|
||||
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
|
||||
}
|
||||
function reportsSize(el) {
|
||||
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-size");
|
||||
}
|
||||
function reportsTheme(el) {
|
||||
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-theme");
|
||||
}
|
||||
function handleVisualChange(el) {
|
||||
doTriggerResize(el);
|
||||
doSendHiddenState(el);
|
||||
if (reportsSize(el)) doSendSize(el);
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
}
|
||||
function ensureObservers(el) {
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
if (!$el.data("shiny-resize-observer")) {
|
||||
const onResize = sendOutputInfoFns.createObserverCallback(
|
||||
100,
|
||||
() => handleVisualChange(el)
|
||||
);
|
||||
const ro = new ResizeObserver(() => onResize());
|
||||
ro.observe(el);
|
||||
$el.data("shiny-resize-observer-callback", onResize);
|
||||
$el.data("shiny-resize-observer", ro);
|
||||
}
|
||||
if (!$el.data("shiny-intersection-observer")) {
|
||||
const onIntersect = sendOutputInfoFns.createObserverCallback(
|
||||
100,
|
||||
() => handleVisualChange(el)
|
||||
);
|
||||
const io = new IntersectionObserver(() => onIntersect());
|
||||
io.observe(el);
|
||||
$el.data("shiny-intersection-observer-callback", onIntersect);
|
||||
$el.data("shiny-intersection-observer", io);
|
||||
}
|
||||
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
|
||||
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
});
|
||||
const mo = new MutationObserver(() => onMutate());
|
||||
mo.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"]
|
||||
});
|
||||
$el.data("shiny-mutate-observer", mo);
|
||||
$el.data("shiny-mutate-observer-callback", onMutate);
|
||||
}
|
||||
}
|
||||
function doSendOutputInfo(initial = false) {
|
||||
const outputIds = /* @__PURE__ */ new Set();
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
if (id) outputIds.add(id);
|
||||
ensureObservers(el);
|
||||
if (!initial) doTriggerResize(el);
|
||||
doSendHiddenState(el, initial);
|
||||
if (reportsSize(el)) {
|
||||
doSendSize(el, initial);
|
||||
}
|
||||
if (reportsTheme(el)) {
|
||||
doSendTheme(el, initial);
|
||||
}
|
||||
});
|
||||
visibleOutputs.forEach((id) => {
|
||||
if (!outputIds.has(id)) {
|
||||
visibleOutputs.delete(id);
|
||||
setInput(".clientdata_output_" + id + "_hidden", true, initial);
|
||||
}
|
||||
const $this = (0, import_jquery40.default)(this), binding = $this.data("shiny-output-binding");
|
||||
$this.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: !isHidden(this),
|
||||
binding
|
||||
});
|
||||
binding.onResize();
|
||||
});
|
||||
}
|
||||
doSendOutputInfo(true);
|
||||
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
|
||||
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
|
||||
function isHidden(obj) {
|
||||
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
|
||||
return false;
|
||||
} else if (getStyle(obj, "display") === "none") {
|
||||
return true;
|
||||
} else {
|
||||
return isHidden(obj.parentNode);
|
||||
}
|
||||
}
|
||||
let lastKnownVisibleOutputs = {};
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const id = getIdFromEl(this);
|
||||
if (isHidden(this)) {
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = true;
|
||||
} else {
|
||||
lastKnownVisibleOutputs[id] = true;
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = false;
|
||||
}
|
||||
});
|
||||
function doSendOutputHiddenState() {
|
||||
const visibleOutputs = {};
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const id = getIdFromEl(this);
|
||||
delete lastKnownVisibleOutputs[id];
|
||||
const hidden = isHidden(this), evt = {
|
||||
type: "shiny:visualchange",
|
||||
visible: !hidden
|
||||
};
|
||||
if (hidden) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
|
||||
} else {
|
||||
visibleOutputs[id] = true;
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
|
||||
}
|
||||
const $this = (0, import_jquery40.default)(this);
|
||||
evt.binding = $this.data("shiny-output-binding");
|
||||
$this.trigger(evt);
|
||||
});
|
||||
for (const name in lastKnownVisibleOutputs) {
|
||||
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
|
||||
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
|
||||
}
|
||||
lastKnownVisibleOutputs = visibleOutputs;
|
||||
}
|
||||
const sendOutputHiddenStateDebouncer = new Debouncer(
|
||||
null,
|
||||
doSendOutputHiddenState,
|
||||
0
|
||||
);
|
||||
function sendOutputHiddenState() {
|
||||
sendOutputHiddenStateDebouncer.normalCall();
|
||||
}
|
||||
inputBatchSender.lastChanceCallback.push(function() {
|
||||
if (sendOutputHiddenStateDebouncer.isPending())
|
||||
sendOutputHiddenStateDebouncer.immediateCall();
|
||||
});
|
||||
function filterEventsByNamespace(namespace, handler, ...args) {
|
||||
const namespaceArr = namespace.split(".");
|
||||
return function(e4) {
|
||||
const eventNamespace = e4.namespace?.split(".") ?? [];
|
||||
for (let i5 = 0; i5 < namespaceArr.length; i5++) {
|
||||
if (eventNamespace.indexOf(namespaceArr[i5]) === -1) return;
|
||||
}
|
||||
handler.apply(this, [namespaceArr, handler, ...args]);
|
||||
};
|
||||
}
|
||||
(0, import_jquery40.default)(window).resize(debounce(500, sendImageSizeFns.regular));
|
||||
const bs3classes = [
|
||||
"modal",
|
||||
"dropdown",
|
||||
"tab",
|
||||
"tooltip",
|
||||
"popover",
|
||||
"collapse"
|
||||
];
|
||||
import_jquery40.default.each(bs3classes, function(idx, classname) {
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.bs." + classname + ".sendImageSize",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendImageSizeFns.regular)
|
||||
);
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.bs." + classname + ".sendOutputHiddenState hidden.bs." + classname + ".sendOutputHiddenState",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendOutputHiddenState)
|
||||
);
|
||||
});
|
||||
(0, import_jquery40.default)(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
|
||||
"*",
|
||||
sendOutputHiddenState
|
||||
);
|
||||
initialValues[".clientdata_pixelratio"] = pixelRatio();
|
||||
(0, import_jquery40.default)(window).resize(function() {
|
||||
inputs.setInput(".clientdata_pixelratio", pixelRatio());
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
inst/www/shared/shiny.min.css
vendored
2
inst/www/shared/shiny.min.css
vendored
File diff suppressed because one or more lines are too long
40
inst/www/shared/shiny.min.js
vendored
40
inst/www/shared/shiny.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,7 +39,7 @@ $datepicker-disabled-color: $dropdown-link-disabled-color !default;
|
||||
$shiny-file-active-shadow: $input-focus-box-shadow !default;
|
||||
|
||||
|
||||
[data-display-if].shiny-conditional--shown,
|
||||
.shiny-panel-conditional,
|
||||
div:where(.shiny-html-output) {
|
||||
/* uiOutput()/ conditionalPanel() are "pass-through" containers when they have children. */
|
||||
&:has(> *) {
|
||||
|
||||
@@ -488,11 +488,6 @@ textarea.textarea-autoresize.form-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* conditionalPanel: hidden until JS evaluates the condition */
|
||||
[data-display-if]:not(.shiny-conditional--shown) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden tabPanels */
|
||||
.nav-hidden {
|
||||
/* override anything bootstrap sets for `.nav` */
|
||||
|
||||
@@ -60,6 +60,10 @@ in its \code{DESCRIPTION} file, if any.}
|
||||
only used for recording or running automated tests. Defaults to the
|
||||
\code{shiny.testmode} option, or FALSE if the option is not set.}
|
||||
}
|
||||
\value{
|
||||
The value passed to \code{\link[=stopApp]{stopApp()}}, or throws an error if the app was
|
||||
stopped with an error.
|
||||
}
|
||||
\description{
|
||||
Runs a Shiny application. This function normally does not return; interrupt R
|
||||
to stop the application (usually by pressing Ctrl+C or Esc).
|
||||
@@ -109,3 +113,7 @@ if (interactive()) {
|
||||
runApp(app)
|
||||
}
|
||||
}
|
||||
\seealso{
|
||||
\code{\link[=startApp]{startApp()}} for non-blocking mode, \code{\link[=stopApp]{stopApp()}} to stop a running
|
||||
app.
|
||||
}
|
||||
|
||||
91
man/startApp.Rd
Normal file
91
man/startApp.Rd
Normal file
@@ -0,0 +1,91 @@
|
||||
% Generated by roxygen2: do not edit by hand
|
||||
% Please edit documentation in R/runapp.R
|
||||
\name{startApp}
|
||||
\alias{startApp}
|
||||
\title{Start Shiny Application (Non-Blocking)}
|
||||
\usage{
|
||||
startApp(
|
||||
appDir = getwd(),
|
||||
port = getOption("shiny.port"),
|
||||
launch.browser = getOption("shiny.launch.browser", interactive()),
|
||||
host = getOption("shiny.host", "127.0.0.1"),
|
||||
workerId = "",
|
||||
quiet = FALSE,
|
||||
display.mode = c("auto", "normal", "showcase"),
|
||||
test.mode = getOption("shiny.testmode", FALSE)
|
||||
)
|
||||
}
|
||||
\arguments{
|
||||
\item{appDir}{The application to run. Should be one of the following:
|
||||
\itemize{
|
||||
\item A directory containing \code{server.R}, plus, either \code{ui.R} or
|
||||
a \code{www} directory that contains the file \code{index.html}.
|
||||
\item A directory containing \code{app.R}.
|
||||
\item An \code{.R} file containing a Shiny application, ending with an
|
||||
expression that produces a Shiny app object.
|
||||
\item A list with \code{ui} and \code{server} components.
|
||||
\item A Shiny app object created by \code{\link[=shinyApp]{shinyApp()}}.
|
||||
}}
|
||||
|
||||
\item{port}{The TCP port that the application should listen on. If the
|
||||
\code{port} is not specified, and the \code{shiny.port} option is set (with
|
||||
\code{options(shiny.port = XX)}), then that port will be used. Otherwise,
|
||||
use a random port between 3000:8000, excluding ports that are blocked
|
||||
by Google Chrome for being considered unsafe: 3659, 4045, 5060,
|
||||
5061, 6000, 6566, 6665:6669 and 6697. Up to twenty random
|
||||
ports will be tried.}
|
||||
|
||||
\item{launch.browser}{If true, the system's default web browser will be
|
||||
launched automatically after the app is started. Defaults to true in
|
||||
interactive sessions only. The value of this parameter can also be a
|
||||
function to call with the application's URL.}
|
||||
|
||||
\item{host}{The IPv4 address that the application should listen on. Defaults
|
||||
to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See
|
||||
Details.}
|
||||
|
||||
\item{workerId}{Can generally be ignored. Exists to help some editions of
|
||||
Shiny Server Pro route requests to the correct process.}
|
||||
|
||||
\item{quiet}{Should Shiny status messages be shown? Defaults to FALSE.}
|
||||
|
||||
\item{display.mode}{The mode in which to display the application. If set to
|
||||
the value \code{"showcase"}, shows application code and metadata from a
|
||||
\code{DESCRIPTION} file in the application directory alongside the
|
||||
application. If set to \code{"normal"}, displays the application normally.
|
||||
Defaults to \code{"auto"}, which displays the application in the mode given
|
||||
in its \code{DESCRIPTION} file, if any.}
|
||||
|
||||
\item{test.mode}{Should the application be launched in test mode? This is
|
||||
only used for recording or running automated tests. Defaults to the
|
||||
\code{shiny.testmode} option, or FALSE if the option is not set.}
|
||||
}
|
||||
\value{
|
||||
A \code{ShinyAppHandle} object with methods \code{stop()}, \code{status()},
|
||||
\code{url()}, and \code{result()}. The \code{status()} method returns \code{"running"},
|
||||
\code{"success"}, or \code{"error"}. The \code{result()} method throws an error if called
|
||||
while running, or re-throws the error if the app stopped with an error.
|
||||
}
|
||||
\description{
|
||||
Starts a Shiny application in non-blocking mode, returning a
|
||||
\code{ShinyAppHandle} immediately while the app runs in the background.
|
||||
The \code{later} event loop services the app, so the R console remains
|
||||
available for interaction.
|
||||
}
|
||||
\examples{
|
||||
\dontrun{
|
||||
# Start app in the background
|
||||
handle <- startApp("myapp")
|
||||
|
||||
# Check status
|
||||
handle$status()
|
||||
handle$url()
|
||||
|
||||
# Stop the app
|
||||
handle$stop()
|
||||
}
|
||||
|
||||
}
|
||||
\seealso{
|
||||
\code{\link[=runApp]{runApp()}} for blocking mode, \code{\link[=stopApp]{stopApp()}} to stop a running app.
|
||||
}
|
||||
@@ -12,5 +12,7 @@ stopApp(returnValue = invisible())
|
||||
}
|
||||
\description{
|
||||
Stops the currently running Shiny app, returning control to the caller of
|
||||
\code{\link[=runApp]{runApp()}}.
|
||||
\code{\link[=runApp]{runApp()}}. Despite the similar names, \code{stopApp()} is not the
|
||||
counterpart of \code{\link[=startApp]{startApp()}} — it is the counterpart of \code{\link[=runApp]{runApp()}},
|
||||
controlling its return value via \code{returnValue}.
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@posit/shiny",
|
||||
"version": "1.13.0-alpha.9000",
|
||||
"version": "1.12.1-alpha.9000",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@posit/shiny",
|
||||
"version": "1.13.0-alpha.9000",
|
||||
"version": "1.12.1-alpha.9000",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/bootstrap": "5.2.x",
|
||||
|
||||
@@ -63,10 +63,9 @@
|
||||
"bundle_shiny": "tsx srcts/build/shiny.ts",
|
||||
"bundle_external_libs": "tsx srcts/build/external_libs.ts",
|
||||
"bundle_extras": "tsx srcts/build/extras.ts",
|
||||
"checks": "npm run lint && npm run build_types && npm run test_types && npm run coverage && npm run circular",
|
||||
"checks": "npm run lint && npm run build_types && npm run coverage && npm run circular",
|
||||
"lint": "node --eval \"console.log('linting code...')\" && eslint 'srcts/src/**/*.ts' --fix",
|
||||
"build_types": "tsc -p tsconfig.json",
|
||||
"test_types": "tsx --test $(find srcts/src -path '*/__tests__/*.test.ts' -print)",
|
||||
"coverage_detailed": "npx --yes type-check --detail",
|
||||
"coverage": "type-coverage -p tsconfig.json --at-least 90",
|
||||
"circular": "npx --yes dpdm --transform ./srcts/src/index.ts",
|
||||
|
||||
@@ -39,5 +39,17 @@ declare global {
|
||||
): this;
|
||||
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
|
||||
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
|
||||
|
||||
on(
|
||||
events: `shown.bs.${string}.sendImageSize`,
|
||||
selector: string,
|
||||
handler: (
|
||||
this: HTMLElement,
|
||||
e: JQuery.EventHandlerBase<HTMLElement, any>,
|
||||
// e: JQuery.Event & {
|
||||
// namespace: string;
|
||||
// }
|
||||
) => void,
|
||||
): this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { InputBatchSender } from "../../inputPolicies";
|
||||
import { SendOutputInfo } from "../sendOutputInfo";
|
||||
|
||||
void test("pending observer output info is flushed before the next input batch send", () => {
|
||||
const sentInputs: Array<{ [key: string]: unknown }> = [];
|
||||
const shinyapp = {
|
||||
taskQueue: {
|
||||
enqueue: () => {
|
||||
throw new Error("task queue should not be used in this test");
|
||||
},
|
||||
},
|
||||
sendInput: (values: { [key: string]: unknown }) => {
|
||||
sentInputs.push(values);
|
||||
},
|
||||
};
|
||||
const inputBatchSender = new InputBatchSender(shinyapp as never);
|
||||
const sendOutputInfo = new SendOutputInfo();
|
||||
|
||||
sendOutputInfo.setSendMethod(inputBatchSender, () => {
|
||||
/* no-op */
|
||||
});
|
||||
|
||||
const observerCallback = sendOutputInfo.createObserverCallback(100, () => {
|
||||
inputBatchSender.setInput(".clientdata_output_plot_width", 400, {
|
||||
priority: "immediate",
|
||||
});
|
||||
});
|
||||
|
||||
observerCallback();
|
||||
|
||||
inputBatchSender.setInput("user", 1, { priority: "event" });
|
||||
|
||||
assert.equal(sentInputs.length, 1);
|
||||
const expected: { [key: string]: unknown } = { user: 1 };
|
||||
|
||||
expected[".clientdata_output_plot_width"] = 400;
|
||||
|
||||
assert.deepEqual(sentInputs[0], expected);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
} from "../inputPolicies";
|
||||
import type { EventPriority } from "../inputPolicies/inputPolicy";
|
||||
import { shinyAppBindOutput, shinyAppUnbindOutput } from "./initedMethods";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
|
||||
type BindScope = HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
@@ -229,6 +229,8 @@ type BindInputsCtx = {
|
||||
inputsRate: InputRateDecorator;
|
||||
inputBindings: BindingRegistry<InputBinding>;
|
||||
outputBindings: BindingRegistry<OutputBinding>;
|
||||
sendOutputHiddenState: () => void;
|
||||
maybeAddThemeObserver: (el: HTMLElement) => void;
|
||||
initDeferredIframes: () => void;
|
||||
outputIsRecalculating: (id: string) => boolean;
|
||||
};
|
||||
@@ -316,7 +318,12 @@ function bindInputs(
|
||||
}
|
||||
|
||||
async function bindOutputs(
|
||||
{ outputBindings, outputIsRecalculating }: BindInputsCtx,
|
||||
{
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
outputBindings,
|
||||
outputIsRecalculating,
|
||||
}: BindInputsCtx,
|
||||
scope: BindScope = document.documentElement,
|
||||
): Promise<void> {
|
||||
const $scope = $(scope);
|
||||
@@ -348,6 +355,12 @@ async function bindOutputs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this element reports its CSS styles to getCurrentOutputInfo()
|
||||
// then it should have a MutationObserver() to resend CSS if its
|
||||
// style/class attributes change. This observer should already exist
|
||||
// for _static_ UI, but not yet for _dynamic_ UI
|
||||
maybeAddThemeObserver(el);
|
||||
|
||||
const bindingAdapter = new OutputBindingAdapter(el, binding);
|
||||
|
||||
await shinyAppBindOutput(id, bindingAdapter);
|
||||
@@ -370,7 +383,8 @@ async function bindOutputs(
|
||||
}
|
||||
|
||||
// Send later in case DOM layout isn't final yet.
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
}
|
||||
|
||||
function unbindInputs(
|
||||
@@ -405,6 +419,7 @@ function unbindInputs(
|
||||
}
|
||||
}
|
||||
function unbindOutputs(
|
||||
{ sendOutputHiddenState }: BindInputsCtx,
|
||||
scope: BindScope = document.documentElement,
|
||||
includeSelf = false,
|
||||
) {
|
||||
@@ -428,27 +443,6 @@ function unbindOutputs(
|
||||
bindingsRegistry.removeBinding(id, "output");
|
||||
$el.removeClass("shiny-bound-output");
|
||||
$el.removeData("shiny-output-binding");
|
||||
|
||||
for (const prefix of [
|
||||
"shiny-resize-observer",
|
||||
"shiny-intersection-observer",
|
||||
"shiny-mutate-observer",
|
||||
]) {
|
||||
const observer = $el.data(prefix);
|
||||
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
$el.removeData(prefix);
|
||||
}
|
||||
|
||||
const callback = $el.data(prefix + "-callback") as
|
||||
| { cancel?: () => void }
|
||||
| undefined;
|
||||
|
||||
callback?.cancel?.();
|
||||
$el.removeData(prefix + "-callback");
|
||||
}
|
||||
|
||||
$el.trigger({
|
||||
type: "shiny:unbound",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
@@ -458,7 +452,8 @@ function unbindOutputs(
|
||||
}
|
||||
|
||||
// Send later in case DOM layout isn't final yet.
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
}
|
||||
|
||||
// (Named used before TS conversion)
|
||||
@@ -479,9 +474,13 @@ async function _bindAll(
|
||||
|
||||
return currentInputs;
|
||||
}
|
||||
function unbindAll(scope: BindScope, includeSelf = false): void {
|
||||
function unbindAll(
|
||||
shinyCtx: BindInputsCtx,
|
||||
scope: BindScope,
|
||||
includeSelf = false,
|
||||
): void {
|
||||
unbindInputs(scope, includeSelf);
|
||||
unbindOutputs(scope, includeSelf);
|
||||
unbindOutputs(shinyCtx, scope, includeSelf);
|
||||
}
|
||||
async function bindAll(
|
||||
shinyCtx: BindInputsCtx,
|
||||
|
||||
@@ -17,14 +17,15 @@ import {
|
||||
} from "../inputPolicies";
|
||||
import type { InputPolicyOpts } from "../inputPolicies/inputPolicy";
|
||||
import { addDefaultInputOpts } from "../inputPolicies/inputValidateDecorator";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
import {
|
||||
$escape,
|
||||
compareVersion,
|
||||
getBoundingClientSizeBeforeZoom,
|
||||
getComputedLinkColor,
|
||||
getStyle,
|
||||
hasDefinedProperty,
|
||||
isShinyInDevMode,
|
||||
isVisible,
|
||||
mapValues,
|
||||
pixelRatio,
|
||||
} from "../utils";
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
renderHtml,
|
||||
renderHtmlAsync,
|
||||
} from "./render";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
import { addCustomMessageHandler, ShinyApp, type Handler } from "./shinyapp";
|
||||
import { registerNames as singletonsRegisterNames } from "./singletons";
|
||||
|
||||
@@ -219,6 +220,8 @@ class ShinyClass {
|
||||
return {
|
||||
inputs,
|
||||
inputsRate,
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
inputBindings,
|
||||
outputBindings,
|
||||
initDeferredIframes,
|
||||
@@ -231,7 +234,7 @@ class ShinyClass {
|
||||
await bindAll(shinyBindCtx(), scope);
|
||||
};
|
||||
this.unbindAll = function (scope: BindScope, includeSelf = false) {
|
||||
unbindAll(scope, includeSelf);
|
||||
unbindAll(shinyBindCtx(), scope, includeSelf);
|
||||
};
|
||||
|
||||
// Calls .initialize() for all of the input objects in all input bindings,
|
||||
@@ -259,11 +262,12 @@ class ShinyClass {
|
||||
}
|
||||
this.initializeInputs = initializeInputs;
|
||||
|
||||
function getIdFromEl(el: HTMLElement): string | null {
|
||||
function getIdFromEl(el: HTMLElement) {
|
||||
const $el = $(el);
|
||||
const bindingAdapter = $el.data("shiny-output-binding");
|
||||
|
||||
return bindingAdapter ? bindingAdapter.getId() : null;
|
||||
if (!bindingAdapter) return null;
|
||||
else return bindingAdapter.getId();
|
||||
}
|
||||
|
||||
// Initialize all input objects in the document, before binding
|
||||
@@ -281,224 +285,327 @@ class ShinyClass {
|
||||
(x) => x.value,
|
||||
);
|
||||
|
||||
function setInput(name: string, value: unknown, initial = false): void {
|
||||
if (initial) {
|
||||
initialValues[name] = value;
|
||||
} else {
|
||||
inputs.setInput(name, value);
|
||||
}
|
||||
}
|
||||
// The server needs to know the size of each image and plot output element,
|
||||
// in case it is auto-sizing
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function () {
|
||||
const id = getIdFromEl(this),
|
||||
rect = getBoundingClientSizeBeforeZoom(this);
|
||||
|
||||
function doSendSize(el: HTMLElement, initial = false): void {
|
||||
const id = getIdFromEl(el);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
|
||||
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const rect = getBoundingClientSizeBeforeZoom(el);
|
||||
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
|
||||
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
|
||||
}
|
||||
}
|
||||
|
||||
function doTriggerResize(el: HTMLElement): void {
|
||||
const $el = $(el),
|
||||
binding = $el.data("shiny-output-binding");
|
||||
|
||||
if (!binding) return;
|
||||
|
||||
$el.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: isVisible(el),
|
||||
binding: binding,
|
||||
});
|
||||
binding.onResize();
|
||||
}
|
||||
|
||||
function doSendTheme(el: HTMLElement, initial = false): void {
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
function getComputedBgColor(
|
||||
el: HTMLElement | null,
|
||||
): string | null | undefined {
|
||||
if (!el) {
|
||||
// Top of document, can't recurse further
|
||||
return null;
|
||||
}
|
||||
|
||||
function getComputedBgColor(
|
||||
el: HTMLElement | null,
|
||||
): string | null | undefined {
|
||||
if (!el) {
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
|
||||
if (!bgColor) return bgColor;
|
||||
const m = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
|
||||
);
|
||||
|
||||
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
|
||||
// No background color on this element. See if it has a background image.
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
|
||||
if (bgImage && bgImage !== "none") {
|
||||
// Failed to detect background color, since it has a background image
|
||||
return null;
|
||||
} else {
|
||||
// Recurse
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
|
||||
if (!bgColor) return bgColor;
|
||||
const m = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
|
||||
);
|
||||
|
||||
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
}
|
||||
return bgColor;
|
||||
}
|
||||
|
||||
function getComputedFont(el: HTMLElement): {
|
||||
families: string[] | undefined;
|
||||
size: string | undefined;
|
||||
} {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
initial,
|
||||
);
|
||||
return bgColor;
|
||||
}
|
||||
|
||||
const visibleOutputs = new Set<string>();
|
||||
function getComputedFont(el: HTMLElement) {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
|
||||
function doSendHiddenState(el: HTMLElement, initial = false): void {
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const hidden = !isVisible(el);
|
||||
|
||||
if (hidden) {
|
||||
visibleOutputs.delete(id);
|
||||
} else {
|
||||
visibleOutputs.add(id);
|
||||
}
|
||||
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
function reportsSize(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.classList.contains("shiny-image-output") ||
|
||||
el.classList.contains("shiny-plot-output") ||
|
||||
el.classList.contains("shiny-report-size")
|
||||
);
|
||||
}
|
||||
|
||||
function reportsTheme(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.classList.contains("shiny-image-output") ||
|
||||
el.classList.contains("shiny-plot-output") ||
|
||||
el.classList.contains("shiny-report-theme")
|
||||
);
|
||||
}
|
||||
|
||||
function handleVisualChange(el: HTMLElement): void {
|
||||
doTriggerResize(el);
|
||||
doSendHiddenState(el);
|
||||
if (reportsSize(el)) doSendSize(el);
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
}
|
||||
|
||||
function ensureObservers(el: HTMLElement): void {
|
||||
const $el = $(el);
|
||||
|
||||
if (!$el.data("shiny-resize-observer")) {
|
||||
const onResize = sendOutputInfoFns.createObserverCallback(100, () =>
|
||||
handleVisualChange(el),
|
||||
);
|
||||
const ro = new ResizeObserver(() => onResize());
|
||||
|
||||
ro.observe(el);
|
||||
$el.data("shiny-resize-observer-callback", onResize);
|
||||
$el.data("shiny-resize-observer", ro);
|
||||
}
|
||||
|
||||
if (!$el.data("shiny-intersection-observer")) {
|
||||
const onIntersect = sendOutputInfoFns.createObserverCallback(100, () =>
|
||||
handleVisualChange(el),
|
||||
);
|
||||
const io = new IntersectionObserver(() => onIntersect());
|
||||
|
||||
io.observe(el);
|
||||
$el.data("shiny-intersection-observer-callback", onIntersect);
|
||||
$el.data("shiny-intersection-observer", io);
|
||||
}
|
||||
|
||||
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
|
||||
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
});
|
||||
const mo = new MutationObserver(() => onMutate());
|
||||
|
||||
mo.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
|
||||
$el.data("shiny-mutate-observer", mo);
|
||||
$el.data("shiny-mutate-observer-callback", onMutate);
|
||||
}
|
||||
}
|
||||
|
||||
function doSendOutputInfo(initial = false) {
|
||||
const outputIds = new Set<string>();
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function () {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
if (id) outputIds.add(id);
|
||||
ensureObservers(el);
|
||||
initialValues[".clientdata_output_" + id + "_bg"] =
|
||||
getComputedBgColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
|
||||
el,
|
||||
"color",
|
||||
);
|
||||
initialValues[".clientdata_output_" + id + "_accent"] =
|
||||
getComputedLinkColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_font"] =
|
||||
getComputedFont(el);
|
||||
maybeAddThemeObserver(el);
|
||||
},
|
||||
);
|
||||
|
||||
if (!initial) doTriggerResize(el);
|
||||
doSendHiddenState(el, initial);
|
||||
if (reportsSize(el)) {
|
||||
doSendSize(el, initial);
|
||||
}
|
||||
if (reportsTheme(el)) {
|
||||
doSendTheme(el, initial);
|
||||
}
|
||||
});
|
||||
// Resend computed styles if *an output element's* class or style attribute changes.
|
||||
// This gives us some level of confidence that getCurrentOutputInfo() will be
|
||||
// properly invalidated if output container is mutated; but unfortunately,
|
||||
// we don't have a reasonable way to detect change in *inherited* styles
|
||||
// (other than session$setCurrentTheme())
|
||||
// https://github.com/rstudio/shiny/issues/3196
|
||||
// https://github.com/rstudio/shiny/issues/2998
|
||||
function maybeAddThemeObserver(el: HTMLElement): void {
|
||||
if (!window.MutationObserver) {
|
||||
return; // IE10 and lower
|
||||
}
|
||||
|
||||
visibleOutputs.forEach((id) => {
|
||||
if (!outputIds.has(id)) {
|
||||
visibleOutputs.delete(id);
|
||||
setInput(".clientdata_output_" + id + "_hidden", true, initial);
|
||||
}
|
||||
const cl = el.classList;
|
||||
const reportTheme =
|
||||
cl.contains("shiny-image-output") ||
|
||||
cl.contains("shiny-plot-output") ||
|
||||
cl.contains("shiny-report-theme");
|
||||
|
||||
if (!reportTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $el = $(el);
|
||||
|
||||
if ($el.data("shiny-theme-observer")) {
|
||||
return; // i.e., observer is already observing
|
||||
}
|
||||
|
||||
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
|
||||
const observer = new MutationObserver(() =>
|
||||
observerCallback.normalCall(),
|
||||
);
|
||||
const config = { attributes: true, attributeFilter: ["style", "class"] };
|
||||
|
||||
observer.observe(el, config);
|
||||
$el.data("shiny-theme-observer", observer);
|
||||
}
|
||||
|
||||
function doSendTheme(el: HTMLElement): void {
|
||||
// Sending theme info on error isn't necessary (it'd add an unnecessary additional round-trip)
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
}
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
);
|
||||
}
|
||||
|
||||
function doSendImageSize() {
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function () {
|
||||
const id = getIdFromEl(this),
|
||||
rect = getBoundingClientSizeBeforeZoom(this);
|
||||
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_height",
|
||||
rect.height,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function () {
|
||||
doSendTheme(this);
|
||||
},
|
||||
);
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const $this = $(this),
|
||||
binding = $this.data("shiny-output-binding");
|
||||
|
||||
$this.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: !isHidden(this),
|
||||
binding: binding,
|
||||
});
|
||||
binding.onResize();
|
||||
});
|
||||
}
|
||||
|
||||
doSendOutputInfo(true);
|
||||
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
|
||||
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
|
||||
|
||||
// Return true if the object or one of its ancestors in the DOM tree has
|
||||
// style='display:none'; otherwise return false.
|
||||
function isHidden(obj: HTMLElement | null): boolean {
|
||||
// null means we've hit the top of the tree. If width or height is
|
||||
// non-zero, then we know that no ancestor has display:none.
|
||||
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
|
||||
return false;
|
||||
} else if (getStyle(obj, "display") === "none") {
|
||||
return true;
|
||||
} else {
|
||||
return isHidden(obj.parentNode as HTMLElement | null);
|
||||
}
|
||||
}
|
||||
let lastKnownVisibleOutputs: { [key: string]: boolean } = {};
|
||||
// Set initial state of outputs to hidden, if needed
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const id = getIdFromEl(this);
|
||||
|
||||
if (isHidden(this)) {
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = true;
|
||||
} else {
|
||||
lastKnownVisibleOutputs[id] = true;
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = false;
|
||||
}
|
||||
});
|
||||
// Send update when hidden state changes
|
||||
function doSendOutputHiddenState() {
|
||||
const visibleOutputs: { [key: string]: boolean } = {};
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const id = getIdFromEl(this);
|
||||
|
||||
delete lastKnownVisibleOutputs[id];
|
||||
// Assume that the object is hidden when width and height are 0
|
||||
const hidden = isHidden(this),
|
||||
evt = {
|
||||
type: "shiny:visualchange",
|
||||
visible: !hidden,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
|
||||
} else {
|
||||
visibleOutputs[id] = true;
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
|
||||
}
|
||||
const $this = $(this);
|
||||
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
evt.binding = $this.data("shiny-output-binding");
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
$this.trigger(evt);
|
||||
});
|
||||
// Anything left in lastKnownVisibleOutputs is orphaned
|
||||
for (const name in lastKnownVisibleOutputs) {
|
||||
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
|
||||
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
|
||||
}
|
||||
// Update the visible outputs for next time
|
||||
lastKnownVisibleOutputs = visibleOutputs;
|
||||
}
|
||||
// sendOutputHiddenState gets called each time DOM elements are shown or
|
||||
// hidden. This can be in the hundreds or thousands of times at startup.
|
||||
// We'll debounce it, so that we do the actual work once per tick.
|
||||
const sendOutputHiddenStateDebouncer = new Debouncer(
|
||||
null,
|
||||
doSendOutputHiddenState,
|
||||
0,
|
||||
);
|
||||
|
||||
function sendOutputHiddenState() {
|
||||
sendOutputHiddenStateDebouncer.normalCall();
|
||||
}
|
||||
// We need to make sure doSendOutputHiddenState actually gets called before
|
||||
// the inputBatchSender sends data to the server. The lastChanceCallback
|
||||
// here does that - if the debouncer has a pending call, flush it.
|
||||
inputBatchSender.lastChanceCallback.push(function () {
|
||||
if (sendOutputHiddenStateDebouncer.isPending())
|
||||
sendOutputHiddenStateDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
// Given a namespace and a handler function, return a function that invokes
|
||||
// the handler only when e's namespace matches. For example, if the
|
||||
// namespace is "bs", it would match when e.namespace is "bs" or "bs.tab".
|
||||
// If the namespace is "bs.tab", it would match for "bs.tab", but not "bs".
|
||||
function filterEventsByNamespace(
|
||||
namespace: string,
|
||||
handler: (...handlerArgs: any[]) => void,
|
||||
...args: any[]
|
||||
) {
|
||||
const namespaceArr = namespace.split(".");
|
||||
|
||||
return function (this: HTMLElement, e: JQuery.TriggeredEvent) {
|
||||
const eventNamespace = e.namespace?.split(".") ?? [];
|
||||
|
||||
// If any of the namespace strings aren't present in this event, quit.
|
||||
for (let i = 0; i < namespaceArr.length; i++) {
|
||||
if (eventNamespace.indexOf(namespaceArr[i]) === -1) return;
|
||||
}
|
||||
|
||||
handler.apply(this, [namespaceArr, handler, ...args]);
|
||||
};
|
||||
}
|
||||
|
||||
// The size of each image may change either because the browser window was
|
||||
// resized, or because a tab was shown/hidden (hidden elements report size
|
||||
// of 0x0). It's OK to over-report sizes because the input pipeline will
|
||||
// filter out values that haven't changed.
|
||||
$(window).resize(debounce(500, sendImageSizeFns.regular));
|
||||
// Need to register callbacks for each Bootstrap 3 class.
|
||||
const bs3classes = [
|
||||
"modal",
|
||||
"dropdown",
|
||||
"tab",
|
||||
"tooltip",
|
||||
"popover",
|
||||
"collapse",
|
||||
];
|
||||
|
||||
$.each(bs3classes, function (idx, classname) {
|
||||
$(document.body).on(
|
||||
"shown.bs." + classname + ".sendImageSize",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendImageSizeFns.regular),
|
||||
);
|
||||
$(document.body).on(
|
||||
"shown.bs." +
|
||||
classname +
|
||||
".sendOutputHiddenState " +
|
||||
"hidden.bs." +
|
||||
classname +
|
||||
".sendOutputHiddenState",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendOutputHiddenState),
|
||||
);
|
||||
});
|
||||
|
||||
// This is needed for Bootstrap 2 compatibility and for non-Bootstrap
|
||||
// related shown/hidden events (like conditionalPanel)
|
||||
$(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
|
||||
$(document.body).on(
|
||||
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
|
||||
"*",
|
||||
sendOutputHiddenState,
|
||||
);
|
||||
|
||||
// Send initial pixel ratio, and update it if it changes
|
||||
initialValues[".clientdata_pixelratio"] = pixelRatio();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
shinyInitializeInputs,
|
||||
shinyUnbindAll,
|
||||
} from "./initedMethods";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
|
||||
import type { WherePosition } from "./singletons";
|
||||
import { renderHtml as singletonsRenderHtml } from "./singletons";
|
||||
@@ -267,7 +267,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
// should have been applied synchronously.
|
||||
oldStyle.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendOutputInfoFns.transitioned();
|
||||
sendImageSizeFns.transitioned();
|
||||
};
|
||||
xhr.send();
|
||||
};
|
||||
@@ -327,7 +327,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
// base64-encoded and inlined into the href. We also add a dummy DOM
|
||||
// element that the CSS applies to. The dummy CSS includes a
|
||||
// transition, and when the `transitionend` event happens, we call
|
||||
// sendOutputInfoFns.transitioned() and remove the old sheet. We also remove the
|
||||
// sendImageSizeFns.transitioned() and remove the old sheet. We also remove the
|
||||
// dummy DOM element and dummy CSS content.
|
||||
//
|
||||
// The reason this works is because (we assume) that if multiple
|
||||
@@ -337,7 +337,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
//
|
||||
// Because it is common for multiple stylesheets to arrive close
|
||||
// together, but not on exactly the same tick, we call
|
||||
// sendOutputInfoFns.transitioned(), which is debounced. Otherwise, it can result in
|
||||
// sendImageSizeFns.transitioned(), which is debounced. Otherwise, it can result in
|
||||
// the same plot being redrawn multiple times with different
|
||||
// styling.
|
||||
$link.attr("onload", () => {
|
||||
@@ -350,7 +350,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
$dummyEl.one("transitionend", () => {
|
||||
$dummyEl.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendOutputInfoFns.transitioned();
|
||||
sendImageSizeFns.transitioned();
|
||||
});
|
||||
$(document.body).append($dummyEl);
|
||||
|
||||
|
||||
36
srcts/src/shiny/sendImageSize.ts
Normal file
36
srcts/src/shiny/sendImageSize.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
|
||||
class SendImageSize {
|
||||
// This function gets defined in initShiny() and 'hoisted' so it can be reused
|
||||
// (to send CSS info) inside of Shiny.renderDependencies()
|
||||
regular!: () => void;
|
||||
transitioned!: () => void;
|
||||
|
||||
setImageSend(
|
||||
inputBatchSender: InputBatchSender,
|
||||
doSendImageSize: () => void,
|
||||
): Debouncer<typeof doSendImageSize> {
|
||||
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
|
||||
|
||||
this.regular = function () {
|
||||
sendImageSizeDebouncer.normalCall();
|
||||
};
|
||||
|
||||
// Make sure sendImageSize actually gets called before the inputBatchSender
|
||||
// sends data to the server.
|
||||
inputBatchSender.lastChanceCallback.push(function () {
|
||||
if (sendImageSizeDebouncer.isPending())
|
||||
sendImageSizeDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
// A version of sendImageSize which debounces for longer.
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
|
||||
return sendImageSizeDebouncer;
|
||||
}
|
||||
}
|
||||
|
||||
const sendImageSizeFns = new SendImageSize();
|
||||
|
||||
export { sendImageSizeFns };
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
|
||||
type FlushableObserverCallback = (() => void) & {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
isPending: () => boolean;
|
||||
};
|
||||
|
||||
class SendOutputInfo {
|
||||
regular!: () => void;
|
||||
transitioned!: () => void;
|
||||
#pendingObserverCallbacks = new Set<FlushableObserverCallback>();
|
||||
|
||||
setSendMethod(
|
||||
inputBatchSender: InputBatchSender,
|
||||
doSendOutputInfo: () => void,
|
||||
): Debouncer<typeof doSendOutputInfo> {
|
||||
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
|
||||
|
||||
this.regular = function () {
|
||||
sendOutputInfoDebouncer.normalCall();
|
||||
};
|
||||
|
||||
inputBatchSender.lastChanceCallback.push(() => {
|
||||
this.#pendingObserverCallbacks.forEach((callback) => callback.flush());
|
||||
|
||||
if (sendOutputInfoDebouncer.isPending())
|
||||
sendOutputInfoDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
|
||||
return sendOutputInfoDebouncer;
|
||||
}
|
||||
|
||||
createObserverCallback(
|
||||
delayMs: number,
|
||||
callback: () => void,
|
||||
): FlushableObserverCallback {
|
||||
const debouncer = new Debouncer(
|
||||
null,
|
||||
() => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
callback();
|
||||
},
|
||||
delayMs,
|
||||
);
|
||||
|
||||
const observerCallback: FlushableObserverCallback = Object.assign(
|
||||
() => {
|
||||
this.#pendingObserverCallbacks.add(observerCallback);
|
||||
debouncer.normalCall();
|
||||
},
|
||||
{
|
||||
cancel: () => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
debouncer.cancel();
|
||||
},
|
||||
flush: () => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
if (debouncer.isPending()) {
|
||||
debouncer.immediateCall();
|
||||
}
|
||||
},
|
||||
isPending: () => debouncer.isPending(),
|
||||
},
|
||||
);
|
||||
|
||||
return observerCallback;
|
||||
}
|
||||
}
|
||||
|
||||
const sendOutputInfoFns = new SendOutputInfo();
|
||||
|
||||
export { SendOutputInfo, sendOutputInfoFns };
|
||||
export type { FlushableObserverCallback };
|
||||
@@ -70,8 +70,6 @@ const messageHandlers: { [key: string]: Handler } = {};
|
||||
const customMessageHandlerOrder: string[] = [];
|
||||
const customMessageHandlers: { [key: string]: Handler } = {};
|
||||
|
||||
const conditionalShownClass = "shiny-conditional--shown";
|
||||
|
||||
// Adds Shiny (internal) message handler
|
||||
function addMessageHandler(type: string, handler: Handler) {
|
||||
if (messageHandlers[type]) {
|
||||
@@ -616,16 +614,16 @@ class ShinyApp {
|
||||
const nsPrefix = el.attr("data-ns-prefix") as string;
|
||||
const nsScope = this._narrowScope(scope, nsPrefix);
|
||||
const show = Boolean(condFunc(nsScope));
|
||||
const showing = el.hasClass(conditionalShownClass);
|
||||
const showing = el.css("display") !== "none";
|
||||
|
||||
if (show !== showing) {
|
||||
if (show) {
|
||||
el.trigger("show");
|
||||
el.addClass(conditionalShownClass);
|
||||
el.show();
|
||||
el.trigger("shown");
|
||||
} else {
|
||||
el.trigger("hide");
|
||||
el.removeClass(conditionalShownClass);
|
||||
el.hide();
|
||||
el.trigger("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { debounce } from "../debounce";
|
||||
|
||||
void test("debounce can cancel a pending callback before it fires", async () => {
|
||||
let calls = 0;
|
||||
const debounced = debounce(10, () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
debounced();
|
||||
debounced.cancel();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -39,10 +39,6 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
|
||||
this.args = args;
|
||||
this.$invoke();
|
||||
}
|
||||
cancel(): void {
|
||||
this.$clearTimer();
|
||||
this.args = null;
|
||||
}
|
||||
isPending(): boolean {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
@@ -74,21 +70,15 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
|
||||
// 900ms intervals will result in a single execution
|
||||
// of the underlying function, 1000ms after the 17th
|
||||
// call.
|
||||
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((
|
||||
...args: Parameters<T>
|
||||
) => void) & {
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
function debounce<T extends (...args: unknown[]) => void>(
|
||||
threshold: number | undefined,
|
||||
func: T,
|
||||
): DebouncedFunction<T> {
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Do not alter `function()` into an arrow function.
|
||||
// The `this` context needs to be dynamically bound
|
||||
const debounced = function thisFunc(...args: Parameters<T>) {
|
||||
return function thisFunc(...args: Parameters<T>) {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
@@ -102,16 +92,6 @@ function debounce<T extends (...args: unknown[]) => void>(
|
||||
func.apply(thisFunc, args);
|
||||
}, threshold);
|
||||
};
|
||||
|
||||
debounced.cancel = function () {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export { debounce, Debouncer };
|
||||
export type { DebouncedFunction };
|
||||
|
||||
@@ -59,16 +59,6 @@ function getStyle(el: Element, styleProp: string): string | undefined {
|
||||
return x;
|
||||
}
|
||||
|
||||
function isVisible(el: HTMLElement): boolean {
|
||||
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (getStyle(el, "display") === "none") {
|
||||
return false;
|
||||
}
|
||||
return el.parentElement ? isVisible(el.parentElement) : true;
|
||||
}
|
||||
|
||||
// Convert a number to a string with leading zeros
|
||||
function padZeros(n: number, digits: number): string {
|
||||
let str = n.toString();
|
||||
@@ -431,7 +421,6 @@ export {
|
||||
isBS3,
|
||||
isnan,
|
||||
isShinyInDevMode,
|
||||
isVisible,
|
||||
makeResizeFilter,
|
||||
mapValues,
|
||||
mergeSort,
|
||||
|
||||
1
srcts/types/src/events/jQueryEvents.d.ts
vendored
1
srcts/types/src/events/jQueryEvents.d.ts
vendored
@@ -13,6 +13,7 @@ declare global {
|
||||
on(events: EvtPrefix<"mousedown2">, handler: EvtFn<JQuery.MouseDownEvent>): this;
|
||||
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
|
||||
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
|
||||
on(events: `shown.bs.${string}.sendImageSize`, selector: string, handler: (this: HTMLElement, e: JQuery.EventHandlerBase<HTMLElement, any>) => void): this;
|
||||
}
|
||||
}
|
||||
export {};
|
||||
|
||||
4
srcts/types/src/shiny/bind.d.ts
vendored
4
srcts/types/src/shiny/bind.d.ts
vendored
@@ -7,6 +7,8 @@ type BindInputsCtx = {
|
||||
inputsRate: InputRateDecorator;
|
||||
inputBindings: BindingRegistry<InputBinding>;
|
||||
outputBindings: BindingRegistry<OutputBinding>;
|
||||
sendOutputHiddenState: () => void;
|
||||
maybeAddThemeObserver: (el: HTMLElement) => void;
|
||||
initDeferredIframes: () => void;
|
||||
outputIsRecalculating: (id: string) => boolean;
|
||||
};
|
||||
@@ -21,7 +23,7 @@ declare function bindInputs(shinyCtx: BindInputsCtx, scope?: BindScope): {
|
||||
};
|
||||
};
|
||||
declare function _bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<ReturnType<typeof bindInputs>>;
|
||||
declare function unbindAll(scope: BindScope, includeSelf?: boolean): void;
|
||||
declare function unbindAll(shinyCtx: BindInputsCtx, scope: BindScope, includeSelf?: boolean): void;
|
||||
declare function bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<void>;
|
||||
export { _bindAll, bindAll, unbindAll };
|
||||
export type { BindInputsCtx, BindScope };
|
||||
|
||||
9
srcts/types/src/shiny/sendImageSize.d.ts
vendored
Normal file
9
srcts/types/src/shiny/sendImageSize.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { Debouncer } from "../time";
|
||||
declare class SendImageSize {
|
||||
regular: () => void;
|
||||
transitioned: () => void;
|
||||
setImageSend(inputBatchSender: InputBatchSender, doSendImageSize: () => void): Debouncer<typeof doSendImageSize>;
|
||||
}
|
||||
declare const sendImageSizeFns: SendImageSize;
|
||||
export { sendImageSizeFns };
|
||||
17
srcts/types/src/shiny/sendOutputInfo.d.ts
vendored
17
srcts/types/src/shiny/sendOutputInfo.d.ts
vendored
@@ -1,17 +0,0 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { Debouncer } from "../time";
|
||||
type FlushableObserverCallback = (() => void) & {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
isPending: () => boolean;
|
||||
};
|
||||
declare class SendOutputInfo {
|
||||
#private;
|
||||
regular: () => void;
|
||||
transitioned: () => void;
|
||||
setSendMethod(inputBatchSender: InputBatchSender, doSendOutputInfo: () => void): Debouncer<typeof doSendOutputInfo>;
|
||||
createObserverCallback(delayMs: number, callback: () => void): FlushableObserverCallback;
|
||||
}
|
||||
declare const sendOutputInfoFns: SendOutputInfo;
|
||||
export { SendOutputInfo, sendOutputInfoFns };
|
||||
export type { FlushableObserverCallback };
|
||||
7
srcts/types/src/time/debounce.d.ts
vendored
7
srcts/types/src/time/debounce.d.ts
vendored
@@ -10,14 +10,9 @@ declare class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X>
|
||||
constructor(target: InputPolicy | null, func: X, delayMs: number | undefined);
|
||||
normalCall(...args: Parameters<X>): void;
|
||||
immediateCall(...args: Parameters<X>): void;
|
||||
cancel(): void;
|
||||
isPending(): boolean;
|
||||
$clearTimer(): void;
|
||||
$invoke(): void;
|
||||
}
|
||||
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((...args: Parameters<T>) => void) & {
|
||||
cancel: () => void;
|
||||
};
|
||||
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): DebouncedFunction<T>;
|
||||
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): (...args: Parameters<T>) => void;
|
||||
export { debounce, Debouncer };
|
||||
export type { DebouncedFunction };
|
||||
|
||||
3
srcts/types/src/utils/index.d.ts
vendored
3
srcts/types/src/utils/index.d.ts
vendored
@@ -5,7 +5,6 @@ declare function escapeHTML(str: string): string;
|
||||
declare function randomId(): string;
|
||||
declare function strToBool(str: string): boolean | undefined;
|
||||
declare function getStyle(el: Element, styleProp: string): string | undefined;
|
||||
declare function isVisible(el: HTMLElement): boolean;
|
||||
declare function padZeros(n: number, digits: number): string;
|
||||
declare function roundSignif(x: number, digits?: number): number;
|
||||
declare function parseDate(dateString: string): Date;
|
||||
@@ -35,4 +34,4 @@ declare function getComputedLinkColor(el: HTMLElement): string;
|
||||
declare function isBS3(): boolean;
|
||||
declare function toLowerCase<T extends string>(str: T): Lowercase<T>;
|
||||
declare function isShinyInDevMode(): boolean;
|
||||
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, isVisible, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };
|
||||
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };
|
||||
|
||||
275
tests/testthat/test-non-blocking.R
Normal file
275
tests/testthat/test-non-blocking.R
Normal file
@@ -0,0 +1,275 @@
|
||||
# Prevent browser launch in interactive sessions
|
||||
withr::local_options(list(shiny.launch.browser = FALSE), .local_envir = teardown_env())
|
||||
|
||||
test_that("ShinyAppHandle lifecycle and API (success path)", {
|
||||
app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
|
||||
|
||||
# While running
|
||||
|
||||
expect_equal(handle$status(), "running")
|
||||
expect_match(handle$url(), "^http://")
|
||||
expect_error(handle$result(), "App is still running")
|
||||
|
||||
output <- capture.output(print(handle))
|
||||
expect_match(output[1], "Shiny app handle")
|
||||
expect_match(output[2], "URL:")
|
||||
expect_match(output[3], "running")
|
||||
|
||||
# stop() returns invisible self
|
||||
ret <- withVisible(handle$stop())
|
||||
expect_false(ret$visible)
|
||||
expect_identical(ret$value, handle)
|
||||
|
||||
# After stop
|
||||
expect_equal(handle$status(), "success")
|
||||
expect_null(handle$result())
|
||||
|
||||
output <- capture.output(print(handle))
|
||||
expect_match(output[3], "success")
|
||||
|
||||
# Double stop is a silent no-op
|
||||
expect_no_warning(handle$stop())
|
||||
expect_equal(handle$status(), "success")
|
||||
})
|
||||
|
||||
test_that("ShinyAppHandle lifecycle (error path)", {
|
||||
app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
|
||||
|
||||
stopApp(stop("test_error", call. = FALSE))
|
||||
while (handle$status() == "running") {
|
||||
later::run_now(timeoutSecs = 1)
|
||||
}
|
||||
|
||||
expect_equal(handle$status(), "error")
|
||||
expect_error(handle$result(), "test_error")
|
||||
|
||||
output <- capture.output(print(handle))
|
||||
expect_match(output[3], "error")
|
||||
})
|
||||
|
||||
test_that("handle captures result from stopApp", {
|
||||
app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
|
||||
|
||||
stopApp("test_result")
|
||||
while (handle$status() == "running") {
|
||||
later::run_now(timeoutSecs = 1)
|
||||
}
|
||||
|
||||
expect_equal(handle$status(), "success")
|
||||
expect_equal(handle$result(), "test_result")
|
||||
})
|
||||
|
||||
test_that("non-blocking auto-stops previous app when starting new one", {
|
||||
app1 <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
app2 <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
|
||||
expect_equal(handle1$status(), "running")
|
||||
|
||||
# Starting a second non-blocking app should auto-stop the first
|
||||
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
|
||||
on.exit(handle2$stop(), add = TRUE)
|
||||
|
||||
expect_equal(handle1$status(), "success")
|
||||
expect_equal(handle2$status(), "running")
|
||||
|
||||
handle2$stop()
|
||||
})
|
||||
|
||||
test_that("replacing a non-blocking app does not leave stale service loops", {
|
||||
generations_seen <- integer(0)
|
||||
|
||||
# Mock serviceApp to record which generation is active when called
|
||||
local_mocked_bindings(
|
||||
serviceApp = function(timeout) {
|
||||
generations_seen[[length(generations_seen) + 1L]] <<-
|
||||
.globals$serviceGeneration
|
||||
},
|
||||
.package = "shiny"
|
||||
)
|
||||
|
||||
app1 <- shinyApp(ui = fluidPage(), server = function(input, output) {})
|
||||
app2 <- shinyApp(ui = fluidPage(), server = function(input, output) {})
|
||||
|
||||
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
|
||||
gen1 <- .globals$serviceGeneration
|
||||
|
||||
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
|
||||
on.exit(handle2$stop(), add = TRUE)
|
||||
gen2 <- .globals$serviceGeneration
|
||||
|
||||
# Reset and let service loops run
|
||||
generations_seen <- integer(0)
|
||||
while (length(generations_seen) < 5L) later::run_now(timeoutSecs = 1)
|
||||
|
||||
# Only the new generation should be servicing
|
||||
expect_true(length(generations_seen) > 0)
|
||||
expect_true(all(generations_seen == gen2))
|
||||
|
||||
handle2$stop()
|
||||
})
|
||||
|
||||
test_that("starting a blocking app invalidates stale non-blocking service loops", {
|
||||
service_calls <- 0L
|
||||
|
||||
local_mocked_bindings(
|
||||
serviceApp = function(timeout) {
|
||||
service_calls <<- service_calls + 1L
|
||||
},
|
||||
.package = "shiny"
|
||||
)
|
||||
|
||||
ns <- asNamespace("shiny")
|
||||
g <- get(".globals", envir = ns)
|
||||
|
||||
# Simulate a non-blocking app at generation 1
|
||||
assign("serviceGeneration", 1L, envir = g)
|
||||
assign("stopped", FALSE, envir = g)
|
||||
shiny:::serviceNonBlocking(list(stop = function() {}), 1L)
|
||||
|
||||
# Simulate stopping app 1, then starting a blocking app which bumps generation
|
||||
assign("stopped", TRUE, envir = g)
|
||||
assign("serviceGeneration", 2L, envir = g)
|
||||
assign("stopped", FALSE, envir = g)
|
||||
|
||||
later::run_now(timeoutSecs = 1)
|
||||
|
||||
expect_equal(service_calls, 0L)
|
||||
})
|
||||
|
||||
test_that("nested runApp in blocking mode still errors", {
|
||||
inner_app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
outer_app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {},
|
||||
onStart = function() {
|
||||
runApp(inner_app, launch.browser = FALSE, quiet = TRUE)
|
||||
}
|
||||
)
|
||||
|
||||
expect_error(
|
||||
runApp(outer_app, launch.browser = FALSE, quiet = TRUE),
|
||||
"from within `runApp"
|
||||
)
|
||||
})
|
||||
|
||||
test_that("cleanup callbacks run when stopped", {
|
||||
stopped <- FALSE
|
||||
app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
onStop(function() stopped <<- TRUE)
|
||||
|
||||
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
|
||||
handle$stop()
|
||||
|
||||
expect_true(stopped)
|
||||
})
|
||||
|
||||
test_that("old handle doesn't see new app's result", {
|
||||
app1 <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
|
||||
|
||||
stopApp("result1")
|
||||
while (handle1$status() == "running") {
|
||||
later::run_now(1)
|
||||
}
|
||||
expect_equal(handle1$result(), "result1")
|
||||
|
||||
# Start and stop app2
|
||||
app2 <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
|
||||
|
||||
stopApp("result2")
|
||||
while (handle2$status() == "running") {
|
||||
later::run_now(timeoutSecs = 1)
|
||||
}
|
||||
expect_equal(handle2$result(), "result2")
|
||||
|
||||
# handle1 should still have its original result
|
||||
expect_equal(handle1$result(), "result1")
|
||||
})
|
||||
|
||||
test_that("global isRunning() works with non-blocking apps", {
|
||||
app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
expect_false(isRunning())
|
||||
|
||||
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
|
||||
on.exit(handle$stop(), add = TRUE)
|
||||
|
||||
expect_true(isRunning())
|
||||
|
||||
handle$stop()
|
||||
expect_false(isRunning())
|
||||
})
|
||||
|
||||
test_that("startup failure clears app state (regression test)", {
|
||||
# If startup fails after initCurrentAppState() but before cleanupOnExit <- FALSE,
|
||||
# the app state must be cleared so subsequent runApp() calls don't fail with
|
||||
# "Can't start a new app while another is running"
|
||||
|
||||
# Create an app that fails during onStart (which runs after initCurrentAppState)
|
||||
failing_app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {},
|
||||
onStart = function() stop("Intentional startup failure")
|
||||
)
|
||||
|
||||
# This should fail
|
||||
expect_error(
|
||||
startApp(failing_app, launch.browser = FALSE, quiet = TRUE),
|
||||
"Intentional startup failure"
|
||||
)
|
||||
|
||||
# isRunning() should return FALSE - no app is actually running
|
||||
expect_false(isRunning())
|
||||
|
||||
# A subsequent runApp() call should work
|
||||
working_app <- shinyApp(
|
||||
ui = fluidPage(),
|
||||
server = function(input, output) {}
|
||||
)
|
||||
|
||||
handle <- startApp(working_app, launch.browser = FALSE, quiet = TRUE)
|
||||
on.exit(handle$stop(), add = TRUE)
|
||||
|
||||
expect_equal(handle$status(), "running")
|
||||
handle$stop()
|
||||
})
|
||||
@@ -139,6 +139,7 @@ reference:
|
||||
desc: Functions that are used to run or stop Shiny applications.
|
||||
contents:
|
||||
- runApp
|
||||
- startApp
|
||||
- runGadget
|
||||
- runExample
|
||||
- runGadget
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"srcts/types/"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user