Compare commits

..

40 Commits

Author SHA1 Message Date
shikokuchuo
c4c91f62a1 Scope OTEL promise domain to startApp() setup and service loop
`local_otel_promise_domain()` binds the domain to the caller's frame,
which in `startApp()` exits before any request is served. A persistent
global install would leak into unrelated user promises between ticks.

Wrap the synchronous setup phase and each service iteration in
`with_otel_promise_domain()`. Callbacks are wrapped at registration
time, so promises created during `onStart`, handlers, and observers
stay instrumented when they fire. The domain is dormant between ticks,
so it stays out of user promises at the console.
2026-04-17 15:08:54 +01:00
shikokuchuo
88b4facb8f Move option resolution into .setupShinyApp()
Aligns `startApp()` with `runApp()` by setting `options(warn,
pool.scheduler)` before `as.shiny.appobj()` and passing `ops` through.
Folds the `findVal` precedence block into `.setupShinyApp()`; missingness
is checked in the caller's frame via a `caller = parent.frame()` default
arg, since `runApp()`/`startApp()` formals carry defaults.
2026-04-17 12:48:42 +01:00
shikokuchuo
957b50d3b6 Fix stale variable name in test comment 2026-04-17 12:27:52 +01:00
shikokuchuo
da50bf2249 Add startApp() to pkgdown.yml 2026-04-15 21:18:39 +01:00
shikokuchuo
72636ef4a0 Amend stopApp() docs 2026-04-10 15:28:07 +01:00
shikokuchuo
02a5e0b40f Separate out non-blocking into startApp() 2026-04-10 15:11:08 +01:00
Charlie Gao
0ff93d411f Apply suggestions from code review
Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
2026-03-20 10:31:07 +00:00
shikokuchuo
bd250962e4 Amend currently running message 2026-03-20 10:29:37 +00:00
shikokuchuo
3a0e8627a4 Have handle$stop() method handle setting of stopped flag 2026-03-20 10:26:21 +00:00
shikokuchuo
b813adec56 Move generation increment before the blocking/non-blocking branch 2026-03-18 10:41:56 +00:00
shikokuchuo
f29fa65af9 Use generation invalidation for loops 2026-03-17 22:49:37 +00:00
shikokuchuo
36e7a330d6 Update news 2026-03-17 22:32:51 +00:00
shikokuchuo
6d984266f9 devtools::document() (GitHub Actions) 2026-03-13 18:54:05 +00:00
shikokuchuo
c27c186c0f Make non-default for LLMs 2026-03-13 18:51:09 +00:00
Charlie Gao
2907e83c42 Merge branch 'main' into non-blocking 2026-03-05 14:09:04 +00:00
shikokuchuo
45985690b2 Apply review changes from @cpsievert and @schloerke 2026-02-18 18:39:07 +00:00
shikokuchuo
ce11abe46d Merge branch 'main' into non-blocking 2026-02-18 12:19:35 +00:00
shikokuchuo
7e8903f754 Consistent cleanup order 2026-02-18 12:05:10 +00:00
shikokuchuo
664cbe2858 Move cleanup func creation 2026-02-18 12:00:06 +00:00
shikokuchuo
63af3649c8 Minimize changes to on.exit() handlers 2026-02-18 11:46:34 +00:00
shikokuchuo
3cb928e894 Simplify serviceAsync() 2026-02-17 12:40:47 +00:00
shikokuchuo
8e63d08d8a Make handle$stop() idempotent 2026-02-17 12:21:22 +00:00
Charlie Gao
1db26f60af Merge branch 'main' into non-blocking 2026-02-17 12:09:31 +00:00
shikokuchuo
1432920a7e Refactor pass 2026-02-17 11:50:11 +00:00
shikokuchuo
3882d1e4c3 Update news and docs 2026-02-17 11:08:52 +00:00
shikokuchuo
532c17081a Auto-stop upon new runApp() 2026-02-17 10:54:04 +00:00
shikokuchuo
329bc979c6 Default to non-blocking for LLMs 2026-02-17 10:39:14 +00:00
shikokuchuo
0456847883 Add documentation about running the later loop 2026-02-06 21:47:50 +00:00
shikokuchuo
6bbe29a390 Fix cleanup step and add regression test 2026-02-06 21:24:45 +00:00
shikokuchuo
c8bfa93747 Consolidate two on.exit() clauses 2026-02-06 20:31:45 +00:00
Charlie Gao
27134d9c66 Merge branch 'main' into non-blocking 2026-02-06 19:20:15 +00:00
shikokuchuo
48540283a4 Use combined error message 2026-02-05 15:34:43 +00:00
shikokuchuo
49b76badcc Rename internal function 2026-02-05 15:07:40 +00:00
shikokuchuo
935de77aee Cleanup pass 2026-02-05 14:42:16 +00:00
shikokuchuo
8b53c6d2fd Silence tests 2026-02-05 14:03:59 +00:00
shikokuchuo
3ccbad7a70 Tighten later loop for performance 2026-02-05 13:53:38 +00:00
shikokuchuo
13812b45a7 Review internal methods 2026-02-05 12:45:03 +00:00
shikokuchuo
08680d9566 Review user interface 2026-02-05 11:40:52 +00:00
shikokuchuo
620e5a277b Update unrelated tabPanel test snapshot 2026-02-04 19:41:06 +00:00
shikokuchuo
bb26c0f4d3 Non-blocking concept 2026-02-04 16:28:06 +00:00
39 changed files with 1419 additions and 834 deletions

View File

@@ -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'

View File

@@ -276,6 +276,7 @@ export(snapshotPreprocessInput)
export(snapshotPreprocessOutput)
export(span)
export(splitLayout)
export(startApp)
export(stopApp)
export(strong)
export(submitButton)

28
NEWS.md
View File

@@ -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
View 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
}
}
)
)

View File

@@ -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()].

View File

@@ -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

View File

@@ -41,7 +41,7 @@ export default [{
sourceType: "module",
parserOptions: {
project: ["./tsconfig.eslint.json"],
project: ["./tsconfig.json"],
},
},

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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(> *) {

View File

@@ -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` */

View File

@@ -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
View 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.
}

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View 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 };

View File

@@ -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 };

View File

@@ -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");
}
}

View File

@@ -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);
});

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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 {};

View File

@@ -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 };

View 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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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, };

View 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()
})

View File

@@ -139,6 +139,7 @@ reference:
desc: Functions that are used to run or stop Shiny applications.
contents:
- runApp
- startApp
- runGadget
- runExample
- runGadget

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"exclude": [
"node_modules",
"srcts/types/"
]
}