Compare commits

..

17 Commits

Author SHA1 Message Date
Carson Sievert
0bcf877835 fix: restore theme output refresh compatibility (#4378) 2026-04-28 21:26:52 -05:00
Carson Sievert
719f3c8b3b fix: restore pre-#3682 visibility semantics (#4376) 2026-04-28 16:07:15 -05:00
Carson Sievert
e07298a728 Replace jQuery event-driven output info with per-output ResizeObserver/IntersectionObserver (#3682)
* Use ResizeObserver/IntersectionObserver for per-output resize handling

Replace the old window-resize + Bootstrap event listener approach with
per-output ResizeObserver, IntersectionObserver, and MutationObserver.
Each bound output now gets its own observers that handle resize,
visibility, and theme changes independently, rather than relying on
global window resize events and jQuery Bootstrap hooks.

Also renames sendImageSize -> sendOutputInfo, simplifies bind.ts by
removing sendOutputHiddenState/maybeAddThemeObserver from BindInputsCtx,
and makes ImageOutputBinding.resize() actually set width/height on the
img element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix observer cleanup, zoom-aware sizing, and edge cases in resize handling

- Disconnect ResizeObserver/IntersectionObserver/MutationObserver on unbind
  to prevent callbacks firing on stale elements
- Restore getBoundingClientSizeBeforeZoom for size reporting to fix CSS zoom
  regression (see #4135)
- Guard doTriggerResize against missing binding during teardown races
- Fix visibleOutputs set to properly remove hidden outputs
- Hoist observer setup out of doSendOutputInfo loop to avoid re-allocating
  closures on every call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix pending output observer callbacks after unbind

* Flush pending output info before input send

* test and restore theme refresh for output observers

* fix: avoid theme mutation observers for non-theme outputs

* Replace custom isHidden() with native el.checkVisibility()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Simplify observer setup: inline outputInfoObserver, consolidate cleanup, guard null IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert image resize width/height attr setting

The resize() method only needs to trigger the jQuery resize event for
brush re-projection. Setting width/height attrs on the <img> just
briefly stretched the stale plot before the server re-render replaced
it, with no meaningful effect on behavior.

* Restore image resize() to match main exactly

The previous commit over-scoped the revert by changing the method
signature. This restores the original signature from main.

* Remove review doc

* Skip doTriggerResize() during initial output info send

* Add isVisible() fallback for browsers without checkVisibility()

* `npm run build` (GitHub Actions)

* Update NEWS.md

* Remove unnecessary type cast in debounce cancel test

The debounce() function already returns DebouncedFunction with a
required `cancel` property. The cast to an optional `cancel` weakened
type checking.

* Defer sendOutputInfoFns.regular lookup to execution time

Wrap setTimeout callbacks with arrow functions so regular is resolved
at call time rather than captured when it may still be undefined.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2026-04-28 09:41:16 -05:00
Carson Sievert
75a63716e5 Increment version number to 1.13.0.9000 (#4361)
* Increment version number to 1.13.0.9000

* `npm run build` (GitHub Actions)

* Sync package version (GitHub Actions)

---------

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2026-02-24 16:39:52 -06:00
Carson Sievert
b240b0b868 v1.13.0 release candidate (#4360)
* Increment version number to 1.13.0

* Fix broken URL in NEWS

* Reorder NEWS bullets

* `npm run build` (GitHub Actions)

* Sync package version (GitHub Actions)

---------

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2026-02-24 14:58:46 -06:00
Garrick Aden-Buie
3c18aca49b fix: add stack trace fences to hide internal render pipeline frames (#4358)
* test: add failing tests for render* stack trace fence coverage

Add tests for renderPlot, renderPrint, renderText, renderUI,
renderTable, and renderImage verifying that internal rendering
pipeline frames are hidden by stack trace fences.

All 6 tests fail, revealing that:
- All render functions leak `renderFunc` through the fences
- renderPlot additionally leaks `drawPlot`, `drawReactive`
- renderPrint additionally leaks `with_promise_domain`

Relates to #4357

* refactor: move stack trace test helpers to helper-stacks.R

Move extractStackTrace, cleanLocs, dumpTests, and
captureFilteredRenderTrace into helper-stacks.R so they are
available to all test files. Rename causeRenderError to
captureFilteredRenderTrace.

* fix: add stack trace fences to hide internal render pipeline frames

Add ..stacktraceoff../..stacktraceon.. fence pairs so that internal
rendering pipeline frames (renderFunc, hybrid_chain, drawPlot, etc.)
are hidden from filtered stack traces in the debugger.

- markRenderFunction: wrap renderFunc() call with ..stacktraceoff..
- createRenderFunction: wrap func() with ..stacktraceon.. to restore
  visibility for user code
- renderPrint: wrap func() with ..stacktraceon.. inside promise domain

For renderPlot, the existing ..stacktraceon from installExprFunction
is sufficient once the outer ..stacktraceoff.. is in place.

Fixes #4357

* fix: Legacy datatable stack traces

* chore: Add news bullet

* chore: Just normalize path in the place that needs it

* chore: don't normalize file path

* fix: Handle srcfilealias in stack traces and telemetry

Following commit 272dda27e, which normalized paths only in the #line
directive, sourceUTF8() now creates srcfilealias objects for user code.
This broke code that assumed only package code had srcfile$original.

## How the new approach works

When sourceUTF8() wraps code with a #line directive:

```r
file <- 'app.R'  # Keep original path (relative/symlink/as-typed)
lines <- c(
  '..stacktraceon..({',
  sprintf('#line 1 "%s"', normalizePath(file, ...)),  # Normalize HERE
  readLines(file),
  '})'
)
src <- srcfilecopy(file, lines, isFile = TRUE)  # Uses original path
expr <- parse(text = lines, srcfile = src)
```

The parser sees the #line path differs from srcfilecopy's path, so it
creates a srcfilealias with:
- srcfile$filename = absolute path (from #line, for source refs)
- srcfile$original$filename = original path (from srcfilecopy)

This gives us both: accurate source references + user-friendly paths.

## Changes made

1. Add getSrcfileFilename() helper
   - Prefers $original$filename (user-typed path) when available
   - Falls back to $filename (absolute) for old-style srcfile objects
   - Ensures stack traces show "app.R#10" not "/abs/path/app.R#10"

2. Add isPackageFile() helper
   - Checks if absolute path is under .libPaths()
   - More reliable than checking for $original presence

3. Fix getCallCategories()
   - Now uses isPackageFile() instead of checking $original
   - User code properly categorized as "user" (bold blue in traces)
   - Package code properly categorized as "pkg" (de-emphasized)

4. Update getLocs() and otel_srcref_attributes()
   - Use getSrcfileFilename() to show user-friendly paths

## Benefits

- Stack traces preserve relative paths and symlinks as users typed them
- User vs package code still correctly distinguished
- Better IDE integration (paths match what user entered)
- Telemetry contains meaningful file paths

* fix: Avoid using startsWith()

* fix: Use reverse clamped cumsum for stack trace fence filtering

Replace the forward cumulative sum in stripStackTraces() with a reverse
clamped cumulative sum so that an unmatched `..stacktraceoff..` (one
with no corresponding inner `..stacktraceon..`) is a no-op. This fixes
a regression where markRenderFunction-only callers (e.g. htmlwidgets)
had their user frames hidden when called outside a reactive domain.

The new algorithm concatenates all trace segments into a single vector,
performs vectorized fence scoring, and computes visibility via the
identity: clamped_cumsum = cumsum - pmin(0, cummin(cumsum)).

Fixes #4357

* refactor: Extract skip_if_shiny_otel_tracer_is_enabled() helper

* fix: Handle srcfilealias in reactive auto-labeling

The normalizePath() in sourceUTF8() causes R to create srcfilealias
objects whose $lines is NULL, breaking rassignSrcrefToLabel() and
rexprSrcrefToLabel(). Add getSrcfileLines() helper (alongside
getSrcfileFilename()) to resolve lines from the original srcfilecopy
using srcref[7] for the correct line number.

* fix: Enforce path-boundary check in isPackageFile()

The prefix-only matching in isPackageFile() could misclassify paths
like "/usr/lib/Rcpp/..." as inside "/usr/lib/R". Normalize library
paths with a trailing slash before comparison to ensure proper
path-boundary matching.

* fix: Prefer original filename only for non-package srcfilealias

When a package is installed with keep.source.pkgs = TRUE, the
srcfilecopy original filename may point to a collated build-time path.
For package files (under .libPaths()), keep srcfile$filename to avoid
regressing stack traces and telemetry with install-time paths.

* Update R/conditions.R

---------

Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
2026-02-18 17:01:33 -06:00
Garrick Aden-Buie
9b78be1106 feat: Maybe annotate source for Ark (#4352)
* feat: Maybe annotate source for Ark

* fix: Use pre-parse approach to inject `..stacktraceon..`

* chore: Address code review feedback

* chore: Add news bullet

---------

Co-authored-by: Lionel Henry <lionel.hry@proton.me>
2026-02-17 12:22:07 -05:00
E Nelson
3a130b2015 fix: Update to increase whitespace (#4356)
* Update to increase whitespace

* Update scss again
2026-02-14 09:28:33 -06:00
Carson Sievert
27ddc696dc chore: avoid testing elapsed time on CRAN (#4351)
* chore: avoid testing elapsed time on CRAN

* chore: update tabPanel snapshot for disabled attribute on empty tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:34:39 -06:00
E Nelson
4d787c767c Update to remove whitespace and add padding when necessary (#4350)
* Update to remove whitespace and add padding when necessary

* Cleanup

* `npm run build` (GitHub Actions)

* Consolidate rules

* Updated action button

* `npm run build` (GitHub Actions)

---------

Co-authored-by: elnelson575 <elnelson575@users.noreply.github.com>
2026-02-06 09:49:06 -06:00
Garrick Aden-Buie
e161f2e4a8 fix: Provide context around SHINY_PORT triggering Shiny Server warning (#4345)
* fix: Provide context around SHINY_PORT triggering Shiny Server warning

* `npm run build` (GitHub Actions)

* chore: Add comma

Co-authored-by: Barret Schloerke <barret@posit.co>

---------

Co-authored-by: gadenbuie <gadenbuie@users.noreply.github.com>
Co-authored-by: Barret Schloerke <barret@posit.co>
2026-01-12 11:23:25 -05:00
Copilot
ca259ab0f1 ci: Rebuild npm assets and skip tests for unavailable Suggests packages (#4344)
Co-authored-by: schloerke <93231+schloerke@users.noreply.github.com>
2026-01-12 10:37:48 -05:00
Aditya bansal
9e9a3bf80b R 4.2 / Cairo testing changes reverted (#4342) 2026-01-12 09:21:54 -05:00
Karan
07af5f91c8 chore(license): Change license from GPL-3 to MIT (#4339)
* Change license from GPL-3 to MIT

Updated the project license from GPL-3 to MIT in DESCRIPTION, LICENSE, LICENSE.md, README.md, and package.json. Added LICENSE.md with the MIT license text and updated .Rbuildignore to exclude LICENSE.md from builds.

* `npm run build` (GitHub Actions)

* Update LICENSE and add LICENSE.note

Replaced the LICENSE file content with a summary including year and copyright holder. Moved detailed third-party license information to a new LICENSE.note file.

* Remove R check log file

Deleted the ..Rcheck/00check.log file, likely to clean up generated or temporary files from the repository.
2025-12-16 17:51:22 -06:00
Barret Schloerke
fda6a9fede chore(assets): Update asset versions (#4337) 2025-12-11 11:56:42 -05:00
Barret Schloerke
d2245a2e34 Increment version number to 1.12.1.9000 2025-12-09 16:29:27 -05:00
Barret Schloerke
a12a8130b8 v1.12.1 (#4329) 2025-12-09 16:26:52 -05:00
61 changed files with 2656 additions and 1993 deletions

View File

@@ -34,3 +34,4 @@
^.claude$
^README-npm\.md$
^CRAN-SUBMISSION$
^LICENSE\.md$

View File

@@ -19,7 +19,3 @@ jobs:
uses: rstudio/shiny-workflows/.github/workflows/routine.yaml@v1
R-CMD-check:
uses: rstudio/shiny-workflows/.github/workflows/R-CMD-check.yaml@v1
with:
# On R 4.2, Cairo has difficulty installing
# Remove this line when https://github.com/s-u/Cairo/issues/52 is merged
extra-packages: Cairo=?ignore

View File

@@ -1,7 +1,7 @@
Type: Package
Package: shiny
Title: Web Application Framework for R
Version: 1.12.1
Version: 1.13.0.9000
Authors@R: c(
person("Winston", "Chang", , "winston@posit.co", role = "aut",
comment = c(ORCID = "0000-0002-1576-2126")),
@@ -69,12 +69,12 @@ Description: Makes it incredibly easy to build interactive web
applications with R. Automatic "reactive" binding between inputs and
outputs and extensive prebuilt widgets make it possible to build
beautiful, responsive, and powerful applications with minimal effort.
License: GPL-3 | file LICENSE
License: MIT + file LICENSE
URL: https://shiny.posit.co/, https://github.com/rstudio/shiny
BugReports: https://github.com/rstudio/shiny/issues
Depends:
methods,
R (>= 3.0.2)
R (>= 3.1.2)
Imports:
bslib (>= 0.6.0),
cachem (>= 1.1.0),

1016
LICENSE

File diff suppressed because it is too large Load Diff

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2025 shiny authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1011
LICENSE.note Normal file

File diff suppressed because it is too large Load Diff

33
NEWS.md
View File

@@ -1,3 +1,34 @@
# shiny (development version)
## 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. This also introduces a `shiny:themechange` event
for code that needs to trigger theme clientdata refreshes after changing
surrounding visual theme context. (#3682)
# shiny 1.13.0
## New features
* 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)
* Fixed an issue with `actionLink()` that extended the link underline to
whitespace around the text. (#4348)
# shiny 1.12.1
## New features
@@ -796,7 +827,7 @@ This release features plot caching, an important new tool for improving performa
### Minor new features and improvements
* Upgrade FontAwesome from 4.7.0 to 5.3.1 and made `icon` tags browsable, which means they will display in a web browser or RStudio viewer by default (#2186). Note that if your application or library depends on FontAwesome directly using custom CSS, you may need to make some or all of the changes recommended in [Upgrade from Version 4](https://docs.fontawesome.com/v5/web/setup/upgrade-from-v4). Font Awesome icons can also now be used in static R Markdown documents.
* Upgrade FontAwesome from 4.7.0 to 5.3.1 and made `icon` tags browsable, which means they will display in a web browser or RStudio viewer by default (#2186). Note that if your application or library depends on FontAwesome directly using custom CSS, you may need to make some or all of the changes recommended in [Upgrade from Version 4](https://docs-v5.fontawesome.com/web/setup/upgrade-from-v4). Font Awesome icons can also now be used in static R Markdown documents.
* Address #174: Added `datesdisabled` and `daysofweekdisabled` as new parameters to `dateInput()`. This resolves #174 and exposes the underlying arguments of [Bootstrap Datepicker](http://bootstrap-datepicker.readthedocs.io/en/latest/options.html#datesdisabled). `datesdisabled` expects a character vector with values in `yyyy/mm/dd` format and `daysofweekdisabled` expects an integer vector with day interger ids (Sunday=0, Saturday=6). The default value for both is `NULL`, which leaves all days selectable. Thanks, @nathancday! (#2147)

View File

@@ -87,13 +87,61 @@ getCallNamesForHash <- function(calls) {
})
}
# Get the preferred filename from a srcfile object.
#
# For user code, prefer the original path (as typed by user, potentially a
# symlink or relative path) over the normalized absolute path.
#
# For package files (under .libPaths()), keep the srcfile$filename because
# when a package is installed with keep.source.pkgs = TRUE, the original
# srcfilecopy filename may point to a collated build-time path rather than
# the real installed package path.
getSrcfileFilename <- function(srcfile) {
if (!is.null(srcfile$original) &&
!is.null(srcfile$original$filename) &&
!isPackageFile(srcfile$filename)) {
srcfile$original$filename
} else {
srcfile$filename
}
}
# Get the source lines and correct line number from a srcfile + srcref.
#
# sourceUTF8() wraps user code with a `#line` directive that remaps line
# numbers. This means srcref[1] (the remapped line) may not correctly index
# into the srcfile's $lines. When a #line directive is present, R extends
# the srcref to 8 elements: [7] and [8] are the original (pre-remap) first
# and last line numbers in the srcfilecopy's coordinate system.
#
# Additionally, when the #line path differs from the srcfilecopy filename
# (e.g. macOS /tmp -> /private/tmp, or Windows path normalization), R wraps
# the srcfile in a srcfilealias whose $lines is NULL. In that case, we
# retrieve lines from the original srcfilecopy via $original.
getSrcfileLines <- function(srcfile, srcref) {
lines <- srcfile$lines
line_num <- srcref[1]
if (is.null(lines) && inherits(srcfile, "srcfilealias")) {
lines <- srcfile$original$lines
}
# Use the pre-remap line number when available and different from the
# remapped line, indicating a #line directive shifted line numbering.
if (isTRUE(length(srcref) >= 7 && srcref[7] != srcref[1])) {
line_num <- srcref[7]
}
list(lines = lines, line_num = line_num)
}
getLocs <- function(calls) {
vapply(calls, function(call) {
srcref <- attr(call, "srcref", exact = TRUE)
if (!is.null(srcref)) {
srcfile <- attr(srcref, "srcfile", exact = TRUE)
if (!is.null(srcfile) && !is.null(srcfile$filename)) {
loc <- paste0(srcfile$filename, "#", srcref[[1]])
loc <- paste0(getSrcfileFilename(srcfile), "#", srcref[[1]])
return(paste0(" [", loc, "]"))
}
}
@@ -101,13 +149,36 @@ getLocs <- function(calls) {
}, character(1))
}
# Check if a file path is in an R package library
isPackageFile <- function(filepath) {
if (is.null(filepath) || filepath == "") {
return(FALSE)
}
# Normalize paths for comparison
filepath <- normalizePath(filepath, winslash = "/", mustWork = FALSE)
lib_paths <- normalizePath(.libPaths(), winslash = "/", mustWork = FALSE)
# Ensure trailing slash for proper path-boundary matching, otherwise
# e.g. "/usr/lib/R" would incorrectly match "/usr/lib/Rcpp/..."
lib_paths <- paste0(sub("/$", "", lib_paths), "/")
# Check if the file is under any library path
any(vapply(
lib_paths,
function(lib) identical(substr(filepath, 1, nchar(lib)), lib),
logical(1)
))
}
getCallCategories <- function(calls) {
vapply(calls, function(call) {
srcref <- attr(call, "srcref", exact = TRUE)
if (!is.null(srcref)) {
srcfile <- attr(srcref, "srcfile", exact = TRUE)
if (!is.null(srcfile)) {
if (!is.null(srcfile$original)) {
if (!is.null(srcfile) && !is.null(srcfile$filename)) {
# Use the absolute path for package detection (srcfile$filename)
# rather than the original path which might be relative
if (isPackageFile(srcfile$filename)) {
return("pkg")
} else {
return("user")
@@ -445,45 +516,93 @@ printOneStackTrace <- function(stackTrace, stripResult, full, offset) {
invisible(st)
}
# Filter stack traces using fence markers to hide internal Shiny frames.
#
# `stackTraces` is a list of character vectors (call names), one per "segment".
# A single synchronous error produces one segment (the immediate call stack).
# Asynchronous errors (e.g. from promises) produce multiple segments: the deep
# stack trace segments come first, then the current segment last. Each deep
# segment may begin with frames that overlap the previous segment; a
# `..stacktracefloor..` marker delimits this redundant prefix from the active
# portion.
#
# Within the active frames, `..stacktraceon..` / `..stacktraceoff..` markers
# act as fences. Frames between a matched off/on pair (reading innermost to
# outermost) are hidden — these are the internal rendering pipeline frames
# that users don't need to see. The algorithm uses a *reverse clamped cumulative
# sum* so that an unmatched `..stacktraceoff..` (one with no corresponding
# inner `..stacktraceon..`) is a no-op, preventing it from hiding user frames.
# Fence matching works globally across segments so that a `..stacktraceoff..`
# at the end of one segment can pair with a `..stacktraceon..` at the start
# of the next.
stripStackTraces <- function(stackTraces, values = FALSE) {
score <- 1L # >=1: show, <=0: hide
lapply(seq_along(stackTraces), function(i) {
res <- stripOneStackTrace(stackTraces[[i]], i != 1, score)
score <<- res$score
toShow <- as.logical(res$trace)
if (values) {
as.character(stackTraces[[i]][toShow])
} else {
as.logical(toShow)
n_segs <- length(stackTraces)
if (n_segs == 0L) return(list())
# Replace NULL segments with empty character vectors
stackTraces <- lapply(stackTraces, function(st) st %||% character(0))
seg_lengths <- lengths(stackTraces)
total <- sum(seg_lengths)
if (total == 0L) {
return(lapply(seg_lengths, function(n) {
if (values) character(0) else logical(0)
}))
}
# Pre-compute segment boundaries (used in steps 1 and 4)
seg_ends <- cumsum(seg_lengths)
seg_starts <- c(1L, seg_ends[-n_segs] + 1L)
# Concatenate all segments into one vector for vectorized operations
all <- unlist(stackTraces)
# 1. Identify prefix elements (at/before last ..stacktracefloor.. in segs 2+)
# Prefix elements are always hidden and excluded from fence scoring.
is_active <- rep.int(TRUE, total)
if (n_segs >= 2L) {
for (i in 2:n_segs) {
if (seg_lengths[i] == 0L) next
seg_idx <- seg_starts[i]:seg_ends[i]
floor_pos <- which(all[seg_idx] == "..stacktracefloor..")
if (length(floor_pos)) {
is_active[seg_idx[seq_len(floor_pos[length(floor_pos)])]] <- FALSE
}
}
}
# 2. Compute fence scores and marker mask (vectorized across all segments)
is_on <- all == "..stacktraceon.."
is_off <- all == "..stacktraceoff.."
is_marker <- is_on | is_off | (all == "..stacktracefloor..")
scores <- integer(total)
scores[is_active & is_on] <- 1L
scores[is_active & is_off] <- -1L
# 3. Reverse clamped cumsum across all segments.
# Process from innermost (right) to outermost (left). ..stacktraceon.. (+1)
# opens a hidden region working outward, ..stacktraceoff.. (-1) closes it.
# Clamping at 0 means an unmatched ..stacktraceoff.. (one with no inner
# ..stacktraceon..) is a no-op. Prefix elements have score 0 and pass the
# running total through unchanged.
#
# Vectorized via the identity: clamped_cumsum = cumsum - pmin(0, cummin(cumsum))
rs <- rev(scores)
cs <- cumsum(rs)
depth <- rev(cs - pmin.int(0L, cummin(cs)))
# 4. Compute visibility (vectorized) and split back into segments
toShow <- is_active & depth == 0L & !is_marker
lapply(seq_len(n_segs), function(i) {
if (seg_lengths[i] == 0L) {
if (values) return(character(0)) else return(logical(0))
}
idx <- seg_starts[i]:seg_ends[i]
if (values) as.character(all[idx[toShow[idx]]]) else toShow[idx]
})
}
stripOneStackTrace <- function(stackTrace, truncateFloor, startingScore) {
prefix <- logical(0)
if (truncateFloor) {
indexOfFloor <- utils::tail(which(stackTrace == "..stacktracefloor.."), 1)
if (length(indexOfFloor)) {
stackTrace <- stackTrace[(indexOfFloor+1L):length(stackTrace)]
prefix <- rep_len(FALSE, indexOfFloor)
}
}
if (length(stackTrace) == 0) {
return(list(score = startingScore, character(0)))
}
score <- rep.int(0L, length(stackTrace))
score[stackTrace == "..stacktraceon.."] <- 1L
score[stackTrace == "..stacktraceoff.."] <- -1L
score <- startingScore + cumsum(score)
toShow <- score > 0 & !(stackTrace %in% c("..stacktraceon..", "..stacktraceoff..", "..stacktracefloor.."))
list(score = utils::tail(score, 1), trace = c(prefix, toShow))
}
# Given sys.parents() (which corresponds to sys.calls()), return a logical index
# that prunes each subtree so that only the final branch remains. The result,
# when applied to sys.calls(), is a linear list of calls without any "wrapper"

View File

@@ -59,11 +59,11 @@ actionButton <- function(inputId, label, icon = NULL, width = NULL,
icon <- validateIcon(icon)
if (!is.null(icon)) {
icon <- span(icon, class = "action-icon")
icon <- span(icon, class = "action-icon", .noWS = c("outside", "inside"))
}
if (!is.null(label)) {
label <- span(label, class = "action-label")
label <- span(label, class = "action-label", .noWS = c("outside", "inside"))
}
tags$button(
@@ -74,6 +74,7 @@ actionButton <- function(inputId, label, icon = NULL, width = NULL,
`data-val` = value,
disabled = if (isTRUE(disabled)) NA else NULL,
icon, label,
.noWS = "inside",
...
)
}
@@ -86,11 +87,11 @@ actionLink <- function(inputId, label, icon = NULL, ...) {
icon <- validateIcon(icon)
if (!is.null(icon)) {
icon <- span(icon, class = "action-icon")
icon <- span(icon, class = "action-icon", .noWS = c("outside", "inside"))
}
if (!is.null(label)) {
label <- span(label, class = "action-label")
label <- span(label, class = "action-label", .noWS = c("outside", "inside"))
}
tags$a(
@@ -99,6 +100,7 @@ actionLink <- function(inputId, label, icon = NULL, ...) {
class = "action-button action-link",
`data-val` = value,
icon, label,
.noWS = "inside",
...
)
}

View File

@@ -16,7 +16,9 @@ otel_srcref_attributes <- function(srcref, fn_name = NULL) {
# Semantic conventions for code: https://opentelemetry.io/docs/specs/semconv/registry/attributes/code/
#
# Inspiration from https://github.com/r-lib/testthat/pull/2087/files#diff-92de3306849d93d6f7e76c5aaa1b0c037e2d716f72848f8a1c70536e0c8a1564R123-R124
filename <- attr(srcref, "srcfile")$filename
srcfile <- attr(srcref, "srcfile")
# Prefer the original filename (as user typed it) over the normalized path
filename <- getSrcfileFilename(srcfile)
dropNulls(list(
"code.function.name" = fn_name,
# Location attrs

View File

@@ -304,20 +304,23 @@ rassignSrcrefToLabel <- function(
if (is.null(srcfile))
return(defaultLabel)
if (is.null(srcfile$lines))
src <- getSrcfileLines(srcfile, srcref)
lines <- src$lines
line_num <- src$line_num
if (is.null(lines))
return(defaultLabel)
lines <- srcfile$lines
# When pasting at the Console, srcfile$lines is not split
if (length(lines) == 1) {
lines <- strsplit(lines, "\n")[[1]]
}
if (length(lines) < srcref[1]) {
if (length(lines) < line_num) {
return(defaultLabel)
}
firstLine <- substring(lines[srcref[1]], srcref[2] - 1)
firstLine <- substring(lines[line_num], srcref[2] - 1)
m <- regexec(
# Require the first assignment within the line
@@ -1160,20 +1163,23 @@ rexprSrcrefToLabel <- function(srcref, defaultLabel, fnName) {
if (is.null(srcfile))
return(defaultLabel)
if (is.null(srcfile$lines))
src <- getSrcfileLines(srcfile, srcref)
lines <- src$lines
line_num <- src$line_num
if (is.null(lines))
return(defaultLabel)
lines <- srcfile$lines
# When pasting at the Console, srcfile$lines is not split
if (length(lines) == 1) {
lines <- strsplit(lines, "\n")[[1]]
}
if (length(lines) < srcref[1]) {
if (length(lines) < line_num) {
return(defaultLabel)
}
firstLine <- substring(lines[srcref[1]], 1, srcref[2] - 1)
firstLine <- substring(lines[line_num], 1, srcref[2] - 1)
# Require the assignment to be parsed from the start
m <- regexec(paste0("^(.*)(<<-|<-|=)\\s*", fnName, "\\s*\\($"), firstLine)

View File

@@ -216,8 +216,14 @@ runApp <- function(
# any valid version.
ver <- Sys.getenv('SHINY_SERVER_VERSION')
if (utils::compareVersion(ver, .shinyServerMinVersion) < 0) {
warning('Shiny Server v', .shinyServerMinVersion,
' or later is required; please upgrade!')
rlang::warn(c(
sprintf(
"Shiny Server v%s or later is required; please upgrade.",
.shinyServerMinVersion
),
"i" = "If you are not using Shiny Server, you are likely seeing this message because the `SHINY_PORT` environment variable is set in your environment.",
"i" = "Avoid using `SHINY_PORT` to prevent this warning."
))
}
}

View File

@@ -130,8 +130,10 @@ markRenderFunction <- function(
# stop warning from happening again for the same object
hasExecuted$set(TRUE)
}
if (is.null(formals(renderFunc))) renderFunc()
else renderFunc(...)
..stacktraceoff..(
if (is.null(formals(renderFunc))) renderFunc()
else renderFunc(...)
)
}
otelAttrs <-
@@ -275,7 +277,7 @@ createRenderFunction <- function(
) {
renderFunc <- function(shinysession, name, ...) {
hybrid_chain(
func(),
..stacktraceon..(func()),
function(value) {
transform(value, shinysession, name, ...)
}
@@ -628,7 +630,7 @@ renderPrint <- function(expr, env = parent.frame(), quoted = FALSE,
domain <- createRenderPrintPromiseDomain(width)
hybrid_chain(
{
with_promise_domain(domain, func())
with_promise_domain(domain, ..stacktraceon..(func()))
},
function(value) {
res <- withVisible(value)
@@ -963,7 +965,7 @@ legacyRenderDataTable <- function(expr, options = NULL, searchDelay = 500,
options <- checkDT9(options)
res <- checkAsIs(options)
hybrid_chain(
func(),
..stacktraceon..(func()),
function(data) {
if (length(dim(data)) != 2) return() # expects a rectangular data object
if (is.data.frame(data)) data <- as.data.frame(data)

View File

@@ -1366,31 +1366,62 @@ tryNativeEncoding <- function(string) {
if (identical(enc2utf8(string2), string)) string2 else string
}
# similarly, try to source() a file with UTF-8
sourceUTF8 <- function(file, envir = globalenv()) {
lines <- readUTF8(file)
enc <- if (any(Encoding(lines) == 'UTF-8')) 'UTF-8' else 'unknown'
src <- srcfilecopy(file, lines, isFile = TRUE) # source reference info
# oddly, parse(file) does not work when file contains multibyte chars that
# **can** be encoded natively on Windows (might be a bug in base R); we
# rewrite the source code in a natively encoded temp file and parse it in this
# case (the source reference is still pointed to the original file, though)
if (isWindows() && enc == 'unknown') {
file <- tempfile(); on.exit(unlink(file), add = TRUE)
writeLines(lines, file)
}
exprs <- try(parse(file, keep.source = FALSE, srcfile = src, encoding = enc))
if (inherits(exprs, "try-error")) {
diagnoseCode(file)
stop("Error sourcing ", file)
maybeAnnotateSourceForArk <- function(file, lines) {
ark_annotate_source <- get0(".ark_annotate_source", baseenv())
if (is.null(ark_annotate_source)) {
return(lines)
}
# Wrap the exprs in first `{`, then ..stacktraceon..(). It's only really the
# ..stacktraceon..() that we care about, but the `{` is needed to make that
# possible.
exprs <- makeCall(`{`, exprs)
# Need to wrap exprs in a list because we want it treated as a single argument
exprs <- makeCall(..stacktraceon.., list(exprs))
file <- normalizePath(file, mustWork = TRUE, winslash = "/") # Just to be safe
uri <- paste0("file:///", sub("^/", "", file)) # Ark expects URIs
lines_str <- paste(lines, collapse = "\n")
tryCatch(
{
annotated <- ark_annotate_source(lines_str, uri)
if (!is.null(annotated)) {
lines <- strsplit(annotated, "\n", fixed = TRUE)[[1]]
}
},
error = function(cnd) {
rlang::warn("Can't inject breakpoints for Ark", parent = cnd)
}
)
lines
}
# similarly, try to source() a file with UTF-8
sourceUTF8 <- function(file, envir = globalenv()) {
file_norm <- normalizePath(file, mustWork = TRUE, winslash = "/")
lines <- readUTF8(file)
enc <- if (any(Encoding(lines) == 'UTF-8')) 'UTF-8' else 'unknown'
# Inject Ark annotations for breakpoints if available
lines <- maybeAnnotateSourceForArk(file, lines)
# Wrap in `..stacktraceon..({...})` using string manipulation before parsing,
# with a `#line` directive to map source references back to the original file
lines <- c(
"..stacktraceon..({",
sprintf('#line 1 "%s"', file_norm),
lines,
"})"
)
# Create a source file copy, i.e. an in-memory srcfile that contains all the
# code but refers to an original file
src <- srcfilecopy(file, lines, isFile = TRUE)
# Parse from our annotated lines
exprs <- tryCatch(
parse(text = lines, keep.source = FALSE, srcfile = src, encoding = enc),
error = function(cnd) {
diagnoseCode(file)
stop("Error sourcing ", file)
}
)
eval(exprs, envir)
}

View File

@@ -61,7 +61,7 @@ We welcome contributions to the **shiny** package. Please see our [CONTRIBUTING.
## License
The shiny package as a whole is licensed under the GPLv3. See the [LICENSE](LICENSE) file for more details.
The shiny package as a whole is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
## R version support

View File

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

View File

@@ -1,2 +1,2 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
:where([data-shiny-busy-spinners] .recalculating){position:relative}[data-shiny-busy-spinners] .recalculating{min-height:var(--shiny-spinner-size, 32px)}[data-shiny-busy-spinners] .recalculating:after{position:absolute;content:"";--_shiny-spinner-url: var(--shiny-spinner-url, url(spinners/ring.svg));--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));--_shiny-spinner-size: var(--shiny-spinner-size, 32px);--_shiny-spinner-delay: var(--shiny-spinner-delay, 1s);background:var(--_shiny-spinner-color);width:var(--_shiny-spinner-size);height:var(--_shiny-spinner-size);inset:calc(50% - var(--_shiny-spinner-size) / 2);mask-image:var(--_shiny-spinner-url);-webkit-mask-image:var(--_shiny-spinner-url);opacity:0;animation-delay:var(--_shiny-spinner-delay);animation-name:fade-in;animation-duration:.25s;animation-fill-mode:forwards}[data-shiny-busy-spinners] .recalculating:has(>*),[data-shiny-busy-spinners] .recalculating:empty{opacity:1}[data-shiny-busy-spinners] .recalculating>*:not(.recalculating){opacity:var(--_shiny-fade-opacity);transition:opacity .25s ease var(--shiny-spinner-delay, 1s)}[data-shiny-busy-spinners] .recalculating.html-widget-output{visibility:inherit!important}[data-shiny-busy-spinners] .recalculating.html-widget-output>*{visibility:hidden}[data-shiny-busy-spinners] .recalculating.html-widget-output :after{visibility:visible}[data-shiny-busy-spinners] .recalculating.shiny-html-output:not(.shiny-table-output):after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, transparent, var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), transparent ) );--_shiny-pulse-height: var(--shiny-pulse-height, 3px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.2s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-direction:alternate;animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(.recalculating:not(.shiny-html-output)):after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(.recalculating.shiny-table-output):after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(#shiny-disconnected-overlay):after{display:none}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, transparent, var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), transparent ) );--_shiny-pulse-height: var(--shiny-pulse-height, 3px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.2s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-direction:alternate;animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:has(#shiny-disconnected-overlay):after{display:none}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes busy-page-pulse{0%{left:-14%;right:97%}45%{left:0%;right:14%}55%{left:14%;right:0%}to{left:97%;right:-14%}}.shiny-spinner-output-container{--shiny-spinner-size: 0px}

View File

@@ -1,3 +1,3 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
"use strict";(()=>{document.documentElement.classList.add("autoreload-enabled");var c=window.location.protocol==="https:"?"wss:":"ws:",s=window.location.pathname.replace(/\/?$/,"/")+"autoreload/",i=`${c}//${window.location.host}${s}`,l=document.currentScript?.dataset?.wsUrl||i;async function u(o){let e=new WebSocket(o),n=!1;return new Promise((a,r)=>{e.onopen=()=>{n=!0},e.onerror=t=>{r(t)},e.onclose=()=>{n?a(!1):r(new Error("WebSocket connection failed"))},e.onmessage=function(t){t.data==="autoreload"&&a(!0)}})}async function d(o){return new Promise(e=>setTimeout(e,o))}async function w(){for(;;){try{if(await u(l)){window.location.reload();return}}catch{console.debug("Giving up on autoreload");return}await d(1e3)}}w().catch(o=>{console.error(o)});})();
//# sourceMappingURL=shiny-autoreload.js.map

View File

@@ -1,2 +1,2 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
#showcase-well{border-radius:0}.shiny-code{background-color:#fff;margin-bottom:0}.shiny-code code{font-family:Menlo,Consolas,Courier New,monospace}.shiny-code-container{margin-top:20px;clear:both}.shiny-code-container h3{display:inline;margin-right:15px}.showcase-header{font-size:16px;font-weight:400}.showcase-code-link{text-align:right;padding:15px}#showcase-app-container{vertical-align:top}#showcase-code-tabs{margin-right:15px}#showcase-code-tabs pre{border:none;line-height:1em}#showcase-code-tabs .nav,#showcase-code-tabs ul{margin-bottom:0}#showcase-code-tabs .tab-content{border-style:solid;border-color:#e5e5e5;border-width:0px 1px 1px 1px;overflow:auto;border-bottom-right-radius:4px;border-bottom-left-radius:4px}#showcase-app-code{width:100%}#showcase-code-position-toggle{float:right}#showcase-sxs-code{padding-top:20px;vertical-align:top}.showcase-code-license{display:block;text-align:right}#showcase-code-content pre{background-color:#fff}

View File

@@ -1,3 +1,3 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
"use strict";(()=>{var m=400;function c(e,s){let t=0;if(e.nodeType===3){let n=e.nodeValue?.replace(/\n/g,"").length??0;if(n>=s)return{element:e,offset:s};t+=n}else if(e.nodeType===1&&e.firstChild){let n=c(e.firstChild,s);if(n.element!==null)return n;t+=n.offset}return e.nextSibling?c(e.nextSibling,s-t):{element:null,offset:t}}function a(e,s,t){let n=0;for(let l=0;l<e.childNodes.length;l++){let i=e.childNodes[l];if(i.nodeType===3){let o=/\n/g,d;for(;(d=o.exec(i.nodeValue))!==null;)if(n++,n===s)return c(i,d.index+t+1)}else if(i.nodeType===1){let o=a(i,s-n,t);if(o.element!==null)return o;n+=o.offset}}return{element:null,offset:n}}function g(e,s){if(!document.createRange)return;let t=document.getElementById("srcref_"+e);if(!t){t=document.createElement("span"),t.id="srcref_"+e;let n=e,l=document.getElementById(s.replace(/\./g,"_")+"_code");if(!l)return;let i=a(l,n[0],n[4]),o=a(l,n[2],n[5]);if(i.element===null||o.element===null)return;let d=document.createRange();i.element.parentNode?.nodeName==="SPAN"&&i.element!==o.element?d.setStartBefore(i.element.parentNode):d.setStart(i.element,i.offset),o.element.parentNode?.nodeName==="SPAN"&&i.element!==o.element?d.setEndAfter(o.element.parentNode):d.setEnd(o.element,o.offset),d.surroundContents(t)}$(t).stop(!0,!0).effect("highlight",null,1600)}window.Shiny&&window.Shiny.addCustomMessageHandler("showcase-src",function(e){e.srcref&&e.srcfile&&g(e.srcref,e.srcfile)});var r=!1,u=function(e,s){let t=s?m:1,n=e?document.getElementById("showcase-sxs-code"):document.getElementById("showcase-code-inline"),l=e?document.getElementById("showcase-code-inline"):document.getElementById("showcase-sxs-code");if(document.getElementById("showcase-app-metadata")===null){let o=$("#showcase-well");e?o.fadeOut(t):o.fadeIn(t)}if(n===null||l===null){console.warn("Could not find the host elements for the code tabs. This is likely a bug in the showcase app.");return}$(n).hide(),$(l).fadeOut(t,function(){let o=document.getElementById("showcase-code-tabs");if(o===null){console.warn("Could not find the code tabs element. This is likely a bug in the showcase app.");return}if(l.removeChild(o),n.appendChild(o),e?p():document.getElementById("showcase-code-content")?.removeAttribute("style"),$(n).fadeIn(t),!e&&(document.getElementById("showcase-app-container")?.removeAttribute("style"),s)){let f=$(n).offset()?.top;f!==void 0&&$(document.body).animate({scrollTop:f})}let d=document.getElementById("readme-md");d!==null&&(d.parentElement?.removeChild(d),e?(l.appendChild(d),$(l).fadeIn(t)):document.getElementById("showcase-app-metadata")?.appendChild(d)),document.getElementById("showcase-code-position-toggle").innerHTML=e?'<i class="fa fa-level-down"></i> show below':'<i class="fa fa-level-up"></i> show with app'}),e&&$(document.body).animate({scrollTop:0},t),r=e,h(e&&s),$(window).trigger("resize")};function h(e){let t=960,n=1,l=document.getElementById("showcase-app-code").offsetWidth;l/2>960?t=l/2:l*.66>960?t=960:(t=l*.66,n=t/960),$("#showcase-app-container").animate({width:t+"px",zoom:n*100+"%"},e?m:0)}var w=function(){u(!r,!0)},y=function(){document.body.offsetWidth>1350&&u(!0,!1)};function p(){document.getElementById("showcase-code-content").style.height=$(window).height()+"px"}function E(){let e=document.getElementById("showcase-markdown-content");if(e!==null){let s=document.getElementById("readme-md");if(s!==null){let t=e.content.cloneNode(!0);s.appendChild(t)}}}$(window).resize(function(){r&&(h(!1),p())});window.toggleCodePosition=w;$(window).on("load",y);$(window).on("load",E);window.hljs&&window.hljs.initHighlightingOnLoad();})();
//# sourceMappingURL=shiny-showcase.js.map

View File

@@ -1,3 +1,3 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
"use strict";(()=>{var t=eval;window.addEventListener("message",function(a){let e=a.data;e.code&&t(e.code)});})();
//# sourceMappingURL=shiny-testmode.js.map

View File

@@ -1,4 +1,4 @@
/*! shiny 1.12.1 | (c) 2012-2025 Posit Software, PBC. | License: GPL-3 | file LICENSE */
/*! shiny 1.13.0.9000 | (c) 2012-2026 Posit Software, PBC. | License: MIT + file LICENSE */
"use strict";
(() => {
var __create = Object.create;
@@ -230,6 +230,10 @@
this.args = args;
this.$invoke();
}
cancel() {
this.$clearTimer();
this.args = null;
}
isPending() {
return this.timerId !== null;
}
@@ -250,7 +254,7 @@
};
function debounce(threshold, func) {
let timerId = null;
return function thisFunc(...args) {
const debounced = function thisFunc(...args) {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
@@ -261,6 +265,13 @@
func.apply(thisFunc, args);
}, threshold);
};
debounced.cancel = function() {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
};
return debounced;
}
// srcts/src/time/invoke.ts
@@ -331,22 +342,58 @@
}
};
// srcts/src/shiny/sendImageSize.ts
var SendImageSize = class {
setImageSend(inputBatchSender, doSendImageSize) {
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
// 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);
this.regular = function() {
sendImageSizeDebouncer.normalCall();
sendOutputInfoDebouncer.normalCall();
};
inputBatchSender.lastChanceCallback.push(function() {
if (sendImageSizeDebouncer.isPending())
sendImageSizeDebouncer.immediateCall();
inputBatchSender.lastChanceCallback.push(() => {
__privateGet(this, _pendingObserverCallbacks).forEach((callback) => callback.flush());
if (sendOutputInfoDebouncer.isPending())
sendOutputInfoDebouncer.immediateCall();
});
this.transitioned = debounce(200, this.regular);
return sendImageSizeDebouncer;
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;
}
};
var sendImageSizeFns = new SendImageSize();
_pendingObserverCallbacks = new WeakMap();
var sendOutputInfoFns = new SendOutputInfo();
// srcts/src/shiny/singletons.ts
var import_jquery4 = __toESM(require_jquery());
@@ -543,7 +590,7 @@
$head.append(newStyle);
oldStyle.remove();
removeSheet(oldSheet);
sendImageSizeFns.transitioned();
sendOutputInfoFns.transitioned();
};
xhr.send();
};
@@ -578,7 +625,7 @@
$dummyEl.one("transitionend", () => {
$dummyEl.remove();
removeSheet(oldSheet);
sendImageSizeFns.transitioned();
sendOutputInfoFns.transitioned();
});
(0, import_jquery5.default)(document.body).append($dummyEl);
const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
@@ -811,6 +858,15 @@
}
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;
@@ -5749,12 +5805,7 @@ ${duplicateIdMsg}`;
}
return inputItems;
}
async function bindOutputs({
sendOutputHiddenState,
maybeAddThemeObserver,
outputBindings,
outputIsRecalculating
}, scope = document.documentElement) {
async function bindOutputs({ outputBindings, outputIsRecalculating }, scope = document.documentElement) {
const $scope = (0, import_jquery35.default)(scope);
const bindings = outputBindings.getBindings();
for (let i5 = 0; i5 < bindings.length; i5++) {
@@ -5769,7 +5820,6 @@ ${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);
@@ -5787,8 +5837,7 @@ ${duplicateIdMsg}`;
});
}
}
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
setTimeout(() => sendOutputInfoFns.regular(), 0);
}
function unbindInputs(scope = document.documentElement, includeSelf = false) {
const inputs = (0, import_jquery35.default)(scope).find(".shiny-bound-input").toArray();
@@ -5811,7 +5860,7 @@ ${duplicateIdMsg}`;
});
}
}
function unbindOutputs({ sendOutputHiddenState }, scope = document.documentElement, includeSelf = false) {
function unbindOutputs(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);
@@ -5825,6 +5874,20 @@ ${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
@@ -5832,8 +5895,7 @@ ${duplicateIdMsg}`;
bindingType: "output"
});
}
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
setTimeout(() => sendOutputInfoFns.regular(), 0);
}
async function _bindAll(shinyCtx, scope) {
await bindOutputs(shinyCtx, scope);
@@ -5841,9 +5903,9 @@ ${duplicateIdMsg}`;
bindingsRegistry.checkValidity(scope);
return currentInputs;
}
function unbindAll(shinyCtx, scope, includeSelf = false) {
function unbindAll(scope, includeSelf = false) {
unbindInputs(scope, includeSelf);
unbindOutputs(shinyCtx, scope, includeSelf);
unbindOutputs(scope, includeSelf);
}
async function bindAll(shinyCtx, scope) {
const currentInputItems = await _bindAll(shinyCtx, scope);
@@ -7206,7 +7268,7 @@ ${duplicateIdMsg}`;
// srcts/src/shiny/index.ts
var ShinyClass = class {
constructor() {
this.version = "1.12.1";
this.version = "1.13.0.9000";
const { inputBindings, fileInputBinding: fileInputBinding2 } = initInputBindings();
const { outputBindings } = initOutputBindings();
setFileInputBinding(fileInputBinding2);
@@ -7288,8 +7350,6 @@ ${duplicateIdMsg}`;
return {
inputs,
inputsRate,
sendOutputHiddenState,
maybeAddThemeObserver,
inputBindings,
outputBindings,
initDeferredIframes,
@@ -7300,7 +7360,7 @@ ${duplicateIdMsg}`;
await bindAll(shinyBindCtx(), scope);
};
this.unbindAll = function(scope, includeSelf = false) {
unbindAll(shinyBindCtx(), scope, includeSelf);
unbindAll(scope, includeSelf);
};
function initializeInputs(scope = document.documentElement) {
const bindings = inputBindings.getBindings();
@@ -7322,230 +7382,190 @@ ${duplicateIdMsg}`;
function getIdFromEl(el) {
const $el = (0, import_jquery40.default)(el);
const bindingAdapter = $el.data("shiny-output-binding");
if (!bindingAdapter) return null;
else return bindingAdapter.getId();
return bindingAdapter ? bindingAdapter.getId() : null;
}
initializeInputs(document.documentElement);
const initialValues = mapValues(
await _bindAll(shinyBindCtx(), document.documentElement),
(x2) => x2.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 setInput(name, value, initial = false) {
if (initial) {
initialValues[name] = value;
} else {
inputs.setInput(name, value);
}
);
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 getComputedFont(el) {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize
};
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);
}
}
(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 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 doSendTheme(el) {
function doSendTheme(el, initial = false) {
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() {
(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
);
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);
}
}
);
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function() {
doSendTheme(this);
}
);
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
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();
});
}
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);
return bgColor;
}
}
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
function getComputedFont(el2) {
const fontFamily = getStyle(el2, "font-family");
const fontSize = getStyle(el2, "font-size");
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize
};
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 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
);
}
const sendOutputHiddenStateDebouncer = new Debouncer(
null,
doSendOutputHiddenState,
0
);
function sendOutputHiddenState() {
sendOutputHiddenStateDebouncer.normalCall();
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);
}
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;
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 refreshOutputInfo(el, initial = false) {
if (!initial) doTriggerResize(el);
doSendHiddenState(el, initial);
if (reportsSize(el)) doSendSize(el, initial);
if (reportsTheme(el)) doSendTheme(el, initial);
}
function refreshThemeOutputs(initial = false) {
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
const el = this;
if (reportsTheme(el)) doSendTheme(el, initial);
});
}
function registerThemeRefreshSignals() {
const scheduleThemeInfoRefresh = sendOutputInfoFns.createObserverCallback(
100,
() => refreshThemeOutputs()
);
(0, import_jquery40.default)(window).resize(function() {
scheduleThemeInfoRefresh();
});
(0, import_jquery40.default)(document).on("shiny:themechange", function() {
scheduleThemeInfoRefresh();
});
}
function ensureObservers(el) {
const $el = (0, import_jquery40.default)(el);
if (!$el.data("shiny-resize-observer")) {
const onResize = sendOutputInfoFns.createObserverCallback(
100,
() => refreshOutputInfo(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,
() => refreshOutputInfo(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);
refreshOutputInfo(el, initial);
});
visibleOutputs.forEach((id) => {
if (!outputIds.has(id)) {
visibleOutputs.delete(id);
setInput(".clientdata_output_" + id + "_hidden", true, initial);
}
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
);
doSendOutputInfo(true);
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
registerThemeRefreshSignals();
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

@@ -463,10 +463,11 @@ textarea.textarea-autoresize.form-control {
}
}
// Add spacing between icon and label for actionButton()
.action-button:not(.action-link) {
// Add spacing between icon and label for action buttons and links (#4348)
// Using margin instead of padding so the underline doesn't extend into the gap for links
.action-button {
.action-icon + .action-label {
padding-left: 0.5ch;
margin-left: 1ch;
}
}

6
package-lock.json generated
View File

@@ -1,13 +1,13 @@
{
"name": "@posit/shiny",
"version": "1.11.1-alpha.9001",
"version": "1.13.0-alpha.9000",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@posit/shiny",
"version": "1.11.1-alpha.9001",
"license": "GPL-3.0-only",
"version": "1.13.0-alpha.9000",
"license": "MIT",
"dependencies": {
"@types/bootstrap": "5.2.x",
"@types/bootstrap-datepicker": "1.10.0",

View File

@@ -5,8 +5,8 @@
"url": "git+https://github.com/rstudio/shiny.git"
},
"name": "@posit/shiny",
"version": "1.12.1",
"license": "GPL-3.0-only",
"version": "1.13.0-alpha.9000",
"license": "MIT",
"main": "",
"browser": "",
"types": "srcts/types/extras/globalShiny.d.ts",
@@ -63,9 +63,10 @@
"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 coverage && npm run circular",
"checks": "npm run lint && npm run build_types && npm run test_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,17 +39,5 @@ 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

@@ -0,0 +1,42 @@
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 { sendImageSizeFns } from "./sendImageSize";
import { sendOutputInfoFns } from "./sendOutputInfo";
type BindScope = HTMLElement | JQuery<HTMLElement>;
@@ -229,8 +229,6 @@ type BindInputsCtx = {
inputsRate: InputRateDecorator;
inputBindings: BindingRegistry<InputBinding>;
outputBindings: BindingRegistry<OutputBinding>;
sendOutputHiddenState: () => void;
maybeAddThemeObserver: (el: HTMLElement) => void;
initDeferredIframes: () => void;
outputIsRecalculating: (id: string) => boolean;
};
@@ -318,12 +316,7 @@ function bindInputs(
}
async function bindOutputs(
{
sendOutputHiddenState,
maybeAddThemeObserver,
outputBindings,
outputIsRecalculating,
}: BindInputsCtx,
{ outputBindings, outputIsRecalculating }: BindInputsCtx,
scope: BindScope = document.documentElement,
): Promise<void> {
const $scope = $(scope);
@@ -355,12 +348,6 @@ 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);
@@ -383,8 +370,7 @@ async function bindOutputs(
}
// Send later in case DOM layout isn't final yet.
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
setTimeout(() => sendOutputInfoFns.regular(), 0);
}
function unbindInputs(
@@ -419,7 +405,6 @@ function unbindInputs(
}
}
function unbindOutputs(
{ sendOutputHiddenState }: BindInputsCtx,
scope: BindScope = document.documentElement,
includeSelf = false,
) {
@@ -443,6 +428,27 @@ 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
@@ -452,8 +458,7 @@ function unbindOutputs(
}
// Send later in case DOM layout isn't final yet.
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
setTimeout(() => sendOutputInfoFns.regular(), 0);
}
// (Named used before TS conversion)
@@ -474,13 +479,9 @@ async function _bindAll(
return currentInputs;
}
function unbindAll(
shinyCtx: BindInputsCtx,
scope: BindScope,
includeSelf = false,
): void {
function unbindAll(scope: BindScope, includeSelf = false): void {
unbindInputs(scope, includeSelf);
unbindOutputs(shinyCtx, scope, includeSelf);
unbindOutputs(scope, includeSelf);
}
async function bindAll(
shinyCtx: BindInputsCtx,

View File

@@ -17,15 +17,14 @@ 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";
@@ -52,7 +51,7 @@ import {
renderHtml,
renderHtmlAsync,
} from "./render";
import { sendImageSizeFns } from "./sendImageSize";
import { sendOutputInfoFns } from "./sendOutputInfo";
import { addCustomMessageHandler, ShinyApp, type Handler } from "./shinyapp";
import { registerNames as singletonsRegisterNames } from "./singletons";
@@ -220,8 +219,6 @@ class ShinyClass {
return {
inputs,
inputsRate,
sendOutputHiddenState,
maybeAddThemeObserver,
inputBindings,
outputBindings,
initDeferredIframes,
@@ -234,7 +231,7 @@ class ShinyClass {
await bindAll(shinyBindCtx(), scope);
};
this.unbindAll = function (scope: BindScope, includeSelf = false) {
unbindAll(shinyBindCtx(), scope, includeSelf);
unbindAll(scope, includeSelf);
};
// Calls .initialize() for all of the input objects in all input bindings,
@@ -262,12 +259,11 @@ class ShinyClass {
}
this.initializeInputs = initializeInputs;
function getIdFromEl(el: HTMLElement) {
function getIdFromEl(el: HTMLElement): string | null {
const $el = $(el);
const bindingAdapter = $el.data("shiny-output-binding");
if (!bindingAdapter) return null;
else return bindingAdapter.getId();
return bindingAdapter ? bindingAdapter.getId() : null;
}
// Initialize all input objects in the document, before binding
@@ -285,327 +281,246 @@ class ShinyClass {
(x) => x.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 setInput(name: string, value: unknown, initial = false): void {
if (initial) {
initialValues[name] = value;
} else {
inputs.setInput(name, value);
}
}
if (rect.width !== 0 || rect.height !== 0) {
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
}
},
);
function doSendSize(el: HTMLElement, initial = false): void {
const id = getIdFromEl(el);
function getComputedBgColor(
el: HTMLElement | null,
): string | null | undefined {
if (!el) {
// Top of document, can't recurse further
return null;
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;
}
const bgColor = getStyle(el, "background-color");
function getComputedBgColor(
el: HTMLElement | null,
): string | null | undefined {
if (!el) {
return null;
}
if (!bgColor) return bgColor;
const m = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
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,
);
}
const visibleOutputs = new Set<string>();
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);
}
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 refreshOutputInfo(el: HTMLElement, initial = false): void {
if (!initial) doTriggerResize(el);
doSendHiddenState(el, initial);
if (reportsSize(el)) doSendSize(el, initial);
if (reportsTheme(el)) doSendTheme(el, initial);
}
function refreshThemeOutputs(initial = false): void {
$(".shiny-bound-output").each(function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const el = this;
if (reportsTheme(el)) doSendTheme(el, initial);
});
}
function registerThemeRefreshSignals(): void {
const scheduleThemeInfoRefresh = sendOutputInfoFns.createObserverCallback(
100,
() => refreshThemeOutputs(),
);
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");
// Compatibility: window resize used to refresh theme-reporting output
// info.
$(window).resize(function () {
scheduleThemeInfoRefresh();
});
if (bgImage && bgImage !== "none") {
// Failed to detect background color, since it has a background image
return null;
} else {
// Recurse
return getComputedBgColor(el.parentElement);
}
// Explicit API for code that changes surrounding theme context without
// changing the output element itself.
$(document).on("shiny:themechange", function () {
scheduleThemeInfoRefresh();
});
}
function ensureObservers(el: HTMLElement): void {
const $el = $(el);
if (!$el.data("shiny-resize-observer")) {
const onResize = sendOutputInfoFns.createObserverCallback(100, () =>
refreshOutputInfo(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, () =>
refreshOutputInfo(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);
}
return bgColor;
}
function getComputedFont(el: HTMLElement) {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
function doSendOutputInfo(initial = false) {
const outputIds = new Set<string>();
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize,
};
}
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function () {
$(".shiny-bound-output").each(function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
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);
},
);
if (id) outputIds.add(id);
ensureObservers(el);
// 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
}
refreshOutputInfo(el, 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();
visibleOutputs.forEach((id) => {
if (!outputIds.has(id)) {
visibleOutputs.delete(id);
setInput(".clientdata_output_" + id + "_hidden", true, initial);
}
});
}
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,
);
doSendOutputInfo(true);
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
registerThemeRefreshSignals();
// 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 { sendImageSizeFns } from "./sendImageSize";
import { sendOutputInfoFns } from "./sendOutputInfo";
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);
sendImageSizeFns.transitioned();
sendOutputInfoFns.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
// sendImageSizeFns.transitioned() and remove the old sheet. We also remove the
// sendOutputInfoFns.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
// sendImageSizeFns.transitioned(), which is debounced. Otherwise, it can result in
// sendOutputInfoFns.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);
sendImageSizeFns.transitioned();
sendOutputInfoFns.transitioned();
});
$(document.body).append($dummyEl);

View File

@@ -1,36 +0,0 @@
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

@@ -0,0 +1,77 @@
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

@@ -0,0 +1,18 @@
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,6 +39,10 @@ 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;
}
@@ -70,15 +74,21 @@ 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,
): (...args: Parameters<T>) => void {
): DebouncedFunction<T> {
let timerId: ReturnType<typeof setTimeout> | null = null;
// Do not alter `function()` into an arrow function.
// The `this` context needs to be dynamically bound
return function thisFunc(...args: Parameters<T>) {
const debounced = function thisFunc(...args: Parameters<T>) {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
@@ -92,6 +102,16 @@ 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,6 +59,16 @@ 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();
@@ -421,6 +431,7 @@ export {
isBS3,
isnan,
isShinyInDevMode,
isVisible,
makeResizeFilter,
mapValues,
mergeSort,

View File

@@ -13,7 +13,6 @@ 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,8 +7,6 @@ type BindInputsCtx = {
inputsRate: InputRateDecorator;
inputBindings: BindingRegistry<InputBinding>;
outputBindings: BindingRegistry<OutputBinding>;
sendOutputHiddenState: () => void;
maybeAddThemeObserver: (el: HTMLElement) => void;
initDeferredIframes: () => void;
outputIsRecalculating: (id: string) => boolean;
};
@@ -23,7 +21,7 @@ declare function bindInputs(shinyCtx: BindInputsCtx, scope?: BindScope): {
};
};
declare function _bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<ReturnType<typeof bindInputs>>;
declare function unbindAll(shinyCtx: BindInputsCtx, scope: BindScope, includeSelf?: boolean): void;
declare function unbindAll(scope: BindScope, includeSelf?: boolean): void;
declare function bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<void>;
export { _bindAll, bindAll, unbindAll };
export type { BindInputsCtx, BindScope };

View File

@@ -1,9 +0,0 @@
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

@@ -0,0 +1,17 @@
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,9 +10,14 @@ 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;
}
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): (...args: Parameters<T>) => 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>;
export { debounce, Debouncer };
export type { DebouncedFunction };

View File

@@ -5,6 +5,7 @@ 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;
@@ -34,4 +35,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, 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, isVisible, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };

View File

@@ -3,19 +3,26 @@
Code
actionButton("foo", "Click me")
Output
<button id="foo" type="button" class="btn btn-default action-button">
<span class="action-label">Click me</span>
</button>
<button id="foo" type="button" class="btn btn-default action-button"><span class="action-label">Click me</span></button>
---
Code
actionButton("foo", "Click me", icon = icon("star"))
Output
<button id="foo" type="button" class="btn btn-default action-button">
<span class="action-icon">
<i class="far fa-star" role="presentation" aria-label="star icon"></i>
</span>
<span class="action-label">Click me</span>
</button>
<button id="foo" type="button" class="btn btn-default action-button"><span class="action-icon"><i class="far fa-star" role="presentation" aria-label="star icon"></i></span><span class="action-label">Click me</span></button>
# actionLink uses .noWS to prevent underline rendering issues
Code
actionLink("foo", "Click me")
Output
<a id="foo" href="#" class="action-button action-link"><span class="action-label">Click me</span></a>
---
Code
actionLink("foo", "Click me", icon = icon("star"))
Output
<a id="foo" href="#" class="action-button action-link"><span class="action-icon"><i class="far fa-star" role="presentation" aria-label="star icon"></i></span><span class="action-label">Click me</span></a>

View File

@@ -1,111 +1,103 @@
# integration tests
Code
df
df_integration_slim
Output
num call loc
1 68 A [test-stacks.R#3]
2 67 B [test-stacks.R#7]
3 66 <reactive:C> [test-stacks.R#11]
4 44 C
5 43 renderTable [test-stacks.R#18]
6 42 func
7 41 force
8 40 withVisible
9 39 withCallingHandlers
10 38 domain$wrapSync
11 37 promises::with_promise_domain
12 36 captureStackTraces
13 32 tryCatch
14 31 do
15 30 hybrid_chain
16 29 renderFunc
17 28 renderTable({ C() }, server = FALSE)
18 10 isolate
19 9 withCallingHandlers [test-stacks.R#16]
20 8 domain$wrapSync
21 7 promises::with_promise_domain
22 6 captureStackTraces
23 2 tryCatch
24 1 try
25 0 causeError [test-stacks.R#14]
1 70 A [test-stacks.R#3]
2 69 B [test-stacks.R#7]
3 68 <reactive:C> [test-stacks.R#11]
4 46 C
5 45 renderTable [test-stacks.R#18]
6 44 func
7 28 renderTable({ C() }, server = FALSE)
8 10 isolate
9 9 withCallingHandlers [test-stacks.R#16]
10 8 domain$wrapSync
11 7 promises::with_promise_domain
12 6 captureStackTraces
13 2 tryCatch
14 1 try
15 0 causeError [test-stacks.R#14]
---
Code
df
df_integration_full
Output
num call loc
1 71 h
2 70 .handleSimpleError
3 69 stop
4 68 A [test-stacks.R#3]
5 67 B [test-stacks.R#7]
6 66 <reactive:C> [test-stacks.R#11]
7 65 ..stacktraceon..
8 64 .func
9 63 withVisible
10 62 withCallingHandlers
11 61 contextFunc
12 60 env$runWith
13 59 withCallingHandlers
14 58 domain$wrapSync
15 57 promises::with_promise_domain
16 56 captureStackTraces
17 55 force
18 54 with_otel_span_context
19 53 force
20 52 domain$wrapSync
21 51 promises::with_promise_domain
22 50 withReactiveDomain
23 49 domain$wrapSync
24 48 promises::with_promise_domain
25 47 ctx$run
26 46 self$.updateValue
27 45 ..stacktraceoff..
28 44 C
29 43 renderTable [test-stacks.R#18]
30 42 func
31 41 force
32 40 withVisible
33 39 withCallingHandlers
34 38 domain$wrapSync
35 37 promises::with_promise_domain
36 36 captureStackTraces
37 35 doTryCatch
38 34 tryCatchOne
39 33 tryCatchList
40 32 tryCatch
41 31 do
42 30 hybrid_chain
43 29 renderFunc
44 28 renderTable({ C() }, server = FALSE)
45 27 ..stacktraceon.. [test-stacks.R#17]
46 26 contextFunc
47 25 env$runWith
48 24 withCallingHandlers
49 23 domain$wrapSync
50 22 promises::with_promise_domain
51 21 captureStackTraces
52 20 force
53 19 with_otel_span_context
54 18 force
55 17 domain$wrapSync
56 16 promises::with_promise_domain
57 15 withReactiveDomain
58 14 domain$wrapSync
59 13 promises::with_promise_domain
60 12 ctx$run
61 11 ..stacktraceoff..
62 10 isolate
63 9 withCallingHandlers [test-stacks.R#16]
64 8 domain$wrapSync
65 7 promises::with_promise_domain
66 6 captureStackTraces
67 5 doTryCatch [test-stacks.R#15]
68 4 tryCatchOne
69 3 tryCatchList
70 2 tryCatch
71 1 try
72 0 causeError [test-stacks.R#14]
1 73 h
2 72 .handleSimpleError
3 71 stop
4 70 A [test-stacks.R#3]
5 69 B [test-stacks.R#7]
6 68 <reactive:C> [test-stacks.R#11]
7 67 ..stacktraceon..
8 66 .func
9 65 withVisible
10 64 withCallingHandlers
11 63 contextFunc
12 62 env$runWith
13 61 withCallingHandlers
14 60 domain$wrapSync
15 59 promises::with_promise_domain
16 58 captureStackTraces
17 57 force
18 56 with_otel_span_context
19 55 force
20 54 domain$wrapSync
21 53 promises::with_promise_domain
22 52 withReactiveDomain
23 51 domain$wrapSync
24 50 promises::with_promise_domain
25 49 ctx$run
26 48 self$.updateValue
27 47 ..stacktraceoff..
28 46 C
29 45 renderTable [test-stacks.R#18]
30 44 func
31 43 ..stacktraceon..
32 42 force
33 41 withVisible
34 40 withCallingHandlers
35 39 domain$wrapSync
36 38 promises::with_promise_domain
37 37 captureStackTraces
38 36 doTryCatch
39 35 tryCatchOne
40 34 tryCatchList
41 33 tryCatch
42 32 do
43 31 hybrid_chain
44 30 renderFunc
45 29 ..stacktraceoff..
46 28 renderTable({ C() }, server = FALSE)
47 27 ..stacktraceon.. [test-stacks.R#17]
48 26 contextFunc
49 25 env$runWith
50 24 withCallingHandlers
51 23 domain$wrapSync
52 22 promises::with_promise_domain
53 21 captureStackTraces
54 20 force
55 19 with_otel_span_context
56 18 force
57 17 domain$wrapSync
58 16 promises::with_promise_domain
59 15 withReactiveDomain
60 14 domain$wrapSync
61 13 promises::with_promise_domain
62 12 ctx$run
63 11 ..stacktraceoff..
64 10 isolate
65 9 withCallingHandlers [test-stacks.R#16]
66 8 domain$wrapSync
67 7 promises::with_promise_domain
68 6 captureStackTraces
69 5 doTryCatch [test-stacks.R#15]
70 4 tryCatchOne
71 3 tryCatchList
72 2 tryCatch
73 1 try
74 0 causeError [test-stacks.R#14]

View File

@@ -353,7 +353,7 @@
<div class="tabbable">
<ul class="nav nav-tabs" data-tabsetid="4785">
<li>
<a href="#tab-4785-1" data-toggle="tab" data-bs-toggle="tab"></a>
<a href="#tab-4785-1" data-toggle="tab" data-bs-toggle="tab" disabled></a>
</li>
<li class="active">
<a href="#tab-4785-2" data-toggle="tab" data-bs-toggle="tab" data-value="A">A</a>

View File

@@ -1,3 +1,9 @@
skip_if_shiny_otel_tracer_is_enabled <- function() {
if (shiny_otel_tracer()$is_enabled()) {
skip("Skipping stack trace tests when OpenTelemetry is already enabled")
}
}
# Helper function to create a mock otel span
create_mock_otel_span <- function(name = "test_span") {
structure(

View File

@@ -0,0 +1,118 @@
#' @details `extractStackTrace` takes a list of calls (e.g. as returned
#' from `conditionStackTrace(cond)`) and returns a data frame with one
#' row for each stack frame and the columns `num` (stack frame number),
#' `call` (a function name or similar), and `loc` (source file path
#' and line number, if available). It was deprecated after shiny 1.0.5 because
#' it doesn't support deep stack traces.
#' @rdname stacktrace
#' @export
extractStackTrace <- function(calls,
full = get_devmode_option("shiny.fullstacktrace", FALSE),
offset = getOption("shiny.stacktraceoffset", TRUE)) {
srcrefs <- getSrcRefs(calls)
if (offset) {
# Offset calls vs. srcrefs by 1 to make them more intuitive.
# E.g. for "foo [bar.R:10]", line 10 of bar.R will be part of
# the definition of foo().
srcrefs <- c(utils::tail(srcrefs, -1), list(NULL))
}
calls <- setSrcRefs(calls, srcrefs)
callnames <- getCallNames(calls)
# Hide and show parts of the callstack based on ..stacktrace(on|off)..
if (full) {
toShow <- rep.int(TRUE, length(calls))
} else {
# Remove stop(), .handleSimpleError(), and h() calls from the end of
# the calls--they don't add any helpful information. But only remove
# the last *contiguous* block of them, and then, only if they are the
# last thing in the calls list.
hideable <- callnames %in% c("stop", ".handleSimpleError", "h")
# What's the last that *didn't* match stop/.handleSimpleError/h?
lastGoodCall <- max(which(!hideable))
toRemove <- length(calls) - lastGoodCall
# But don't remove more than 5 levels--that's an indication we might
# have gotten it wrong, I guess
if (toRemove > 0 && toRemove < 5) {
calls <- utils::head(calls, -toRemove)
callnames <- utils::head(callnames, -toRemove)
}
toShow <- stripStackTraces(list(callnames))[[1]]
toShow <-
toShow &
# doTryCatch, tryCatchOne, and tryCatchList are not informative--they're
# just internals for tryCatch
!(callnames %in% c("doTryCatch", "tryCatchOne", "tryCatchList")) &
# doWithOneRestart and withOneRestart are not informative--they're
# just internals for withRestarts
!(callnames %in% c("withOneRestart", "doWithOneRestart"))
}
calls <- calls[toShow]
calls <- rev(calls) # Show in traceback() order
index <- rev(which(toShow))
width <- floor(log10(max(index))) + 1
data.frame(
num = index,
call = getCallNames(calls),
loc = getLocs(calls),
# category = getCallCategories(calls),
stringsAsFactors = FALSE
)
}
cleanLocs <- function(locs) {
locs[!grepl("test-stacks\\.R", locs, perl = TRUE)] <- ""
# sub("^.*#", "", locs)
locs
}
dumpTests <- function(df) {
print(bquote({
expect_equal(df$num, .(df$num))
expect_equal(df$call, .(df$call))
expect_equal(nzchar(df$loc), .(nzchar(df$loc)))
}))
}
# Helper: run a render function whose body throws an error, capture the
# stack trace, apply fence-based filtering, and return the filtered data
# frame. The render function body should call a function that calls stop().
# `needs_session` indicates whether the render function requires
# shinysession/name parameters (TRUE for markRenderFunction-based renders
# like renderPlot and renderPrint, FALSE for createRenderFunction-based
# renders like renderText/renderTable/renderUI/renderImage which can be
# called with no args).
captureFilteredRenderTrace <- function(render_fn, needs_session = TRUE) {
session <- MockShinySession$new()
on.exit(if (!session$isClosed()) session$close())
res <- try({
captureStackTraces({
isolate({
withReactiveDomain(session, {
if (needs_session) {
render_fn(shinysession = session, name = "testoutput")
} else {
render_fn()
}
})
})
})
},
silent = TRUE)
cond <- attr(res, "condition", exact = TRUE)
stopifnot(!is.null(cond))
stopifnot(!is.null(conditionStackTrace(cond)))
suppressMessages(
extractStackTrace(conditionStackTrace(cond), full = FALSE)
)
}

View File

@@ -94,3 +94,30 @@ test_that("Action button allows icon customization", {
expect_equal(as_character(btn2), as_character(btn3))
expect_equal(as_character(btn3), as_character(btn4))
})
test_that("actionLink uses .noWS to prevent underline rendering issues", {
# actionLink should generate compact HTML without whitespace between tags
# This prevents the underline from extending beyond the visible text
# Test without icon
link <- actionLink("test_link", "Click me")
link_html <- as.character(link)
# Verify no newlines/whitespace between closing > and opening <span
expect_false(
grepl(">\n\\s+<span", link_html),
info = "actionLink should not have whitespace between tags"
)
# Test with icon
link_icon <- actionLink("test_link2", "Click me", icon = icon("star"))
link_icon_html <- as.character(link_icon)
# Should also have no whitespace between icon span and label span
expect_false(
grepl(">\n\\s+<span", link_icon_html),
info = "actionLink with icon should not have whitespace between tags"
)
expect_snapshot(actionLink("foo", "Click me"))
expect_snapshot(actionLink("foo", "Click me", icon = icon("star")))
})

View File

@@ -1140,7 +1140,7 @@ test_that("Custom render functions that call installExprFunction", {
test_that("cacheWriteHook and cacheReadHook for render functions", {
testthat::skip_if(shiny_otel_tracer()$is_enabled(), "Skipping stack trace tests when OpenTelemetry is already enabled")
skip_if_shiny_otel_tracer_is_enabled()
write_hook_n <- 0
read_hook_n <- 0

View File

@@ -127,6 +127,7 @@ test_that("ExtendedTask respects reactive_update level otel collection", {
})
test_that("ExtendedTask creates span only when is_recording_otel is TRUE", {
skip_if_not_installed("otelsdk")
# Test that span is only created when otel is enabled
withr::local_options(list(shiny.otel.collect = "reactivity"))

View File

@@ -68,6 +68,7 @@ test_that("reactive bindCache labels are created", {
})
test_that("ExtendedTask otel labels are created", {
skip_if_not_installed("otelsdk")
# Record everything
localOtelCollect("all")

View File

@@ -1,3 +1,4 @@
skip_if_not_installed("ggplot2")
library(ggplot2)
# Sort a list by the names of its keys

View File

@@ -14,6 +14,140 @@ test_that("can access reactive values directly", {
expect_equal(y(), 4)
})
describe("srcfilealias in reactive labels", {
# When a #line directive specifies a path that differs from the srcfilecopy
# filename, R's parser wraps the srcfile in a srcfilealias whose $lines is
# NULL. This is exactly what happens in sourceUTF8() when the normalized path
# differs from the original.
parse_as_srcfilealias <- function(user_code) {
code <- c('#line 1 "/absolute/path/to/app.R"', user_code)
src <- base::srcfilecopy("app.R", code, isFile = TRUE)
exprs <- parse(text = code, keep.source = TRUE, srcfile = src)
list(code = code, exprs = exprs, srcrefs = attr(exprs, "srcref"))
}
it("getSrcfileLines() resolves lines from srcfilealias", {
parsed <- parse_as_srcfilealias("my_val <- reactiveVal(1)")
srcref <- parsed$srcrefs[[1]]
srcfile <- attr(srcref, "srcfile", exact = TRUE)
expect_s3_class(srcfile, "srcfilealias")
expect_null(srcfile$lines)
result <- getSrcfileLines(srcfile, srcref)
expect_false(is.null(result$lines))
expect_equal(result$lines, parsed$code)
expect_match(result$lines[result$line_num], "my_val <- reactiveVal")
})
it("getSrcfileLines() works with regular srcfile", {
code <- c("x <- 1", "y <- 2")
src <- base::srcfilecopy("test.R", code, isFile = TRUE)
exprs <- parse(text = code, keep.source = TRUE, srcfile = src)
srcref <- attr(exprs, "srcref")[[1]]
srcfile <- attr(srcref, "srcfile", exact = TRUE)
expect_false(inherits(srcfile, "srcfilealias"))
result <- getSrcfileLines(srcfile, srcref)
expect_equal(result$lines, code)
expect_equal(result$line_num, 1L)
})
it("rassignSrcrefToLabel() extracts label from srcfilealias", {
parsed <- parse_as_srcfilealias("my_val <- reactiveVal(1)")
srcref <- parsed$srcrefs[[1]]
label <- rassignSrcrefToLabel(srcref, defaultLabel = "fallback")
expect_equal(label, "my_val")
})
it("rexprSrcrefToLabel() extracts label from srcfilealias", {
parsed <- parse_as_srcfilealias("my_r <- reactive({ 1 + 1 })")
# rexprSrcrefToLabel() expects the srcref of the reactive body (the { }),
# not the entire assignment. This mirrors how exprToLabel() calls it with
# the srcref from the body of the expression created by installExprFunction.
assign_expr <- parsed$exprs[[1]]
reactive_body <- assign_expr[[3]][[2]] # reactive( <body> )
body_srcrefs <- attr(reactive_body, "srcref")
srcref <- body_srcrefs[[1]]
label <- rexprSrcrefToLabel(srcref, defaultLabel = "fallback", fnName = "reactive")
expect_equal(label, "my_r")
})
})
test_that("sourceUTF8() auto-labels reactives despite srcfilealias", {
# sourceUTF8() uses normalizePath() in its #line directive but the original
# path for srcfilecopy. When these differ (e.g. macOS /tmp -> /private/tmp),
# R creates a srcfilealias whose $lines is NULL. When they match (e.g.
# Ubuntu), the #line directive still remaps line numbers. getSrcfileLines()
# handles both cases by using srcref[7] (the pre-remap line number).
tmp <- tempfile(fileext = ".R")
on.exit(unlink(tmp), add = TRUE)
reactiveConsole(TRUE)
on.exit(reactiveConsole(FALSE), add = TRUE)
writeLines(c(
"my_val <- reactiveVal(1)",
"my_react <- reactive({ my_val() + 1 })"
), tmp)
env <- new.env(parent = globalenv())
sourceUTF8(tmp, envir = env)
# reactiveVal label (uses rassignSrcrefToLabel)
rv_impl <- attr(env$my_val, ".impl", exact = TRUE)
expect_equal(
rv_impl$.__enclos_env__$private$label,
"my_val"
)
# reactive label (uses rexprSrcrefToLabel via exprToLabel)
r_observable <- attr(env$my_react, "observable", exact = TRUE)
expect_equal(as.character(r_observable$.label), "my_react")
})
describe("srcfilealias filename selection", {
parse_as_srcfilealias <- function(user_code, alias_path = "/absolute/path/to/app.R") {
code <- c(sprintf('#line 1 "%s"', alias_path), user_code)
src <- base::srcfilecopy("app.R", code, isFile = TRUE)
exprs <- parse(text = code, keep.source = TRUE, srcfile = src)
list(code = code, exprs = exprs, srcrefs = attr(exprs, "srcref"))
}
it("getSrcfileFilename() prefers original unless package file", {
lib <- normalizePath(.libPaths()[[1]], winslash = "/", mustWork = FALSE)
pkg_path <- file.path(lib, "pkg", "R", "foo.R")
parsed_pkg <- parse_as_srcfilealias("x <- 1", alias_path = pkg_path)
srcref_pkg <- parsed_pkg$srcrefs[[1]]
srcfile_pkg <- attr(srcref_pkg, "srcfile", exact = TRUE)
expect_equal(getSrcfileFilename(srcfile_pkg), pkg_path)
parsed_user <- parse_as_srcfilealias("y <- 2", alias_path = "/tmp/user.R")
srcref_user <- parsed_user$srcrefs[[1]]
srcfile_user <- attr(srcref_user, "srcfile", exact = TRUE)
expect_equal(getSrcfileFilename(srcfile_user), "app.R")
})
})
test_that("isPackageFile() uses path-boundary matching", {
lib <- normalizePath(.libPaths()[[1]], winslash = "/", mustWork = FALSE)
# A path like "{lib}Extra/foo.R" shares the prefix but is NOT inside the lib
fake_path <- paste0(lib, "Extra/foo.R")
expect_false(isPackageFile(fake_path))
# A path actually inside the library SHOULD match
real_path <- file.path(lib, "pkg", "R", "foo.R")
expect_true(isPackageFile(real_path))
})
test_that("errors in throttled/debounced reactives are catchable", {
reactiveConsole(TRUE)
on.exit(reactiveConsole(FALSE))

View File

@@ -240,6 +240,7 @@ test_that("stack trace stripping works", {
})
test_that("coro async generator deep stack count is low", {
skip_if_not_installed("coro")
gen <- coro::async_generator(function() {
for (i in 1:50) {
await(coro::async_sleep(0.001))

View File

@@ -32,97 +32,6 @@ causeError <- function(full) {
df
}
#' @details `extractStackTrace` takes a list of calls (e.g. as returned
#' from `conditionStackTrace(cond)`) and returns a data frame with one
#' row for each stack frame and the columns `num` (stack frame number),
#' `call` (a function name or similar), and `loc` (source file path
#' and line number, if available). It was deprecated after shiny 1.0.5 because
#' it doesn't support deep stack traces.
#' @rdname stacktrace
#' @export
extractStackTrace <- function(calls,
full = get_devmode_option("shiny.fullstacktrace", FALSE),
offset = getOption("shiny.stacktraceoffset", TRUE)) {
srcrefs <- getSrcRefs(calls)
if (offset) {
# Offset calls vs. srcrefs by 1 to make them more intuitive.
# E.g. for "foo [bar.R:10]", line 10 of bar.R will be part of
# the definition of foo().
srcrefs <- c(utils::tail(srcrefs, -1), list(NULL))
}
calls <- setSrcRefs(calls, srcrefs)
callnames <- getCallNames(calls)
# Hide and show parts of the callstack based on ..stacktrace(on|off)..
if (full) {
toShow <- rep.int(TRUE, length(calls))
} else {
# Remove stop(), .handleSimpleError(), and h() calls from the end of
# the calls--they don't add any helpful information. But only remove
# the last *contiguous* block of them, and then, only if they are the
# last thing in the calls list.
hideable <- callnames %in% c("stop", ".handleSimpleError", "h")
# What's the last that *didn't* match stop/.handleSimpleError/h?
lastGoodCall <- max(which(!hideable))
toRemove <- length(calls) - lastGoodCall
# But don't remove more than 5 levels--that's an indication we might
# have gotten it wrong, I guess
if (toRemove > 0 && toRemove < 5) {
calls <- utils::head(calls, -toRemove)
callnames <- utils::head(callnames, -toRemove)
}
# This uses a ref-counting scheme. It might make sense to switch this
# to a toggling scheme, so the most recent ..stacktrace(on|off)..
# directive wins, regardless of what came before it.
# Also explicitly remove ..stacktraceon.. because it can appear with
# score > 0 but still should never be shown.
score <- rep.int(0, length(callnames))
score[callnames == "..stacktraceoff.."] <- -1
score[callnames == "..stacktraceon.."] <- 1
toShow <- (1 + cumsum(score)) > 0 & !(callnames %in% c("..stacktraceon..", "..stacktraceoff..", "..stacktracefloor.."))
toShow <-
toShow &
# doTryCatch, tryCatchOne, and tryCatchList are not informative--they're
# just internals for tryCatch
!(callnames %in% c("doTryCatch", "tryCatchOne", "tryCatchList")) &
# doWithOneRestart and withOneRestart are not informative--they're
# just internals for withRestarts
!(callnames %in% c("withOneRestart", "doWithOneRestart"))
}
calls <- calls[toShow]
calls <- rev(calls) # Show in traceback() order
index <- rev(which(toShow))
width <- floor(log10(max(index))) + 1
data.frame(
num = index,
call = getCallNames(calls),
loc = getLocs(calls),
# category = getCallCategories(calls),
stringsAsFactors = FALSE
)
}
cleanLocs <- function(locs) {
locs[!grepl("test-stacks\\.R", locs, perl = TRUE)] <- ""
# sub("^.*#", "", locs)
locs
}
dumpTests <- function(df) {
print(bquote({
expect_equal(df$num, .(df$num))
expect_equal(df$call, .(df$call))
expect_equal(nzchar(df$loc), .(nzchar(df$loc)))
}))
}
test_that("integration tests", {
if (shiny_otel_tracer()$is_enabled()) {
announce_snapshot_file(name = "stacks.md")
@@ -139,15 +48,15 @@ test_that("integration tests", {
# problems on CRAN.
skip_on_cran()
df <- causeError(full = FALSE)
# dumpTests(df)
df_integration_slim <- causeError(full = FALSE)
# dumpTests(df_integration_slim)
expect_snapshot(df)
expect_snapshot(df_integration_slim)
df <- causeError(full = TRUE)
df_integration_full <- causeError(full = TRUE)
expect_snapshot(df)
# dumpTests(df)
expect_snapshot(df_integration_full)
# dumpTests(df_integration_full)
})
test_that("shiny.error", {
@@ -272,3 +181,170 @@ test_that("observeEvent is not overly stripped (#4162)", {
expect_match(st_str, "A__", all = FALSE)
expect_match(st_str, "B__", all = FALSE)
})
test_that("renderPlot stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderPlot")
}
df <- captureFilteredRenderTrace(renderPlot({ userFunc() }))
expect_true("userFunc" %in% df$call)
# Internal rendering pipeline frames should NOT appear in the filtered
# stack trace. These are Shiny internals between the stack trace fences
# that currently leak through due to missing fences.
internal_render_frames <- c(
"drawPlot",
"drawReactive",
"renderFunc",
"startPNG"
)
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("renderPrint stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderPrint")
}
df <- captureFilteredRenderTrace(renderPrint({ userFunc() }))
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("renderText stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderText")
}
df <- captureFilteredRenderTrace(renderText({ userFunc() }), needs_session = FALSE)
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("renderUI stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderUI")
}
df <- captureFilteredRenderTrace(renderUI({ userFunc() }), needs_session = FALSE)
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("renderTable stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderTable")
}
df <- captureFilteredRenderTrace(
renderTable({ userFunc() }, server = FALSE),
needs_session = FALSE
)
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("renderImage stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderImage")
}
df <- captureFilteredRenderTrace(
renderImage({ userFunc() }, deleteFile = FALSE),
needs_session = FALSE
)
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("legacyRenderDataTable stack trace fences hide internal rendering pipeline (#4357)", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
userFunc <- function() {
stop("test error in renderDataTable")
}
df <- captureFilteredRenderTrace(
legacyRenderDataTable({ userFunc() })
)
expect_true("userFunc" %in% df$call)
internal_render_frames <- c("renderFunc")
leaked <- df$call[df$call %in% internal_render_frames]
expect_length(leaked, 0)
})
test_that("markRenderFunction preserves user frames outside reactive domain", {
skip_on_cran()
skip_if_shiny_otel_tracer_is_enabled()
# htmlwidgets-style: exprToFunction + markRenderFunction, no ..stacktraceon..
renderWidgetLike <- function(expr, env = parent.frame(), quoted = FALSE) {
if (!quoted) expr <- substitute(expr)
func <- exprToFunction(expr, env, TRUE)
renderFunc <- function() { func() }
markRenderFunction(textOutput, renderFunc)
}
userFunc <- function() stop("boom")
render_fn <- renderWidgetLike({ userFunc() })
res <- try(captureStackTraces({ render_fn() }), silent = TRUE)
cond <- attr(res, "condition", exact = TRUE)
df <- extractStackTrace(conditionStackTrace(cond), full = FALSE)
expect_true("userFunc" %in% df$call)
})

View File

@@ -44,6 +44,7 @@ test_that("runTests works with a dir app that calls modules and uses testServer"
})
test_that("runTests works with a dir app that calls modules that return reactives and use brushing", {
skip_if_not_installed("ggplot2")
app <- test_path("..", "test-modules", "107_scatterplot")
run <- testthat::expect_output(
print(runTests(app)),

View File

@@ -288,6 +288,9 @@ test_that("works with async", {
})
test_that("works with multiple promises in parallel", {
# This test is inherently about timing which is against CRAN's policy.
testthat::skip_on_cran()
server <- function(input, output, session) {
output$txt1 <- renderText({
future({

10
tsconfig.eslint.json Normal file
View File

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