Compare commits

...

10 Commits

Author SHA1 Message Date
Carson
9da8c86a75 Revert "Use native classList API instead of jQuery for class toggling"
This reverts commit 2c077b96f9.
2026-04-28 18:01:09 -05:00
Carson
2c077b96f9 Use native classList API instead of jQuery for class toggling 2026-04-28 18:00:11 -05:00
Carson
22e0a0b0dd Rename CSS class to shiny-conditional--shown
Drops "panel" since the class applies to any [data-display-if] element,
not just conditionalPanel() output. Uses "shown" to mirror the existing
show/shown/hide/hidden events and avoid confusion with Bootstrap's
"active" class.
2026-04-28 17:50:34 -05:00
Carson
ac2c33a5fc Extract CSS class name into a single const 2026-04-28 17:44:18 -05:00
Carson
ee4568f609 Target [data-display-if] in CSS instead of .shiny-panel-conditional
The JS targets all [data-display-if] elements, so the CSS should match.
This ensures hand-crafted HTML with data-display-if (without the
.shiny-panel-conditional class) also gets the hidden-by-default behavior.
2026-04-28 17:35:35 -05:00
Carson
1fc858440d Add NEWS entry for conditionalPanel flash fix (#3505) 2026-04-28 17:20:16 -05:00
cpsievert
d4882a9426 npm run build (GitHub Actions) 2026-04-28 21:37:49 +00:00
Carson
68032b46e5 fix: hide conditionalPanel on initial render to prevent flash (#3505)
conditionalPanel renders visible HTML, but the JS that evaluates the
condition doesn't run until the WebSocket connects. This causes a brief
flash of content that should be hidden.

Fix by using CSS to hide `.shiny-panel-conditional` by default, and
toggling a `--active` modifier class in JS when the condition is true.
This also avoids jQuery's `.show()` setting `display: block`, which
would conflict with BS5's `display: contents` pass-through rule.
2026-04-28 16:33:01 -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
29 changed files with 751 additions and 680 deletions

25
NEWS.md
View File

@@ -1,16 +1,35 @@
# shiny (development version)
## Bug fixes
* `conditionalPanel()` no longer briefly flashes its contents on app start
when the condition is initially `FALSE`. (#3505)
## Improvements
* Output resize/visibility detection now uses native browser observers
(`ResizeObserver`, `IntersectionObserver`) instead of relying on jQuery
`shown`/`hidden` events and `window.resize`. This makes Shiny's client-side
output-info pipeline (image/plot sizing, hidden-state tracking, theme
reporting) work automatically in any layout — including CSS-only show/hide,
third-party tab components, and non-Bootstrap frameworks — without requiring
custom event hooks. (#3682)
# shiny 1.13.0
## New features
* Shiny now supports interactive breakpoints when used with Ark (e.g. in Positron). (#4352)
* Shiny now supports interactive breakpoints when used with Ark (e.g. in
Positron). (#4352)
## Bug fixes and minor improvements
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`) now hide internal Shiny rendering pipeline frames, making error messages cleaner and more focused on user code. (#4358)
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`)
now hide internal Shiny rendering pipeline frames, making error messages
cleaner and more focused on user code. (#4358)
* Fixed an issue with `actionLink()` that extended the link underline to whitespace around the text. (#4348)
* Fixed an issue with `actionLink()` that extended the link underline to
whitespace around the text. (#4348)
# shiny 1.12.1

View File

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

View File

@@ -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);
@@ -6233,6 +6295,7 @@ ${duplicateIdMsg}`;
var messageHandlers = {};
var customMessageHandlerOrder = [];
var customMessageHandlers = {};
var conditionalShownClass = "shiny-conditional--shown";
function addMessageHandler(type, handler) {
if (messageHandlers[type]) {
throw 'handler for message of type "' + type + '" already added.';
@@ -6705,15 +6768,15 @@ ${duplicateIdMsg}`;
const nsPrefix = el.attr("data-ns-prefix");
const nsScope = this._narrowScope(scope, nsPrefix);
const show3 = Boolean(condFunc(nsScope));
const showing = el.css("display") !== "none";
const showing = el.hasClass(conditionalShownClass);
if (show3 !== showing) {
if (show3) {
el.trigger("show");
el.show();
el.addClass(conditionalShownClass);
el.trigger("shown");
} else {
el.trigger("hide");
el.hide();
el.removeClass(conditionalShownClass);
el.trigger("hidden");
}
}
@@ -7288,8 +7351,6 @@ ${duplicateIdMsg}`;
return {
inputs,
inputsRate,
sendOutputHiddenState,
maybeAddThemeObserver,
inputBindings,
outputBindings,
initDeferredIframes,
@@ -7300,7 +7361,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 +7383,178 @@ ${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 handleVisualChange(el) {
doTriggerResize(el);
doSendHiddenState(el);
if (reportsSize(el)) doSendSize(el);
if (reportsTheme(el)) doSendTheme(el);
}
function ensureObservers(el) {
const $el = (0, import_jquery40.default)(el);
if (!$el.data("shiny-resize-observer")) {
const onResize = sendOutputInfoFns.createObserverCallback(
100,
() => handleVisualChange(el)
);
const ro = new ResizeObserver(() => onResize());
ro.observe(el);
$el.data("shiny-resize-observer-callback", onResize);
$el.data("shiny-resize-observer", ro);
}
if (!$el.data("shiny-intersection-observer")) {
const onIntersect = sendOutputInfoFns.createObserverCallback(
100,
() => handleVisualChange(el)
);
const io = new IntersectionObserver(() => onIntersect());
io.observe(el);
$el.data("shiny-intersection-observer-callback", onIntersect);
$el.data("shiny-intersection-observer", io);
}
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
if (reportsTheme(el)) doSendTheme(el);
});
const mo = new MutationObserver(() => onMutate());
mo.observe(el, {
attributes: true,
attributeFilter: ["style", "class"]
});
$el.data("shiny-mutate-observer", mo);
$el.data("shiny-mutate-observer-callback", onMutate);
}
}
function doSendOutputInfo(initial = false) {
const outputIds = /* @__PURE__ */ new Set();
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
const el = this;
const id = getIdFromEl(el);
if (id) outputIds.add(id);
ensureObservers(el);
if (!initial) doTriggerResize(el);
doSendHiddenState(el, initial);
if (reportsSize(el)) {
doSendSize(el, initial);
}
handler.apply(this, [namespaceArr, handler, ...args]);
};
if (reportsTheme(el)) {
doSendTheme(el, initial);
}
});
visibleOutputs.forEach((id) => {
if (!outputIds.has(id)) {
visibleOutputs.delete(id);
setInput(".clientdata_output_" + id + "_hidden", true, initial);
}
});
}
(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);
initialValues[".clientdata_pixelratio"] = pixelRatio();
(0, import_jquery40.default)(window).resize(function() {
inputs.setInput(".clientdata_pixelratio", pixelRatio());

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,7 +39,7 @@ $datepicker-disabled-color: $dropdown-link-disabled-color !default;
$shiny-file-active-shadow: $input-focus-box-shadow !default;
.shiny-panel-conditional,
[data-display-if].shiny-conditional--shown,
div:where(.shiny-html-output) {
/* uiOutput()/ conditionalPanel() are "pass-through" containers when they have children. */
&:has(> *) {

View File

@@ -488,6 +488,11 @@ textarea.textarea-autoresize.form-control {
display: none;
}
/* conditionalPanel: hidden until JS evaluates the condition */
[data-display-if]:not(.shiny-conditional--shown) {
display: none;
}
/* Hidden tabPanels */
.nav-hidden {
/* override anything bootstrap sets for `.nav` */

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@posit/shiny",
"version": "1.12.1-alpha.9000",
"version": "1.13.0-alpha.9000",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@posit/shiny",
"version": "1.12.1-alpha.9000",
"version": "1.13.0-alpha.9000",
"license": "MIT",
"dependencies": {
"@types/bootstrap": "5.2.x",

View File

@@ -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,224 @@ 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");
if (!bgColor) return bgColor;
const m = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
);
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
// No background color on this element. See if it has a background image.
const bgImage = getStyle(el, "background-image");
if (bgImage && bgImage !== "none") {
// Failed to detect background color, since it has a background image
function getComputedBgColor(
el: HTMLElement | null,
): string | null | undefined {
if (!el) {
return null;
} else {
// Recurse
return getComputedBgColor(el.parentElement);
}
const bgColor = getStyle(el, "background-color");
if (!bgColor) return bgColor;
const m = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
);
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
const bgImage = getStyle(el, "background-image");
if (bgImage && bgImage !== "none") {
return null;
} else {
return getComputedBgColor(el.parentElement);
}
}
return bgColor;
}
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,
);
}
function getComputedFont(el: HTMLElement) {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
const visibleOutputs = new Set<string>();
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize,
};
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);
}
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function () {
function reportsSize(el: HTMLElement): boolean {
return (
el.classList.contains("shiny-image-output") ||
el.classList.contains("shiny-plot-output") ||
el.classList.contains("shiny-report-size")
);
}
function reportsTheme(el: HTMLElement): boolean {
return (
el.classList.contains("shiny-image-output") ||
el.classList.contains("shiny-plot-output") ||
el.classList.contains("shiny-report-theme")
);
}
function handleVisualChange(el: HTMLElement): void {
doTriggerResize(el);
doSendHiddenState(el);
if (reportsSize(el)) doSendSize(el);
if (reportsTheme(el)) doSendTheme(el);
}
function ensureObservers(el: HTMLElement): void {
const $el = $(el);
if (!$el.data("shiny-resize-observer")) {
const onResize = sendOutputInfoFns.createObserverCallback(100, () =>
handleVisualChange(el),
);
const ro = new ResizeObserver(() => onResize());
ro.observe(el);
$el.data("shiny-resize-observer-callback", onResize);
$el.data("shiny-resize-observer", ro);
}
if (!$el.data("shiny-intersection-observer")) {
const onIntersect = sendOutputInfoFns.createObserverCallback(100, () =>
handleVisualChange(el),
);
const io = new IntersectionObserver(() => onIntersect());
io.observe(el);
$el.data("shiny-intersection-observer-callback", onIntersect);
$el.data("shiny-intersection-observer", io);
}
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
if (reportsTheme(el)) doSendTheme(el);
});
const mo = new MutationObserver(() => onMutate());
mo.observe(el, {
attributes: true,
attributeFilter: ["style", "class"],
});
$el.data("shiny-mutate-observer", mo);
$el.data("shiny-mutate-observer-callback", onMutate);
}
}
function doSendOutputInfo(initial = false) {
const outputIds = new Set<string>();
$(".shiny-bound-output").each(function () {
// 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
}
if (!initial) doTriggerResize(el);
doSendHiddenState(el, initial);
if (reportsSize(el)) {
doSendSize(el, initial);
}
if (reportsTheme(el)) {
doSendTheme(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);
// 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

@@ -70,6 +70,8 @@ const messageHandlers: { [key: string]: Handler } = {};
const customMessageHandlerOrder: string[] = [];
const customMessageHandlers: { [key: string]: Handler } = {};
const conditionalShownClass = "shiny-conditional--shown";
// Adds Shiny (internal) message handler
function addMessageHandler(type: string, handler: Handler) {
if (messageHandlers[type]) {
@@ -614,16 +616,16 @@ class ShinyApp {
const nsPrefix = el.attr("data-ns-prefix") as string;
const nsScope = this._narrowScope(scope, nsPrefix);
const show = Boolean(condFunc(nsScope));
const showing = el.css("display") !== "none";
const showing = el.hasClass(conditionalShownClass);
if (show !== showing) {
if (show) {
el.trigger("show");
el.show();
el.addClass(conditionalShownClass);
el.trigger("shown");
} else {
el.trigger("hide");
el.hide();
el.removeClass(conditionalShownClass);
el.trigger("hidden");
}
}

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

10
tsconfig.eslint.json Normal file
View File

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