Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,19 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on
values, so nothing sets it
- `onRecalculating` is called with whether the output status is
`"recalculating"`
- the `<img>` `alt` is the renderer's `alt` (`renderPlot(alt =)` /
`@render.plot(alt=)`), or `""` when the value carries none (#327)
- an output error replaces the image with a div holding the message (#327)
- the last image is **not** kept on screen
- classes match Shiny's `renderError()`: `shiny-output-error`, one
`shiny-output-error-<type>` per error type (an R one-class `type` arrives
as a plain string and is accepted), then `className`
- `width` / `height` still apply, so the layout does not jump
- the error div is measured like the placeholder, so a resize while the
error shows still sends the new size to the server
- the image comes back once the error clears and a value arrives
- silent errors (`req()`) carry no message and are not errors here: they
arrive as a `null` value, so the placeholder shows
- two `ImageOutput`s sharing an id both receive the same image, sized for only
one of them

Expand Down
4 changes: 4 additions & 0 deletions pkg-js/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
IDs were found" warning grew and the console logged `Output <id> not found`
(#330).

- `ImageOutput` hides the last image and shows the error message when the
render function errors, with Shiny's `shiny-output-error` classes. Its
`<img>` now uses the renderer's `alt` text instead of `alt=""` (#327).

- `useShinyOutputValue()` is now typed `T | null | undefined`. A server
`NULL` / `None` arrives as `null`, which the old `T | undefined` type hid.
Passing a `defaultValue` narrows it to `T | null` (#329).
Expand Down
18 changes: 9 additions & 9 deletions pkg-js/dist/shinyreact.js

Large diffs are not rendered by default.

38 changes: 35 additions & 3 deletions pkg-js/src/shiny-react/ImageOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { MISSING, type MISSING as MISSINGType } from "./missing";
import {
useShinyInput,
useShinyOutputError,
useShinyOutputStatus,
useShinyOutputValue,
} from "./use-shiny";
Expand All @@ -12,6 +13,8 @@ export type ImageData = {
src: string;
width: number;
height: number;
/** `renderPlot(alt =)` / `@render.plot(alt=)`; absent for some renderers. */
alt?: string;
coordmap: {
panels: {
panel: number;
Expand Down Expand Up @@ -89,6 +92,8 @@ export type ImageData = {
* - Updates Shiny when the image size changes (using ResizeObserver with
* debouncing)
* - Hides the image when Shiny sets the hidden state
* - Replaces the image with the error message when the render function
* errors, styled with Shiny's `shiny-output-error` classes
* - Handles image load events to ensure accurate dimension reporting
*
* The server-side renderImage() function receives the client dimensions and can
Expand Down Expand Up @@ -182,6 +187,7 @@ export function ImageOutput({
skipNs,
);
const imgStatus = useShinyOutputStatus(namespacedId, skipNs);
const imgError = useShinyOutputError(namespacedId, skipNs);

// Create a reference to the img element to access its properties
const imgRef = useRef<HTMLImageElement>(null);
Expand Down Expand Up @@ -266,8 +272,11 @@ export function ImageOutput({
// <img> only renders once imgData exists, which only exists once the
// server has rendered, which only happens once dimensions arrive — a
// chicken-and-egg loop).
//
// The error message div takes the placeholder's place (and ref) while an
// error shows, so a resize during an error still reaches the server.
useEffect(() => {
if (imgData) return;
if (imgData && !imgError) return;
const placeholder = placeholderRef.current;
if (!placeholder) return;

Expand All @@ -291,12 +300,35 @@ export function ImageOutput({
resizeObserver.disconnect();
debounced.cancel();
};
}, [imgData, setImgWidth, setImgHeight, debounceMs]);
}, [imgData, imgError, setImgWidth, setImgHeight, debounceMs]);

if (imgHidden) {
return null;
}

// Match vanilla Shiny: an error hides the last image and shows the message,
// with the same classes Shiny's renderError() adds.
if (imgError) {
const errorClasses = [
"shiny-output-error",
// concat, like Shiny's asArray(): R unboxes a one-class `type` to a
// plain string despite the string[] type.
...([] as string[])
.concat(imgError.type ?? [])
.map((t) => `shiny-output-error-${t}`),
className,
];
return (
<div
ref={placeholderRef}
className={errorClasses.filter(Boolean).join(" ")}
style={{ width: width, height: height }}
>
{imgError.message}
</div>
);
}

// Show a placeholder while waiting for the first image or during recalculation
if (!imgData) {
return (
Expand Down Expand Up @@ -339,7 +371,7 @@ export function ImageOutput({
<img
ref={imgRef}
src={imgData.src}
alt=""
alt={imgData.alt ?? ""}
className={className}
style={{
width: width,
Expand Down
127 changes: 124 additions & 3 deletions pkg-js/src/shiny-react/__tests__/ImageOutput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,23 @@ import { __resetLifecycleStoreForTests } from "../lifecycle-store";
import { ShinyModuleProvider } from "../ShinyModuleContext";
import { getReactRegistry } from "../react-registry";

// Elements watched by a live (not yet disconnected) ResizeObserver.
const observed = new Set<Element>();

beforeAll(() => {
// ImageOutput attaches a ResizeObserver. jsdom doesn't ship one — stub it
// so the component can mount without throwing.
// so the component can mount, and record what it watches.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
class {
observe() {}
private els: Element[] = [];
observe(el: Element) {
this.els.push(el);
observed.add(el);
}
unobserve() {}
disconnect() {}
disconnect() {
this.els.forEach((el) => observed.delete(el));
}
};
});

Expand Down Expand Up @@ -133,3 +142,115 @@ describe("ImageOutput namespace precedence", () => {
expect(ids).not.toContain(".clientdata_output_ctx-myimg_width");
});
});

// Drive an output the way the real registry would: `outputs.add(id, setValue,
// setStatus, setError)` hands the hook its setters, so keep the latest ones per
// id and call them.
function captureOutputSetters() {
const setters = new Map<
string,
{ value: (v: unknown) => void; error: (e: unknown) => void }[]
>();
const reg = getReactRegistry();
(reg.outputs.add as ReturnType<typeof vi.fn>).mockImplementation(
(
id: string,
value: (v: unknown) => void,
_status: unknown,
error: (e: unknown) => void,
) => {
setters.set(id, [...(setters.get(id) ?? []), { value, error }]);
return () => {};
},
);
return {
sendValue: (id: string, v: unknown) =>
act(() => setters.get(id)!.forEach((s) => s.value(v))),
sendError: (id: string, e: unknown) =>
act(() => setters.get(id)!.forEach((s) => s.error(e))),
};
}

const IMG = { src: "data:image/png;base64,AAAA", width: 4, height: 3 };

describe("ImageOutput image data", () => {
it("uses the renderer's alt text", async () => {
// #327: alt="" was hardcoded, ignoring renderPlot(alt =).
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" />);
await flushPromises();
out.sendValue("plot", { ...IMG, alt: "A scatterplot" });
expect(container.querySelector("img")!.getAttribute("alt")).toBe(
"A scatterplot",
);
});

it("falls back to an empty alt when the renderer sends none", async () => {
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" />);
await flushPromises();
out.sendValue("plot", IMG);
expect(container.querySelector("img")!.getAttribute("alt")).toBe("");
});
});

describe("ImageOutput on error", () => {
it("hides the stale image and shows the message with Shiny's classes", async () => {
// #327: the last image stayed on screen and the message appeared nowhere.
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" className="mine" />);
await flushPromises();
out.sendValue("plot", IMG);
expect(container.querySelector("img")).not.toBeNull();

out.sendError("plot", {
message: "Nothing to plot",
call: null,
type: ["validation", "my-error"],
});

expect(container.querySelector("img")).toBeNull();
const el = container.firstElementChild!;
expect(el.textContent).toBe("Nothing to plot");
expect([...el.classList]).toEqual([
"shiny-output-error",
"shiny-output-error-validation",
"shiny-output-error-my-error",
"mine",
]);
});

it("accepts an R one-class type unboxed to a string", async () => {
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" />);
await flushPromises();
out.sendError("plot", { message: "boom", call: null, type: "validation" });
expect(container.firstElementChild!.className).toBe(
"shiny-output-error shiny-output-error-validation",
);
});

it("keeps measuring its size while the error shows", async () => {
// Without this, a resize during an error never reaches the server.
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" />);
await flushPromises();
out.sendValue("plot", IMG);
out.sendError("plot", { message: "boom", call: null, type: null });

expect(observed.has(container.firstElementChild!)).toBe(true);
});

it("shows the image again once the error clears", async () => {
const out = captureOutputSetters();
const { container } = render(<ImageOutput id="plot" />);
await flushPromises();
out.sendError("plot", { message: "boom", call: null, type: null });
expect(container.querySelector("img")).toBeNull();

out.sendError("plot", null);
out.sendValue("plot", IMG);
expect(container.querySelector("img")).not.toBeNull();
expect(container.textContent).not.toContain("boom");
});
});
Loading
Loading