Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .claude/skills/shinyreact-build-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ an npm-tier build).
| **Input** | `useShinyInput(id, default)` → `[value, setValue]` | `useShinyInputValue(id)` | `useSetShinyInput(id, default)` |
| **Output** | — | `useShinyOutputValue(id, default?)` | — |
| **Status** | | `useShinyOutputStatus(id)` → `"pending" \| "ready" \| "recalculating" \| "error"` | |
| **Error** | | `useShinyOutputError(id)` → `{message, call, type} \| null` | |

Plus `useShinyInitialized()`, `useShinyBusy()`, `useShinyMessageHandler()`, and
the components `ImageOutput`, `ShinyOutput`, `ShinyModuleProvider`.
Expand Down Expand Up @@ -304,6 +305,20 @@ with `.recalculating { opacity: .6; transition: opacity 200ms }`.
`"pending"` is the only state where you have no data yet; `"recalculating"`
means the previous result is still valid, so show it.

**Showing the server's error text** — `useShinyOutputError(id)` returns the
same (sanitized) condition/exception message vanilla Shiny would paint into the
output element, or `null` when the output is fine:

```jsx
const error = useShinyOutputError("foo");
if (error) return <div className="shiny-output-error">{error.message}</div>;
```

`req()` / `validate()` with no message are silent — they never produce an
error here, matching Shiny. With `shiny.sanitize.errors` / `sanitize_errors`
on, the server sends its generic message, so the client never has to decide
what is safe to show.

**Gate the first paint** on `useShinyInitialized()` (`if (!initialized) return
null`) so the UI does not flash empty defaults during connection setup.

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/shinyreact-build-app/references/debugging.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Debugging a shinyreact app

The client is a normal React app, so React DevTools works. What is specific to
shinyreact is the wire, and these four hooks are the instruments — reach for
shinyreact is the wire, and these five hooks are the instruments — reach for
them before adding `console.log`:

| Hook | Tells you |
|---|---|
| `useShinyInitialized()` | whether the WebSocket handshake finished at all |
| `useShinyOutputStatus(id)` | `"pending"` / `"ready"` / `"recalculating"` / `"error"` for one output |
| `useShinyOutputError(id)` | the server's sanitized error message for one output, or `null` |
| `useShinyBusy()` | whether the server is processing *anything* right now |
| `useShinyInputValue(id)` | what a channel currently holds, read from any component |

Expand All @@ -26,4 +27,3 @@ Symptoms, in the order they actually come up:

On the server, `reactive_output` is an ordinary Shiny output — print inside it,
and its errors surface in the Shiny console exactly as usual.

2 changes: 1 addition & 1 deletion .claude/skills/shinyreact-build-app/references/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ the `useShinyMessageHandler("ping", ...)` inside the matching provider.
Every id-taking hook and component goes through one shared resolver, so the
behavior is identical for `useShinyInput`, `useShinyInputValue`,
`useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`,
`useShinyMessageHandler`, `ShinyOutput`, and `ImageOutput`.
`useShinyOutputError`, `useShinyMessageHandler`, `ShinyOutput`, and `ImageOutput`.

Each of them takes an optional `namespace`:

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Run `make help` to see all targets.
The JS output (`pkg-js/dist/shinyreact.js`) is a self-contained IIFE that bundles React 19 and vendored `@posit/shiny-react`, and installs the public API at `window.shinyreact`.

**Global API exposed at `window.shinyreact`:**
- `useShinyInput`, `useShinyInputValue`, `useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`, `useShinyMessageHandler`, `useShinyInitialized`, `useShinyBusy` — re-exported shiny-react hooks
- `useShinyInput`, `useShinyInputValue`, `useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`, `useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`, `useShinyBusy` — re-exported shiny-react hooks
- `ImageOutput`, `ShinyModuleProvider`, `ShinyReactComponentElement`, `ShinyOutput`, `MISSING` — components/utilities
- `React`, `ReactDOM` — shared instances (downstream ESM builds should externalize to these to avoid duplicate React)

Expand Down Expand Up @@ -204,6 +204,7 @@ The hook surface follows the Jotai/Recoil cadence — each hook has one responsi
| **Input** | `useShinyInput(id, default)` → `[value, setValue]` | `useShinyInputValue(id)` → `value` | `useSetShinyInput(id, default)` → `setValue` |
| **Output** | — (no compound) | `useShinyOutputValue(id, default?)` → `value` | — |
| **Output status** | | `useShinyOutputStatus(id)` → `"pending" \| "ready" \| "recalculating" \| "error"` | |
| **Output error** | | `useShinyOutputError(id)` → `{message, call, type} \| null` | |

Pick the narrowest hook that fits the call site. A button that pushes events but never reads its own state should use `useSetShinyInput`, not `useShinyInput` with a discarded `[value]`. A display card that just reads should use `useShinyInputValue` / `useShinyOutputValue`. Narrow hooks make data-flow direction visible at the call site, prevent accidental writes from read-only components, and avoid spurious re-renders from subscribing to channels you don't observe.

Expand Down
27 changes: 24 additions & 3 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
- `null` is deliberately not conflated with `undefined` — a `??` would
silently fall through to the context, so the check is `!== undefined`
- applies to `useShinyInput`, `useShinyInputValue`, `useSetShinyInput`,
`useShinyOutputValue`, `useShinyOutputStatus`, `useShinyMessageHandler`,
`ShinyOutput`, and `ImageOutput`
`useShinyOutputValue`, `useShinyOutputStatus`, `useShinyOutputError`,
`useShinyMessageHandler`, `ShinyOutput`, and `ImageOutput`
- `[js]` the prefix is joined with a single hyphen: `${namespace}-${id}`
- an empty-string namespace yields the bare id, same as `null`
- `[js]` nesting `ShinyModuleProvider`s **overrides** rather than concatenates —
Expand Down Expand Up @@ -328,6 +328,20 @@ registers a second `shiny.reactOutput` binding.
- `useShinyOutputStatus(id, options?)` → status
- exactly four values: `"pending"`, `"ready"`, `"recalculating"`, `"error"`
- it starts at `"pending"` and subscribes to the status channel only
- `useShinyOutputError(id, options?)` → `{message, call, type}` or `null`
- it starts at `null` and subscribes to the error channel only — value and
status changes do not re-render it
- `message` is the server's already-sanitized condition/exception text, the
same text vanilla Shiny paints into the output element; sanitization is
shiny's (`shiny.sanitize.errors` / `sanitize_errors`), not shinyreact's
- `[r]` `call` / `type` carry the condition's call and extra classes;
`[py]` both are `null`
- it returns `null` whenever the output is not in the `"error"` state:
initially, after a value arrives, and while recalculating
- the held error **is** reset to `null` when the id or namespace changes
- a late-mounting subscriber is synced to the cached error on attach
- an erroring `reactive_output` delivers its message end to end, and the
message clears when the output recovers (e2e)
- `useShinyMessageHandler(id, handler, options?)`
- the effect re-runs only when the resolved id changes or Shiny flips to
initialized, never on handler identity
Expand Down Expand Up @@ -465,6 +479,12 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on
- `setRecalculating(false)` → back to `"ready"` only from `"recalculating"`;
`"pending"` and `"error"` are left alone
- `setError` → `"error"`, fanning the error out
- `setError` with an **empty message** is a *silent* error (`req()`, and
`validate()` with no message) and is handled as `setValue(null)` instead:
status `"ready"`, error left `null`
- only `[r]` sends this shape — py-shiny already sends a `null` value for
`req()` — so the two servers look identical to the same component (e2e,
Python side)
- a value arriving in the `"error"` state clears the error and returns to
`"ready"`
- entering `"recalculating"` from `"error"` clears the error, so status and
Expand Down Expand Up @@ -1170,7 +1190,8 @@ initial page.
- a missing `#shinyreact-config` tag is a hard error, opted into at import
- `window.shinyreact` contains exactly: `useShinyInput`, `useShinyInputValue`,
`useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`,
`useShinyMessageHandler`, `useShinyInitialized`, `useShinyBusy`,
`useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`,
`useShinyBusy`,
`ImageOutput`, `MISSING`, `ShinyModuleProvider`,
`ShinyReactComponentElement`, `ShinyOutput`, `React`, `ReactDOM`
- `React` / `ReactDOM` are exposed so downstream ESM builds can externalize
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The bundle re-exports these hooks from `@posit/shiny-react`:
| `useSetShinyInput(id, default, opts)` | Write-only producer — registers an input and returns just the setter |
| `useShinyOutputValue(id, default?)` | Consume arbitrary data sent by `reactive_output` |
| `useShinyOutputStatus(id)` | Output lifecycle status — `"pending" \| "ready" \| "recalculating" \| "error"` |
| `useShinyOutputError(id)` | The server's sanitized error message for an output, or `null` |
| `useShinyMessageHandler(type, fn)` | Handle server-to-client custom messages |
| `useShinyInitialized()` | Check whether Shiny is connected |
| `useShinyBusy()` | Whether the Shiny server is currently processing a request |
Expand Down
18 changes: 9 additions & 9 deletions pkg-js/dist/shinyreact.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pkg-js/src/__tests__/global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe("installGlobal", () => {
"useShinyInput",
"useShinyInputValue",
"useShinyMessageHandler",
"useShinyOutputError",
"useShinyOutputStatus",
"useShinyOutputValue",
]);
Expand Down
3 changes: 3 additions & 0 deletions pkg-js/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useShinyBusy,
useShinyInput,
useShinyInputValue,
useShinyOutputError,
useShinyOutputStatus,
useShinyOutputValue,
useShinyMessageHandler,
Expand All @@ -31,6 +32,7 @@ declare global {
useShinyBusy: typeof useShinyBusy;
useShinyInput: typeof useShinyInput;
useShinyInputValue: typeof useShinyInputValue;
useShinyOutputError: typeof useShinyOutputError;
useShinyOutputStatus: typeof useShinyOutputStatus;
useShinyOutputValue: typeof useShinyOutputValue;
useShinyMessageHandler: typeof useShinyMessageHandler;
Expand Down Expand Up @@ -58,6 +60,7 @@ export function installGlobal(): void {
useShinyBusy,
useShinyInput,
useShinyInputValue,
useShinyOutputError,
useShinyOutputStatus,
useShinyOutputValue,
useShinyMessageHandler,
Expand Down
21 changes: 21 additions & 0 deletions pkg-js/src/shiny-react/__tests__/output-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,27 @@ describe("OutputRegistryEntry status lifecycle", () => {
expect(entry.getLastError()).toBeNull();
});

it("an empty-message error is a silent error, delivered as a null value", () => {
// #257. `req()` in R arrives as an error with message "" (vanilla Shiny
// blanks the output); py-shiny sends a null value for the same `req()`.
// Both servers must look the same to the React component.
const entry = new OutputRegistryEntry("test");
entry.setValue("first");
const setStatus = vi.fn();
const setError = vi.fn();
const setValue = vi.fn();
entry.addUseStateSetStatusFn(setStatus);
entry.addUseStateSetErrorFn(setError);
entry.addUseStateSetValueFn(setValue);

entry.setError({ message: "", call: [], type: ["shiny.silent.error"] });

expect(entry.getStatus()).toBe("ready");
expect(entry.getLastError()).toBeNull();
expect(setError).not.toHaveBeenCalled();
expect(setValue).toHaveBeenCalledWith(null);
});

it("isEmpty considers status and error subscribers", () => {
const entry = new OutputRegistryEntry("test");
expect(entry.isEmpty()).toBe(true);
Expand Down
124 changes: 124 additions & 0 deletions pkg-js/src/shiny-react/__tests__/use-shiny-output-error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ErrorsMessageValue } from "../output-registry";
import {
_resetReactRegistryForTesting,
getReactRegistry,
} from "../react-registry";
import {
_resetShinyReactInitializedForTesting,
useShinyOutputError,
} from "../use-shiny";

// Same stand-in as use-shiny-output-value.test.tsx: resolve initializedPromise
// immediately so the hook's effect runs, and let the real OutputRegistry play
// the other half of the interaction under test.
vi.mock("../get-shiny", () => ({
getShiny: () => ({
initializedPromise: Promise.resolve(),
outputBindings: { register: () => {} },
OutputBinding: class {},
addCustomMessageHandler: () => {},
bindAll: () => {},
unbindAll: () => {},
}),
}));

beforeEach(() => {
_resetReactRegistryForTesting();
_resetShinyReactInitializedForTesting();
});

afterEach(() => {
document
.querySelectorAll(".shiny-react-output-container")
.forEach((el) => el.remove());
});

async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}

const boom: ErrorsMessageValue = {
message: "invalid number of 'breaks'",
call: ["hist(x, breaks = n)"],
type: undefined,
};

describe("useShinyOutputError", () => {
it("starts at null and delivers the server's error message", async () => {
const registry = getReactRegistry();
const { result } = renderHook(() => useShinyOutputError("out"));
await flush();
expect(result.current).toBeNull();

act(() => registry.outputs.get("out")!.setError(boom));
await flush();

expect(result.current).toEqual(boom);
});

it("syncs a late-mounting subscriber with the cached error", async () => {
const registry = getReactRegistry();
registry.outputs.add(
"out",
() => {},
() => {},
() => {},
);
registry.outputs.get("out")!.setError(boom);

const { result } = renderHook(() => useShinyOutputError("out"));
await flush();

expect(result.current).toEqual(boom);
});

it("clears when a value arrives", async () => {
const registry = getReactRegistry();
const { result } = renderHook(() => useShinyOutputError("out"));
await flush();

act(() => registry.outputs.get("out")!.setError(boom));
await flush();
act(() => registry.outputs.get("out")!.setValue("recovered"));
await flush();

expect(result.current).toBeNull();
});

it("stays null for a silent error", async () => {
const registry = getReactRegistry();
const { result } = renderHook(() => useShinyOutputError("out"));
await flush();

act(() =>
registry.outputs
.get("out")!
.setError({ message: "", call: [], type: ["shiny.silent.error"] }),
);
await flush();

expect(result.current).toBeNull();
});

it("drops the previous id's error when the id changes", async () => {
const registry = getReactRegistry();
const { result, rerender } = renderHook(
({ id }: { id: string }) => useShinyOutputError(id),
{ initialProps: { id: "first" } },
);
await flush();

act(() => registry.outputs.get("first")!.setError(boom));
await flush();
expect(result.current).toEqual(boom);

rerender({ id: "second" });
await flush();

expect(result.current).toBeNull();
});
});
1 change: 1 addition & 0 deletions pkg-js/src/shiny-react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
useShinyInput,
useShinyInputValue,
useShinyMessageHandler,
useShinyOutputError,
useShinyOutputStatus,
useShinyOutputValue,
} from "./use-shiny";
Expand Down
14 changes: 12 additions & 2 deletions pkg-js/src/shiny-react/output-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { getShiny } from "./get-shiny";

export type ErrorsMessageValue = {
message: string;
call: string[];
type?: string[];
// `call` / `type` are R-only extras; py-shiny sends null for both.
call: string[] | null;
type?: string[] | null;
};

export type OutputStatus = "pending" | "ready" | "recalculating" | "error";
Expand Down Expand Up @@ -124,6 +125,15 @@ export class OutputRegistryEntry<T> {
}

setError(err: ErrorsMessageValue) {
// An empty message is how Shiny signals a *silent* error (`req()`): vanilla
// Shiny blanks the output element rather than showing error text. Only R
// takes this path — py-shiny sends a `null` value for `req()` — so mapping
// it to a `null` value keeps the two servers saying the same thing to the
// same React component.
if (err.message === "") {
this.setValue(null as T);
return;
}
this.lastError = err;
this.useStateSetErrorFns.forEach((fn) => fn(err));
this.setStatus("error");
Expand Down
Loading
Loading