Compare commits

..

2 Commits

Author SHA1 Message Date
Karan Gathani
5faf3b0c22 fix: handle optional callback type for jQuery fadeOut overload 2026-03-31 12:04:52 -07:00
Karan Gathani
df5c831ad0 refactor: make shiny-showcase.ts jQuery-optional with native JS fallbacks 2026-03-31 11:18:38 -07:00
27 changed files with 845 additions and 797 deletions

22
NEWS.md
View File

@@ -1,32 +1,16 @@
# 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)
* 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.eslint.json"],
project: ["./tsconfig.json"],
},
},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -230,10 +230,6 @@
this.args = args;
this.$invoke();
}
cancel() {
this.$clearTimer();
this.args = null;
}
isPending() {
return this.timerId !== null;
}
@@ -254,7 +250,7 @@
};
function debounce(threshold, func) {
let timerId = null;
const debounced = function thisFunc(...args) {
return function thisFunc(...args) {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
@@ -265,13 +261,6 @@
func.apply(thisFunc, args);
}, threshold);
};
debounced.cancel = function() {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
};
return debounced;
}
// srcts/src/time/invoke.ts
@@ -342,58 +331,22 @@
}
};
// srcts/src/shiny/sendOutputInfo.ts
var _pendingObserverCallbacks;
var SendOutputInfo = class {
constructor() {
__privateAdd(this, _pendingObserverCallbacks, /* @__PURE__ */ new Set());
}
setSendMethod(inputBatchSender, doSendOutputInfo) {
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
// srcts/src/shiny/sendImageSize.ts
var SendImageSize = class {
setImageSend(inputBatchSender, doSendImageSize) {
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
this.regular = function() {
sendOutputInfoDebouncer.normalCall();
sendImageSizeDebouncer.normalCall();
};
inputBatchSender.lastChanceCallback.push(() => {
__privateGet(this, _pendingObserverCallbacks).forEach((callback) => callback.flush());
if (sendOutputInfoDebouncer.isPending())
sendOutputInfoDebouncer.immediateCall();
inputBatchSender.lastChanceCallback.push(function() {
if (sendImageSizeDebouncer.isPending())
sendImageSizeDebouncer.immediateCall();
});
this.transitioned = debounce(200, this.regular);
return sendOutputInfoDebouncer;
}
createObserverCallback(delayMs, callback) {
const debouncer = new Debouncer(
null,
() => {
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
callback();
},
delayMs
);
const observerCallback = Object.assign(
() => {
__privateGet(this, _pendingObserverCallbacks).add(observerCallback);
debouncer.normalCall();
},
{
cancel: () => {
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
debouncer.cancel();
},
flush: () => {
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
if (debouncer.isPending()) {
debouncer.immediateCall();
}
},
isPending: () => debouncer.isPending()
}
);
return observerCallback;
return sendImageSizeDebouncer;
}
};
_pendingObserverCallbacks = new WeakMap();
var sendOutputInfoFns = new SendOutputInfo();
var sendImageSizeFns = new SendImageSize();
// srcts/src/shiny/singletons.ts
var import_jquery4 = __toESM(require_jquery());
@@ -590,7 +543,7 @@
$head.append(newStyle);
oldStyle.remove();
removeSheet(oldSheet);
sendOutputInfoFns.transitioned();
sendImageSizeFns.transitioned();
};
xhr.send();
};
@@ -625,7 +578,7 @@
$dummyEl.one("transitionend", () => {
$dummyEl.remove();
removeSheet(oldSheet);
sendOutputInfoFns.transitioned();
sendImageSizeFns.transitioned();
});
(0, import_jquery5.default)(document.body).append($dummyEl);
const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
@@ -858,15 +811,6 @@
}
return x2;
}
function isVisible(el) {
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
return true;
}
if (getStyle(el, "display") === "none") {
return false;
}
return el.parentElement ? isVisible(el.parentElement) : true;
}
function padZeros(n4, digits) {
let str = n4.toString();
while (str.length < digits) str = "0" + str;
@@ -5805,7 +5749,12 @@ ${duplicateIdMsg}`;
}
return inputItems;
}
async function bindOutputs({ outputBindings, outputIsRecalculating }, scope = document.documentElement) {
async function bindOutputs({
sendOutputHiddenState,
maybeAddThemeObserver,
outputBindings,
outputIsRecalculating
}, scope = document.documentElement) {
const $scope = (0, import_jquery35.default)(scope);
const bindings = outputBindings.getBindings();
for (let i5 = 0; i5 < bindings.length; i5++) {
@@ -5820,6 +5769,7 @@ ${duplicateIdMsg}`;
if ($el.hasClass("shiny-bound-output")) {
continue;
}
maybeAddThemeObserver(el);
const bindingAdapter = new OutputBindingAdapter(el, binding);
await shinyAppBindOutput(id, bindingAdapter);
$el.data("shiny-output-binding", bindingAdapter);
@@ -5837,7 +5787,8 @@ ${duplicateIdMsg}`;
});
}
}
setTimeout(() => sendOutputInfoFns.regular(), 0);
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
}
function unbindInputs(scope = document.documentElement, includeSelf = false) {
const inputs = (0, import_jquery35.default)(scope).find(".shiny-bound-input").toArray();
@@ -5860,7 +5811,7 @@ ${duplicateIdMsg}`;
});
}
}
function unbindOutputs(scope = document.documentElement, includeSelf = false) {
function unbindOutputs({ sendOutputHiddenState }, scope = document.documentElement, includeSelf = false) {
const outputs = (0, import_jquery35.default)(scope).find(".shiny-bound-output").toArray();
if (includeSelf && (0, import_jquery35.default)(scope).hasClass("shiny-bound-output")) {
outputs.push(scope);
@@ -5874,20 +5825,6 @@ ${duplicateIdMsg}`;
bindingsRegistry.removeBinding(id, "output");
$el.removeClass("shiny-bound-output");
$el.removeData("shiny-output-binding");
for (const prefix of [
"shiny-resize-observer",
"shiny-intersection-observer",
"shiny-mutate-observer"
]) {
const observer = $el.data(prefix);
if (observer) {
observer.disconnect();
$el.removeData(prefix);
}
const callback = $el.data(prefix + "-callback");
callback?.cancel?.();
$el.removeData(prefix + "-callback");
}
$el.trigger({
type: "shiny:unbound",
// @ts-expect-error; Can not remove info on a established, malformed Event object
@@ -5895,7 +5832,8 @@ ${duplicateIdMsg}`;
bindingType: "output"
});
}
setTimeout(() => sendOutputInfoFns.regular(), 0);
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
}
async function _bindAll(shinyCtx, scope) {
await bindOutputs(shinyCtx, scope);
@@ -5903,9 +5841,9 @@ ${duplicateIdMsg}`;
bindingsRegistry.checkValidity(scope);
return currentInputs;
}
function unbindAll(scope, includeSelf = false) {
function unbindAll(shinyCtx, scope, includeSelf = false) {
unbindInputs(scope, includeSelf);
unbindOutputs(scope, includeSelf);
unbindOutputs(shinyCtx, scope, includeSelf);
}
async function bindAll(shinyCtx, scope) {
const currentInputItems = await _bindAll(shinyCtx, scope);
@@ -7350,6 +7288,8 @@ ${duplicateIdMsg}`;
return {
inputs,
inputsRate,
sendOutputHiddenState,
maybeAddThemeObserver,
inputBindings,
outputBindings,
initDeferredIframes,
@@ -7360,7 +7300,7 @@ ${duplicateIdMsg}`;
await bindAll(shinyBindCtx(), scope);
};
this.unbindAll = function(scope, includeSelf = false) {
unbindAll(scope, includeSelf);
unbindAll(shinyBindCtx(), scope, includeSelf);
};
function initializeInputs(scope = document.documentElement) {
const bindings = inputBindings.getBindings();
@@ -7382,190 +7322,230 @@ ${duplicateIdMsg}`;
function getIdFromEl(el) {
const $el = (0, import_jquery40.default)(el);
const bindingAdapter = $el.data("shiny-output-binding");
return bindingAdapter ? bindingAdapter.getId() : null;
if (!bindingAdapter) return null;
else return bindingAdapter.getId();
}
initializeInputs(document.documentElement);
const initialValues = mapValues(
await _bindAll(shinyBindCtx(), document.documentElement),
(x2) => x2.value
);
function setInput(name, value, initial = false) {
if (initial) {
initialValues[name] = value;
} else {
inputs.setInput(name, value);
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
function() {
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
if (rect.width !== 0 || rect.height !== 0) {
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
}
}
}
function doSendSize(el, initial = false) {
const id = getIdFromEl(el);
if (!id) return;
const rect = getBoundingClientSizeBeforeZoom(el);
if (rect.width !== 0 || rect.height !== 0) {
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
);
function getComputedBgColor(el) {
if (!el) {
return null;
}
const bgColor = getStyle(el, "background-color");
if (!bgColor) return bgColor;
const m2 = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
);
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
const bgImage = getStyle(el, "background-image");
if (bgImage && bgImage !== "none") {
return null;
} else {
return getComputedBgColor(el.parentElement);
}
}
return bgColor;
}
function doTriggerResize(el) {
const $el = (0, import_jquery40.default)(el), binding = $el.data("shiny-output-binding");
if (!binding) return;
$el.trigger({
type: "shiny:visualchange",
// @ts-expect-error; Can not remove info on a established, malformed Event object
visible: isVisible(el),
binding
});
binding.onResize();
function getComputedFont(el) {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize
};
}
function doSendTheme(el, initial = false) {
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function() {
const el = this;
const id = getIdFromEl(el);
initialValues[".clientdata_output_" + id + "_bg"] = getComputedBgColor(el);
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
el,
"color"
);
initialValues[".clientdata_output_" + id + "_accent"] = getComputedLinkColor(el);
initialValues[".clientdata_output_" + id + "_font"] = getComputedFont(el);
maybeAddThemeObserver(el);
}
);
function maybeAddThemeObserver(el) {
if (!window.MutationObserver) {
return;
}
const cl = el.classList;
const reportTheme = cl.contains("shiny-image-output") || cl.contains("shiny-plot-output") || cl.contains("shiny-report-theme");
if (!reportTheme) {
return;
}
const $el = (0, import_jquery40.default)(el);
if ($el.data("shiny-theme-observer")) {
return;
}
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
const observer = new MutationObserver(
() => observerCallback.normalCall()
);
const config = { attributes: true, attributeFilter: ["style", "class"] };
observer.observe(el, config);
$el.data("shiny-theme-observer", observer);
}
function doSendTheme(el) {
if (el.classList.contains("shiny-output-error")) {
return;
}
function getComputedBgColor(el2) {
if (!el2) {
return null;
}
const bgColor = getStyle(el2, "background-color");
if (!bgColor) return bgColor;
const m2 = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
);
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
const bgImage = getStyle(el2, "background-image");
if (bgImage && bgImage !== "none") {
return null;
} else {
return getComputedBgColor(el2.parentElement);
const id = getIdFromEl(el);
inputs.setInput(
".clientdata_output_" + id + "_bg",
getComputedBgColor(el)
);
inputs.setInput(
".clientdata_output_" + id + "_fg",
getStyle(el, "color")
);
inputs.setInput(
".clientdata_output_" + id + "_accent",
getComputedLinkColor(el)
);
inputs.setInput(
".clientdata_output_" + id + "_font",
getComputedFont(el)
);
}
function doSendImageSize() {
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
function() {
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
if (rect.width !== 0 || rect.height !== 0) {
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
inputs.setInput(
".clientdata_output_" + id + "_height",
rect.height
);
}
}
return bgColor;
}
function getComputedFont(el2) {
const fontFamily = getStyle(el2, "font-family");
const fontSize = getStyle(el2, "font-size");
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize
};
}
const id = getIdFromEl(el);
if (!id) return;
setInput(
".clientdata_output_" + id + "_bg",
getComputedBgColor(el),
initial
);
setInput(
".clientdata_output_" + id + "_fg",
getStyle(el, "color"),
initial
);
setInput(
".clientdata_output_" + id + "_accent",
getComputedLinkColor(el),
initial
);
setInput(
".clientdata_output_" + id + "_font",
getComputedFont(el),
initial
);
}
const visibleOutputs = /* @__PURE__ */ new Set();
function doSendHiddenState(el, initial = false) {
const id = getIdFromEl(el);
if (!id) return;
const hidden = !isVisible(el);
if (hidden) {
visibleOutputs.delete(id);
} else {
visibleOutputs.add(id);
}
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
}
function reportsSize(el) {
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-size");
}
function reportsTheme(el) {
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-theme");
}
function 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);
(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();
});
}
doSendOutputInfo(true);
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
registerThemeRefreshSignals();
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
function isHidden(obj) {
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
return false;
} else if (getStyle(obj, "display") === "none") {
return true;
} else {
return isHidden(obj.parentNode);
}
}
let lastKnownVisibleOutputs = {};
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
const id = getIdFromEl(this);
if (isHidden(this)) {
initialValues[".clientdata_output_" + id + "_hidden"] = true;
} else {
lastKnownVisibleOutputs[id] = true;
initialValues[".clientdata_output_" + id + "_hidden"] = false;
}
});
function doSendOutputHiddenState() {
const visibleOutputs = {};
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
const id = getIdFromEl(this);
delete lastKnownVisibleOutputs[id];
const hidden = isHidden(this), evt = {
type: "shiny:visualchange",
visible: !hidden
};
if (hidden) {
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
} else {
visibleOutputs[id] = true;
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
}
const $this = (0, import_jquery40.default)(this);
evt.binding = $this.data("shiny-output-binding");
$this.trigger(evt);
});
for (const name in lastKnownVisibleOutputs) {
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
}
lastKnownVisibleOutputs = visibleOutputs;
}
const sendOutputHiddenStateDebouncer = new Debouncer(
null,
doSendOutputHiddenState,
0
);
function sendOutputHiddenState() {
sendOutputHiddenStateDebouncer.normalCall();
}
inputBatchSender.lastChanceCallback.push(function() {
if (sendOutputHiddenStateDebouncer.isPending())
sendOutputHiddenStateDebouncer.immediateCall();
});
function filterEventsByNamespace(namespace, handler, ...args) {
const namespaceArr = namespace.split(".");
return function(e4) {
const eventNamespace = e4.namespace?.split(".") ?? [];
for (let i5 = 0; i5 < namespaceArr.length; i5++) {
if (eventNamespace.indexOf(namespaceArr[i5]) === -1) return;
}
handler.apply(this, [namespaceArr, handler, ...args]);
};
}
(0, import_jquery40.default)(window).resize(debounce(500, sendImageSizeFns.regular));
const bs3classes = [
"modal",
"dropdown",
"tab",
"tooltip",
"popover",
"collapse"
];
import_jquery40.default.each(bs3classes, function(idx, classname) {
(0, import_jquery40.default)(document.body).on(
"shown.bs." + classname + ".sendImageSize",
"*",
filterEventsByNamespace("bs", sendImageSizeFns.regular)
);
(0, import_jquery40.default)(document.body).on(
"shown.bs." + classname + ".sendOutputHiddenState hidden.bs." + classname + ".sendOutputHiddenState",
"*",
filterEventsByNamespace("bs", sendOutputHiddenState)
);
});
(0, import_jquery40.default)(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
(0, import_jquery40.default)(document.body).on(
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
"*",
sendOutputHiddenState
);
initialValues[".clientdata_pixelratio"] = pixelRatio();
(0, import_jquery40.default)(window).resize(function() {
inputs.setInput(".clientdata_pixelratio", pixelRatio());

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -63,10 +63,9 @@
"bundle_shiny": "tsx srcts/build/shiny.ts",
"bundle_external_libs": "tsx srcts/build/external_libs.ts",
"bundle_extras": "tsx srcts/build/extras.ts",
"checks": "npm run lint && npm run build_types && npm run test_types && npm run coverage && npm run circular",
"checks": "npm run lint && npm run build_types && npm run coverage && npm run circular",
"lint": "node --eval \"console.log('linting code...')\" && eslint 'srcts/src/**/*.ts' --fix",
"build_types": "tsc -p tsconfig.json",
"test_types": "tsx --test $(find srcts/src -path '*/__tests__/*.test.ts' -print)",
"coverage_detailed": "npx --yes type-check --detail",
"coverage": "type-coverage -p tsconfig.json --at-least 90",
"circular": "npx --yes dpdm --transform ./srcts/src/index.ts",

View File

@@ -8,6 +8,143 @@ type ShowcaseSrcMessage = {
const animateMs = 400;
// ---------------------------------------------------------------------------
// jQuery-optional helpers: use jQuery when available, otherwise native JS.
// ---------------------------------------------------------------------------
function hasJQuery(): boolean {
return typeof jQuery !== "undefined";
}
function fadeOut(
el: HTMLElement,
ms: number,
callback?: () => void,
): void {
if (hasJQuery()) {
if (callback) {
$(el).fadeOut(ms, callback);
} else {
$(el).fadeOut(ms);
}
return;
}
el.style.transition = `opacity ${ms}ms`;
el.style.opacity = "0";
setTimeout(() => {
el.style.display = "none";
el.style.transition = "";
callback?.();
}, ms);
}
function fadeIn(el: HTMLElement, ms: number): void {
if (hasJQuery()) {
$(el).fadeIn(ms);
return;
}
el.style.display = "";
el.style.opacity = "0";
el.style.transition = `opacity ${ms}ms`;
// Force reflow so the transition actually fires
void el.offsetHeight;
el.style.opacity = "1";
// Clean up after transition ends
const cleanup = () => {
el.style.transition = "";
el.removeEventListener("transitionend", cleanup);
};
el.addEventListener("transitionend", cleanup);
}
function hideEl(el: HTMLElement): void {
if (hasJQuery()) {
$(el).hide();
return;
}
el.style.display = "none";
}
function animateScroll(top: number, ms: number): void {
if (hasJQuery()) {
$(document.body).animate({ scrollTop: top }, ms);
return;
}
window.scrollTo({ top, behavior: ms > 0 ? "smooth" : "instant" });
}
function animateStyle(
el: HTMLElement,
props: Record<string, string>,
ms: number,
): void {
if (hasJQuery()) {
$(el).animate(props, ms);
return;
}
el.style.transition = ms > 0 ? `all ${ms}ms` : "";
for (const [key, value] of Object.entries(props)) {
(el.style as any)[key] = value;
}
if (ms > 0) {
const cleanup = () => {
el.style.transition = "";
el.removeEventListener("transitionend", cleanup);
};
el.addEventListener("transitionend", cleanup);
}
}
function highlightEl(el: HTMLElement): void {
if (hasJQuery()) {
$(el).stop(true, true).effect("highlight", null, 1600);
return;
}
// CSS-based highlight fallback
el.style.transition = "background-color 0.4s";
el.style.backgroundColor = "#ffff99";
setTimeout(() => {
el.style.transition = "background-color 1.2s";
el.style.backgroundColor = "";
const cleanup = () => {
el.style.transition = "";
el.removeEventListener("transitionend", cleanup);
};
el.addEventListener("transitionend", cleanup);
}, 400);
}
function triggerResize(): void {
if (hasJQuery()) {
$(window).trigger("resize");
return;
}
window.dispatchEvent(new Event("resize"));
}
function onResize(fn: () => void): void {
if (hasJQuery()) {
$(window).resize(fn);
return;
}
window.addEventListener("resize", fn);
}
function onLoad(fn: () => void): void {
if (hasJQuery()) {
$(window).on("load", fn);
return;
}
window.addEventListener("load", fn);
}
function windowHeight(): number {
if (hasJQuery()) {
return $(window).height()!;
}
return window.innerHeight;
}
// Given a DOM node and a column (count of characters), walk recursively
// through the node's siblings counting characters until the given number
// of characters have been found.
@@ -137,7 +274,7 @@ function highlightSrcref(
range.surroundContents(el);
}
// End any previous highlight before starting this one
$(el).stop(true, true).effect("highlight", null, 1600);
highlightEl(el);
}
// If this is the main Shiny window, wire up our custom message handler.
@@ -171,12 +308,14 @@ const setCodePosition = function (above: boolean, animate: boolean) {
if (metadataElement === null) {
// if there's no app metadata, show and hide the entire well container
// when the code changes position
const wellElement = $("#showcase-well");
const wellElement = document.getElementById("showcase-well");
if (above) {
wellElement.fadeOut(animateCodeMs);
} else {
wellElement.fadeIn(animateCodeMs);
if (wellElement) {
if (above) {
fadeOut(wellElement, animateCodeMs);
} else {
fadeIn(wellElement, animateCodeMs);
}
}
}
@@ -188,8 +327,8 @@ const setCodePosition = function (above: boolean, animate: boolean) {
);
return;
}
$(newHostElement).hide();
$(currentHostElement).fadeOut(animateCodeMs, function () {
hideEl(newHostElement);
fadeOut(currentHostElement, animateCodeMs, function () {
const tabs = document.getElementById("showcase-code-tabs");
if (tabs === null) {
@@ -211,7 +350,7 @@ const setCodePosition = function (above: boolean, animate: boolean) {
?.removeAttribute("style");
}
$(newHostElement).fadeIn(animateCodeMs);
fadeIn(newHostElement, animateCodeMs);
if (!above) {
// remove the applied width and zoom on the app container, and
// scroll smoothly down to the code's new home
@@ -219,10 +358,9 @@ const setCodePosition = function (above: boolean, animate: boolean) {
.getElementById("showcase-app-container")
?.removeAttribute("style");
if (animate) {
const top = $(newHostElement).offset()?.top;
if (top !== undefined) {
$(document.body).animate({ scrollTop: top });
}
const top =
newHostElement.getBoundingClientRect().top + window.scrollY;
animateScroll(top, animateMs);
}
}
// if there's a readme, move it either alongside the code or beneath
@@ -233,7 +371,7 @@ const setCodePosition = function (above: boolean, animate: boolean) {
readme.parentElement?.removeChild(readme);
if (above) {
currentHostElement.appendChild(readme);
$(currentHostElement).fadeIn(animateCodeMs);
fadeIn(currentHostElement, animateCodeMs);
} else
document.getElementById("showcase-app-metadata")?.appendChild(readme);
}
@@ -244,11 +382,11 @@ const setCodePosition = function (above: boolean, animate: boolean) {
: '<i class="fa fa-level-up"></i> show with app';
});
if (above) {
$(document.body).animate({ scrollTop: 0 }, animateCodeMs);
animateScroll(0, animateCodeMs);
}
isCodeAbove = above;
setAppCodeSxsWidths(above && animate);
$(window).trigger("resize");
triggerResize();
};
function setAppCodeSxsWidths(animate: boolean) {
@@ -271,13 +409,17 @@ function setAppCodeSxsWidths(animate: boolean) {
appWidth = totalWidth * 0.66;
zoom = appWidth / appTargetWidth;
}
$("#showcase-app-container").animate(
{
width: appWidth + "px",
zoom: zoom * 100 + "%",
},
animate ? animateMs : 0,
);
const appContainer = document.getElementById("showcase-app-container");
if (appContainer) {
animateStyle(
appContainer,
{
width: appWidth + "px",
zoom: zoom * 100 + "%",
},
animate ? animateMs : 0,
);
}
}
const toggleCodePosition = function () {
@@ -296,7 +438,7 @@ const setInitialCodePosition = function () {
// for the tabs
function setCodeHeightFromDocHeight() {
document.getElementById("showcase-code-content")!.style.height =
$(window).height() + "px";
windowHeight() + "px";
}
// Move server-rendered markdown from template into the readme container
@@ -314,7 +456,7 @@ function insertMarkdownContent() {
}
}
$(window).resize(function () {
onResize(function () {
if (isCodeAbove) {
setAppCodeSxsWidths(false);
setCodeHeightFromDocHeight();
@@ -328,8 +470,8 @@ declare global {
}
window.toggleCodePosition = toggleCodePosition;
$(window).on("load", setInitialCodePosition);
$(window).on("load", insertMarkdownContent);
onLoad(setInitialCodePosition);
onLoad(insertMarkdownContent);
if (window.hljs) window.hljs.initHighlightingOnLoad();

View File

@@ -39,5 +39,17 @@ declare global {
): this;
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
on(
events: `shown.bs.${string}.sendImageSize`,
selector: string,
handler: (
this: HTMLElement,
e: JQuery.EventHandlerBase<HTMLElement, any>,
// e: JQuery.Event & {
// namespace: string;
// }
) => void,
): this;
}
}

View File

@@ -1,42 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { InputBatchSender } from "../../inputPolicies";
import { SendOutputInfo } from "../sendOutputInfo";
void test("pending observer output info is flushed before the next input batch send", () => {
const sentInputs: Array<{ [key: string]: unknown }> = [];
const shinyapp = {
taskQueue: {
enqueue: () => {
throw new Error("task queue should not be used in this test");
},
},
sendInput: (values: { [key: string]: unknown }) => {
sentInputs.push(values);
},
};
const inputBatchSender = new InputBatchSender(shinyapp as never);
const sendOutputInfo = new SendOutputInfo();
sendOutputInfo.setSendMethod(inputBatchSender, () => {
/* no-op */
});
const observerCallback = sendOutputInfo.createObserverCallback(100, () => {
inputBatchSender.setInput(".clientdata_output_plot_width", 400, {
priority: "immediate",
});
});
observerCallback();
inputBatchSender.setInput("user", 1, { priority: "event" });
assert.equal(sentInputs.length, 1);
const expected: { [key: string]: unknown } = { user: 1 };
expected[".clientdata_output_plot_width"] = 400;
assert.deepEqual(sentInputs[0], expected);
});

View File

@@ -10,7 +10,7 @@ import type {
} from "../inputPolicies";
import type { EventPriority } from "../inputPolicies/inputPolicy";
import { shinyAppBindOutput, shinyAppUnbindOutput } from "./initedMethods";
import { sendOutputInfoFns } from "./sendOutputInfo";
import { sendImageSizeFns } from "./sendImageSize";
type BindScope = HTMLElement | JQuery<HTMLElement>;
@@ -229,6 +229,8 @@ type BindInputsCtx = {
inputsRate: InputRateDecorator;
inputBindings: BindingRegistry<InputBinding>;
outputBindings: BindingRegistry<OutputBinding>;
sendOutputHiddenState: () => void;
maybeAddThemeObserver: (el: HTMLElement) => void;
initDeferredIframes: () => void;
outputIsRecalculating: (id: string) => boolean;
};
@@ -316,7 +318,12 @@ function bindInputs(
}
async function bindOutputs(
{ outputBindings, outputIsRecalculating }: BindInputsCtx,
{
sendOutputHiddenState,
maybeAddThemeObserver,
outputBindings,
outputIsRecalculating,
}: BindInputsCtx,
scope: BindScope = document.documentElement,
): Promise<void> {
const $scope = $(scope);
@@ -348,6 +355,12 @@ async function bindOutputs(
continue;
}
// If this element reports its CSS styles to getCurrentOutputInfo()
// then it should have a MutationObserver() to resend CSS if its
// style/class attributes change. This observer should already exist
// for _static_ UI, but not yet for _dynamic_ UI
maybeAddThemeObserver(el);
const bindingAdapter = new OutputBindingAdapter(el, binding);
await shinyAppBindOutput(id, bindingAdapter);
@@ -370,7 +383,8 @@ async function bindOutputs(
}
// Send later in case DOM layout isn't final yet.
setTimeout(() => sendOutputInfoFns.regular(), 0);
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
}
function unbindInputs(
@@ -405,6 +419,7 @@ function unbindInputs(
}
}
function unbindOutputs(
{ sendOutputHiddenState }: BindInputsCtx,
scope: BindScope = document.documentElement,
includeSelf = false,
) {
@@ -428,27 +443,6 @@ function unbindOutputs(
bindingsRegistry.removeBinding(id, "output");
$el.removeClass("shiny-bound-output");
$el.removeData("shiny-output-binding");
for (const prefix of [
"shiny-resize-observer",
"shiny-intersection-observer",
"shiny-mutate-observer",
]) {
const observer = $el.data(prefix);
if (observer) {
observer.disconnect();
$el.removeData(prefix);
}
const callback = $el.data(prefix + "-callback") as
| { cancel?: () => void }
| undefined;
callback?.cancel?.();
$el.removeData(prefix + "-callback");
}
$el.trigger({
type: "shiny:unbound",
// @ts-expect-error; Can not remove info on a established, malformed Event object
@@ -458,7 +452,8 @@ function unbindOutputs(
}
// Send later in case DOM layout isn't final yet.
setTimeout(() => sendOutputInfoFns.regular(), 0);
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
}
// (Named used before TS conversion)
@@ -479,9 +474,13 @@ async function _bindAll(
return currentInputs;
}
function unbindAll(scope: BindScope, includeSelf = false): void {
function unbindAll(
shinyCtx: BindInputsCtx,
scope: BindScope,
includeSelf = false,
): void {
unbindInputs(scope, includeSelf);
unbindOutputs(scope, includeSelf);
unbindOutputs(shinyCtx, scope, includeSelf);
}
async function bindAll(
shinyCtx: BindInputsCtx,

View File

@@ -17,14 +17,15 @@ import {
} from "../inputPolicies";
import type { InputPolicyOpts } from "../inputPolicies/inputPolicy";
import { addDefaultInputOpts } from "../inputPolicies/inputValidateDecorator";
import { debounce, Debouncer } from "../time";
import {
$escape,
compareVersion,
getBoundingClientSizeBeforeZoom,
getComputedLinkColor,
getStyle,
hasDefinedProperty,
isShinyInDevMode,
isVisible,
mapValues,
pixelRatio,
} from "../utils";
@@ -51,7 +52,7 @@ import {
renderHtml,
renderHtmlAsync,
} from "./render";
import { sendOutputInfoFns } from "./sendOutputInfo";
import { sendImageSizeFns } from "./sendImageSize";
import { addCustomMessageHandler, ShinyApp, type Handler } from "./shinyapp";
import { registerNames as singletonsRegisterNames } from "./singletons";
@@ -219,6 +220,8 @@ class ShinyClass {
return {
inputs,
inputsRate,
sendOutputHiddenState,
maybeAddThemeObserver,
inputBindings,
outputBindings,
initDeferredIframes,
@@ -231,7 +234,7 @@ class ShinyClass {
await bindAll(shinyBindCtx(), scope);
};
this.unbindAll = function (scope: BindScope, includeSelf = false) {
unbindAll(scope, includeSelf);
unbindAll(shinyBindCtx(), scope, includeSelf);
};
// Calls .initialize() for all of the input objects in all input bindings,
@@ -259,11 +262,12 @@ class ShinyClass {
}
this.initializeInputs = initializeInputs;
function getIdFromEl(el: HTMLElement): string | null {
function getIdFromEl(el: HTMLElement) {
const $el = $(el);
const bindingAdapter = $el.data("shiny-output-binding");
return bindingAdapter ? bindingAdapter.getId() : null;
if (!bindingAdapter) return null;
else return bindingAdapter.getId();
}
// Initialize all input objects in the document, before binding
@@ -281,246 +285,327 @@ class ShinyClass {
(x) => x.value,
);
function setInput(name: string, value: unknown, initial = false): void {
if (initial) {
initialValues[name] = value;
} else {
inputs.setInput(name, value);
}
}
// The server needs to know the size of each image and plot output element,
// in case it is auto-sizing
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
function () {
const id = getIdFromEl(this),
rect = getBoundingClientSizeBeforeZoom(this);
function doSendSize(el: HTMLElement, initial = false): void {
const id = getIdFromEl(el);
if (rect.width !== 0 || rect.height !== 0) {
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
}
},
);
if (!id) return;
const rect = getBoundingClientSizeBeforeZoom(el);
if (rect.width !== 0 || rect.height !== 0) {
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
}
}
function doTriggerResize(el: HTMLElement): void {
const $el = $(el),
binding = $el.data("shiny-output-binding");
if (!binding) return;
$el.trigger({
type: "shiny:visualchange",
// @ts-expect-error; Can not remove info on a established, malformed Event object
visible: isVisible(el),
binding: binding,
});
binding.onResize();
}
function doSendTheme(el: HTMLElement, initial = false): void {
if (el.classList.contains("shiny-output-error")) {
return;
function getComputedBgColor(
el: HTMLElement | null,
): string | null | undefined {
if (!el) {
// Top of document, can't recurse further
return null;
}
function getComputedBgColor(
el: HTMLElement | null,
): string | null | undefined {
if (!el) {
const bgColor = getStyle(el, "background-color");
if (!bgColor) return bgColor;
const m = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
);
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
// No background color on this element. See if it has a background image.
const bgImage = getStyle(el, "background-image");
if (bgImage && bgImage !== "none") {
// Failed to detect background color, since it has a background image
return null;
} else {
// Recurse
return getComputedBgColor(el.parentElement);
}
const bgColor = getStyle(el, "background-color");
if (!bgColor) return bgColor;
const m = bgColor.match(
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
);
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
const bgImage = getStyle(el, "background-image");
if (bgImage && bgImage !== "none") {
return null;
} else {
return getComputedBgColor(el.parentElement);
}
}
return bgColor;
}
function getComputedFont(el: HTMLElement): {
families: string[] | undefined;
size: string | undefined;
} {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize,
};
}
const id = getIdFromEl(el);
if (!id) return;
setInput(
".clientdata_output_" + id + "_bg",
getComputedBgColor(el),
initial,
);
setInput(
".clientdata_output_" + id + "_fg",
getStyle(el, "color"),
initial,
);
setInput(
".clientdata_output_" + id + "_accent",
getComputedLinkColor(el),
initial,
);
setInput(
".clientdata_output_" + id + "_font",
getComputedFont(el),
initial,
);
return bgColor;
}
const visibleOutputs = new Set<string>();
function getComputedFont(el: HTMLElement) {
const fontFamily = getStyle(el, "font-family");
const fontSize = getStyle(el, "font-size");
function doSendHiddenState(el: HTMLElement, initial = false): void {
const id = getIdFromEl(el);
if (!id) return;
const hidden = !isVisible(el);
if (hidden) {
visibleOutputs.delete(id);
} else {
visibleOutputs.add(id);
}
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
return {
families: fontFamily?.replace(/"/g, "").split(", "),
size: fontSize,
};
}
function reportsSize(el: HTMLElement): boolean {
return (
el.classList.contains("shiny-image-output") ||
el.classList.contains("shiny-plot-output") ||
el.classList.contains("shiny-report-size")
);
}
function reportsTheme(el: HTMLElement): boolean {
return (
el.classList.contains("shiny-image-output") ||
el.classList.contains("shiny-plot-output") ||
el.classList.contains("shiny-report-theme")
);
}
function 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(),
);
// Compatibility: window resize used to refresh theme-reporting output
// info.
$(window).resize(function () {
scheduleThemeInfoRefresh();
});
// 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);
}
}
function doSendOutputInfo(initial = false) {
const outputIds = new Set<string>();
$(".shiny-bound-output").each(function () {
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const el = this;
const id = getIdFromEl(el);
if (id) outputIds.add(id);
ensureObservers(el);
initialValues[".clientdata_output_" + id + "_bg"] =
getComputedBgColor(el);
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
el,
"color",
);
initialValues[".clientdata_output_" + id + "_accent"] =
getComputedLinkColor(el);
initialValues[".clientdata_output_" + id + "_font"] =
getComputedFont(el);
maybeAddThemeObserver(el);
},
);
refreshOutputInfo(el, initial);
});
// Resend computed styles if *an output element's* class or style attribute changes.
// This gives us some level of confidence that getCurrentOutputInfo() will be
// properly invalidated if output container is mutated; but unfortunately,
// we don't have a reasonable way to detect change in *inherited* styles
// (other than session$setCurrentTheme())
// https://github.com/rstudio/shiny/issues/3196
// https://github.com/rstudio/shiny/issues/2998
function maybeAddThemeObserver(el: HTMLElement): void {
if (!window.MutationObserver) {
return; // IE10 and lower
}
visibleOutputs.forEach((id) => {
if (!outputIds.has(id)) {
visibleOutputs.delete(id);
setInput(".clientdata_output_" + id + "_hidden", true, initial);
}
const cl = el.classList;
const reportTheme =
cl.contains("shiny-image-output") ||
cl.contains("shiny-plot-output") ||
cl.contains("shiny-report-theme");
if (!reportTheme) {
return;
}
const $el = $(el);
if ($el.data("shiny-theme-observer")) {
return; // i.e., observer is already observing
}
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
const observer = new MutationObserver(() =>
observerCallback.normalCall(),
);
const config = { attributes: true, attributeFilter: ["style", "class"] };
observer.observe(el, config);
$el.data("shiny-theme-observer", observer);
}
function doSendTheme(el: HTMLElement): void {
// Sending theme info on error isn't necessary (it'd add an unnecessary additional round-trip)
if (el.classList.contains("shiny-output-error")) {
return;
}
const id = getIdFromEl(el);
inputs.setInput(
".clientdata_output_" + id + "_bg",
getComputedBgColor(el),
);
inputs.setInput(
".clientdata_output_" + id + "_fg",
getStyle(el, "color"),
);
inputs.setInput(
".clientdata_output_" + id + "_accent",
getComputedLinkColor(el),
);
inputs.setInput(
".clientdata_output_" + id + "_font",
getComputedFont(el),
);
}
function doSendImageSize() {
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
function () {
const id = getIdFromEl(this),
rect = getBoundingClientSizeBeforeZoom(this);
if (rect.width !== 0 || rect.height !== 0) {
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
inputs.setInput(
".clientdata_output_" + id + "_height",
rect.height,
);
}
},
);
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
function () {
doSendTheme(this);
},
);
$(".shiny-bound-output").each(function () {
const $this = $(this),
binding = $this.data("shiny-output-binding");
$this.trigger({
type: "shiny:visualchange",
// @ts-expect-error; Can not remove info on a established, malformed Event object
visible: !isHidden(this),
binding: binding,
});
binding.onResize();
});
}
doSendOutputInfo(true);
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
registerThemeRefreshSignals();
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
// Return true if the object or one of its ancestors in the DOM tree has
// style='display:none'; otherwise return false.
function isHidden(obj: HTMLElement | null): boolean {
// null means we've hit the top of the tree. If width or height is
// non-zero, then we know that no ancestor has display:none.
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
return false;
} else if (getStyle(obj, "display") === "none") {
return true;
} else {
return isHidden(obj.parentNode as HTMLElement | null);
}
}
let lastKnownVisibleOutputs: { [key: string]: boolean } = {};
// Set initial state of outputs to hidden, if needed
$(".shiny-bound-output").each(function () {
const id = getIdFromEl(this);
if (isHidden(this)) {
initialValues[".clientdata_output_" + id + "_hidden"] = true;
} else {
lastKnownVisibleOutputs[id] = true;
initialValues[".clientdata_output_" + id + "_hidden"] = false;
}
});
// Send update when hidden state changes
function doSendOutputHiddenState() {
const visibleOutputs: { [key: string]: boolean } = {};
$(".shiny-bound-output").each(function () {
const id = getIdFromEl(this);
delete lastKnownVisibleOutputs[id];
// Assume that the object is hidden when width and height are 0
const hidden = isHidden(this),
evt = {
type: "shiny:visualchange",
visible: !hidden,
};
if (hidden) {
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
} else {
visibleOutputs[id] = true;
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
}
const $this = $(this);
// @ts-expect-error; Can not remove info on a established, malformed Event object
evt.binding = $this.data("shiny-output-binding");
// @ts-expect-error; Can not remove info on a established, malformed Event object
$this.trigger(evt);
});
// Anything left in lastKnownVisibleOutputs is orphaned
for (const name in lastKnownVisibleOutputs) {
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
}
// Update the visible outputs for next time
lastKnownVisibleOutputs = visibleOutputs;
}
// sendOutputHiddenState gets called each time DOM elements are shown or
// hidden. This can be in the hundreds or thousands of times at startup.
// We'll debounce it, so that we do the actual work once per tick.
const sendOutputHiddenStateDebouncer = new Debouncer(
null,
doSendOutputHiddenState,
0,
);
function sendOutputHiddenState() {
sendOutputHiddenStateDebouncer.normalCall();
}
// We need to make sure doSendOutputHiddenState actually gets called before
// the inputBatchSender sends data to the server. The lastChanceCallback
// here does that - if the debouncer has a pending call, flush it.
inputBatchSender.lastChanceCallback.push(function () {
if (sendOutputHiddenStateDebouncer.isPending())
sendOutputHiddenStateDebouncer.immediateCall();
});
// Given a namespace and a handler function, return a function that invokes
// the handler only when e's namespace matches. For example, if the
// namespace is "bs", it would match when e.namespace is "bs" or "bs.tab".
// If the namespace is "bs.tab", it would match for "bs.tab", but not "bs".
function filterEventsByNamespace(
namespace: string,
handler: (...handlerArgs: any[]) => void,
...args: any[]
) {
const namespaceArr = namespace.split(".");
return function (this: HTMLElement, e: JQuery.TriggeredEvent) {
const eventNamespace = e.namespace?.split(".") ?? [];
// If any of the namespace strings aren't present in this event, quit.
for (let i = 0; i < namespaceArr.length; i++) {
if (eventNamespace.indexOf(namespaceArr[i]) === -1) return;
}
handler.apply(this, [namespaceArr, handler, ...args]);
};
}
// The size of each image may change either because the browser window was
// resized, or because a tab was shown/hidden (hidden elements report size
// of 0x0). It's OK to over-report sizes because the input pipeline will
// filter out values that haven't changed.
$(window).resize(debounce(500, sendImageSizeFns.regular));
// Need to register callbacks for each Bootstrap 3 class.
const bs3classes = [
"modal",
"dropdown",
"tab",
"tooltip",
"popover",
"collapse",
];
$.each(bs3classes, function (idx, classname) {
$(document.body).on(
"shown.bs." + classname + ".sendImageSize",
"*",
filterEventsByNamespace("bs", sendImageSizeFns.regular),
);
$(document.body).on(
"shown.bs." +
classname +
".sendOutputHiddenState " +
"hidden.bs." +
classname +
".sendOutputHiddenState",
"*",
filterEventsByNamespace("bs", sendOutputHiddenState),
);
});
// This is needed for Bootstrap 2 compatibility and for non-Bootstrap
// related shown/hidden events (like conditionalPanel)
$(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
$(document.body).on(
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
"*",
sendOutputHiddenState,
);
// Send initial pixel ratio, and update it if it changes
initialValues[".clientdata_pixelratio"] = pixelRatio();

View File

@@ -7,7 +7,7 @@ import {
shinyInitializeInputs,
shinyUnbindAll,
} from "./initedMethods";
import { sendOutputInfoFns } from "./sendOutputInfo";
import { sendImageSizeFns } from "./sendImageSize";
import type { WherePosition } from "./singletons";
import { renderHtml as singletonsRenderHtml } from "./singletons";
@@ -267,7 +267,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
// should have been applied synchronously.
oldStyle.remove();
removeSheet(oldSheet);
sendOutputInfoFns.transitioned();
sendImageSizeFns.transitioned();
};
xhr.send();
};
@@ -327,7 +327,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
// base64-encoded and inlined into the href. We also add a dummy DOM
// element that the CSS applies to. The dummy CSS includes a
// transition, and when the `transitionend` event happens, we call
// sendOutputInfoFns.transitioned() and remove the old sheet. We also remove the
// sendImageSizeFns.transitioned() and remove the old sheet. We also remove the
// dummy DOM element and dummy CSS content.
//
// The reason this works is because (we assume) that if multiple
@@ -337,7 +337,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
//
// Because it is common for multiple stylesheets to arrive close
// together, but not on exactly the same tick, we call
// sendOutputInfoFns.transitioned(), which is debounced. Otherwise, it can result in
// sendImageSizeFns.transitioned(), which is debounced. Otherwise, it can result in
// the same plot being redrawn multiple times with different
// styling.
$link.attr("onload", () => {
@@ -350,7 +350,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
$dummyEl.one("transitionend", () => {
$dummyEl.remove();
removeSheet(oldSheet);
sendOutputInfoFns.transitioned();
sendImageSizeFns.transitioned();
});
$(document.body).append($dummyEl);

View File

@@ -0,0 +1,36 @@
import type { InputBatchSender } from "../inputPolicies";
import { debounce, Debouncer } from "../time";
class SendImageSize {
// This function gets defined in initShiny() and 'hoisted' so it can be reused
// (to send CSS info) inside of Shiny.renderDependencies()
regular!: () => void;
transitioned!: () => void;
setImageSend(
inputBatchSender: InputBatchSender,
doSendImageSize: () => void,
): Debouncer<typeof doSendImageSize> {
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
this.regular = function () {
sendImageSizeDebouncer.normalCall();
};
// Make sure sendImageSize actually gets called before the inputBatchSender
// sends data to the server.
inputBatchSender.lastChanceCallback.push(function () {
if (sendImageSizeDebouncer.isPending())
sendImageSizeDebouncer.immediateCall();
});
// A version of sendImageSize which debounces for longer.
this.transitioned = debounce(200, this.regular);
return sendImageSizeDebouncer;
}
}
const sendImageSizeFns = new SendImageSize();
export { sendImageSizeFns };

View File

@@ -1,77 +0,0 @@
import type { InputBatchSender } from "../inputPolicies";
import { debounce, Debouncer } from "../time";
type FlushableObserverCallback = (() => void) & {
cancel: () => void;
flush: () => void;
isPending: () => boolean;
};
class SendOutputInfo {
regular!: () => void;
transitioned!: () => void;
#pendingObserverCallbacks = new Set<FlushableObserverCallback>();
setSendMethod(
inputBatchSender: InputBatchSender,
doSendOutputInfo: () => void,
): Debouncer<typeof doSendOutputInfo> {
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
this.regular = function () {
sendOutputInfoDebouncer.normalCall();
};
inputBatchSender.lastChanceCallback.push(() => {
this.#pendingObserverCallbacks.forEach((callback) => callback.flush());
if (sendOutputInfoDebouncer.isPending())
sendOutputInfoDebouncer.immediateCall();
});
this.transitioned = debounce(200, this.regular);
return sendOutputInfoDebouncer;
}
createObserverCallback(
delayMs: number,
callback: () => void,
): FlushableObserverCallback {
const debouncer = new Debouncer(
null,
() => {
this.#pendingObserverCallbacks.delete(observerCallback);
callback();
},
delayMs,
);
const observerCallback: FlushableObserverCallback = Object.assign(
() => {
this.#pendingObserverCallbacks.add(observerCallback);
debouncer.normalCall();
},
{
cancel: () => {
this.#pendingObserverCallbacks.delete(observerCallback);
debouncer.cancel();
},
flush: () => {
this.#pendingObserverCallbacks.delete(observerCallback);
if (debouncer.isPending()) {
debouncer.immediateCall();
}
},
isPending: () => debouncer.isPending(),
},
);
return observerCallback;
}
}
const sendOutputInfoFns = new SendOutputInfo();
export { SendOutputInfo, sendOutputInfoFns };
export type { FlushableObserverCallback };

View File

@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { debounce } from "../debounce";
void test("debounce can cancel a pending callback before it fires", async () => {
let calls = 0;
const debounced = debounce(10, () => {
calls += 1;
});
debounced();
debounced.cancel();
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(calls, 0);
});

View File

@@ -39,10 +39,6 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
this.args = args;
this.$invoke();
}
cancel(): void {
this.$clearTimer();
this.args = null;
}
isPending(): boolean {
return this.timerId !== null;
}
@@ -74,21 +70,15 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
// 900ms intervals will result in a single execution
// of the underlying function, 1000ms after the 17th
// call.
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((
...args: Parameters<T>
) => void) & {
cancel: () => void;
};
function debounce<T extends (...args: unknown[]) => void>(
threshold: number | undefined,
func: T,
): DebouncedFunction<T> {
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
// Do not alter `function()` into an arrow function.
// The `this` context needs to be dynamically bound
const debounced = function thisFunc(...args: Parameters<T>) {
return function thisFunc(...args: Parameters<T>) {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
@@ -102,16 +92,6 @@ function debounce<T extends (...args: unknown[]) => void>(
func.apply(thisFunc, args);
}, threshold);
};
debounced.cancel = function () {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
};
return debounced;
}
export { debounce, Debouncer };
export type { DebouncedFunction };

View File

@@ -59,16 +59,6 @@ function getStyle(el: Element, styleProp: string): string | undefined {
return x;
}
function isVisible(el: HTMLElement): boolean {
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
return true;
}
if (getStyle(el, "display") === "none") {
return false;
}
return el.parentElement ? isVisible(el.parentElement) : true;
}
// Convert a number to a string with leading zeros
function padZeros(n: number, digits: number): string {
let str = n.toString();
@@ -431,7 +421,6 @@ export {
isBS3,
isnan,
isShinyInDevMode,
isVisible,
makeResizeFilter,
mapValues,
mergeSort,

View File

@@ -13,6 +13,7 @@ declare global {
on(events: EvtPrefix<"mousedown2">, handler: EvtFn<JQuery.MouseDownEvent>): this;
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
on(events: `shown.bs.${string}.sendImageSize`, selector: string, handler: (this: HTMLElement, e: JQuery.EventHandlerBase<HTMLElement, any>) => void): this;
}
}
export {};

View File

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

View File

@@ -0,0 +1,9 @@
import type { InputBatchSender } from "../inputPolicies";
import { Debouncer } from "../time";
declare class SendImageSize {
regular: () => void;
transitioned: () => void;
setImageSend(inputBatchSender: InputBatchSender, doSendImageSize: () => void): Debouncer<typeof doSendImageSize>;
}
declare const sendImageSizeFns: SendImageSize;
export { sendImageSizeFns };

View File

@@ -1,17 +0,0 @@
import type { InputBatchSender } from "../inputPolicies";
import { Debouncer } from "../time";
type FlushableObserverCallback = (() => void) & {
cancel: () => void;
flush: () => void;
isPending: () => boolean;
};
declare class SendOutputInfo {
#private;
regular: () => void;
transitioned: () => void;
setSendMethod(inputBatchSender: InputBatchSender, doSendOutputInfo: () => void): Debouncer<typeof doSendOutputInfo>;
createObserverCallback(delayMs: number, callback: () => void): FlushableObserverCallback;
}
declare const sendOutputInfoFns: SendOutputInfo;
export { SendOutputInfo, sendOutputInfoFns };
export type { FlushableObserverCallback };

View File

@@ -10,14 +10,9 @@ declare class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X>
constructor(target: InputPolicy | null, func: X, delayMs: number | undefined);
normalCall(...args: Parameters<X>): void;
immediateCall(...args: Parameters<X>): void;
cancel(): void;
isPending(): boolean;
$clearTimer(): void;
$invoke(): void;
}
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((...args: Parameters<T>) => void) & {
cancel: () => void;
};
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): DebouncedFunction<T>;
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): (...args: Parameters<T>) => void;
export { debounce, Debouncer };
export type { DebouncedFunction };

View File

@@ -5,7 +5,6 @@ declare function escapeHTML(str: string): string;
declare function randomId(): string;
declare function strToBool(str: string): boolean | undefined;
declare function getStyle(el: Element, styleProp: string): string | undefined;
declare function isVisible(el: HTMLElement): boolean;
declare function padZeros(n: number, digits: number): string;
declare function roundSignif(x: number, digits?: number): number;
declare function parseDate(dateString: string): Date;
@@ -35,4 +34,4 @@ declare function getComputedLinkColor(el: HTMLElement): string;
declare function isBS3(): boolean;
declare function toLowerCase<T extends string>(str: T): Lowercase<T>;
declare function isShinyInDevMode(): boolean;
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, isVisible, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };

View File

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