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
8 changes: 7 additions & 1 deletion .claude/skills/shinyreact-build-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ an npm-tier build).
| **Status** | | `useShinyOutputStatus(id)` → `"pending" \| "ready" \| "recalculating" \| "error"` | |
| **Error** | | `useShinyOutputError(id)` → `{message, call, type} \| null` | |

Plus `useShinyInitialized()`, `useShinyBusy()`, `useShinyMessageHandler()`, and
Plus `useShinyInitialized()`, `useShinyConnected()`, `useShinyBusy()`,
`useShinyMessageHandler()`, and
the components `ImageOutput`, `ShinyOutput`, `ShinyModuleProvider`.

**Pick the narrowest hook that fits the call site.** A button that pushes and
Expand Down Expand Up @@ -328,6 +329,11 @@ 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.

**Lost connection** — `useShinyInitialized()` stays `true` after the session
disconnects. To react to a dropped session (a "reconnecting…" banner, disabled
controls), use `useShinyConnected()`: `false` once the socket closes, `true`
again if Shiny reconnects.

**App-wide activity** — `useShinyBusy()` is a boolean that tracks Shiny's
`shiny:busy` / `shiny:idle` events, i.e. whether the server is working on
*anything*:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ them before adding `console.log`:
| Hook | Tells you |
|---|---|
| `useShinyInitialized()` | whether the WebSocket handshake finished at all |
| `useShinyConnected()` | whether the WebSocket is open *now* (`false` after a disconnect) |
| `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 |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,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`, `useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`, `useShinyBusy` — re-exported shiny-react hooks
- `useShinyInput`, `useShinyInputValue`, `useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`, `useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`, `useShinyConnected`, `useShinyBusy` — re-exported shiny-react hooks
- `ImageOutput`, `ShinyModuleProvider`, `ShinyOutput`, `MISSING` — components/utilities
- `ShinyReactComponentElement` (custom-element base class in `src/shiny-react/`) is deliberately **not** exported — it hosts React islands in server-rendered HTML, the inverse of the ui.tsx pattern. Re-export it when partial React app support lands
- `React`, `ReactDOM` — shared instances (downstream ESM builds should externalize to these to avoid duplicate React)
Expand Down
36 changes: 30 additions & 6 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,17 +366,38 @@ registers a second `shiny.reactOutput` binding.
- it seeds `true` when `<html>` already carries `.shiny-busy` at first mount,
so a consumer mounting mid-request is not stale
- it stops responding after unmount
- both lifecycle hooks read one shared external store through
- `useShinyConnected()` → boolean (#328)
- `false` until `shiny:connected`, `false` again on `shiny:disconnected`,
`true` again on the next `shiny:connected` (Shiny's reconnect)
- it seeds from `window.Shiny.shinyapp.isConnected()`, so a consumer
mounting after the socket opened reads `true`
- `useShinyInitialized()` does **not** flip back on disconnect; this hook is
the one that does
- all three lifecycle hooks read one shared external store through
`useSyncExternalStore`
- one pair of DOM listeners per page regardless of consumer count
- Shiny fires its lifecycle events with jQuery's `trigger()`, which skips
`addEventListener()` listeners, so the store listens through
`window.jQuery(document).on()` when jQuery is on the page, and falls back
to `addEventListener()` when it is not (#328)
- before #328 `useShinyBusy()` never updated in a real browser; only the
jsdom tests' native events reached it
- the jQuery check happens once, at first subscribe
- if `window.Shiny` is present but `window.jQuery` is not, it logs a
`console.warn`: native listeners would never hear Shiny's events
- a second jQuery loaded after Shiny's replaces `window.jQuery`, and
Shiny's `trigger()` does not reach handlers bound through it (not
handled)
- (e2e) `useShinyBusy()` follows a slow output; `useShinyConnected()`
turns `false` when the server closes the session
- one set of event listeners per page regardless of consumer count
- listeners are installed lazily on first subscribe and **never** torn down —
they belong to the page, which sidesteps mount/unmount races and
StrictMode re-subscribe churn
- installation is skipped when there is no `document`, and not latched, so a
later DOM-available render still installs them
- the `shiny:connected` listener is `{once: true}`, so if it fires while
`window.Shiny` is still absent, `initialized` never flips and every hook
stays gated for the life of the page
- the `shiny:connected` wait for a late `window.Shiny` is one-shot, so if it
fires while `window.Shiny` is still absent, `initialized` never flips and
every hook stays gated for the life of the page
- the store is only for page-global runtime state; per-id hooks must keep
using their own id-keyed registries

Expand Down Expand Up @@ -604,6 +625,9 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on
actually readable, then unsubscribes — it is deliberately **not**
`{once: true}`, which would consume the event before Shiny existed and
leave discovery uninstalled
- it listens through jQuery like the lifecycle store; before #328 its native
listener never heard Shiny's `shiny:connected`, so a Shiny that loaded
after the bundle left discovery uninstalled
- it no-ops when there is no `document`
- `getShiny()` returns `undefined` rather than throwing when there is no
`window` at all, not just when Shiny has not loaded
Expand Down Expand Up @@ -1415,7 +1439,7 @@ initial page.
- `window.shinyreact` contains exactly: `useShinyInput`, `useShinyInputValue`,
`useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`,
`useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`,
`useShinyBusy`,
`useShinyBusy`, `useShinyConnected`,
`ImageOutput`, `MISSING`, `ShinyModuleProvider`, `ShinyOutput`, `React`,
`ReactDOM`
- `ShinyReactComponentElement` is deliberately absent: not exported from the
Expand Down
11 changes: 11 additions & 0 deletions pkg-js/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
IDs were found" warning grew and the console logged `Output <id> not found`
(#330).

- New `useShinyConnected()` hook: `false` once the session disconnects, `true`
again if Shiny reconnects (#328).

- `useShinyBusy()` now updates in a real browser. Shiny fires `shiny:busy` /
`shiny:idle` through jQuery, which never reached the hook's
`addEventListener()` listeners (#328).

- Renderer dependency discovery now installs when Shiny loads after the
bundle. It waited for `shiny:connected` with a native listener, which the
jQuery-fired event never reached (#328).

- `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).
Expand Down
2 changes: 1 addition & 1 deletion pkg-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ The `"./styles"` export is only needed for components that ship CSS (`ImageOutpu
| **Inputs** | `useShinyInput` · `useShinyInputValue` · `useSetShinyInput` |
| **Outputs** | `useShinyOutputValue` · `useShinyOutputStatus` · `useShinyOutputError` |
| **Messaging** | `useShinyMessageHandler` |
| **Session** | `useShinyInitialized` · `useShinyBusy` |
| **Session** | `useShinyInitialized` · `useShinyConnected` · `useShinyBusy` |
| **Components** | `ShinyOutput` · `ImageOutput` · `ShinyModuleProvider` |
| **Utilities** | `MISSING` · `PROTOCOL_VERSION` |

Expand Down
20 changes: 10 additions & 10 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 @@ -20,6 +20,7 @@ describe("installGlobal", () => {
"ShinyOutput",
"useSetShinyInput",
"useShinyBusy",
"useShinyConnected",
"useShinyInitialized",
"useShinyInput",
"useShinyInputValue",
Expand Down
11 changes: 6 additions & 5 deletions pkg-js/src/dep-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getShiny } from "./shiny-react/get-shiny";
import { onShinyEvent } from "./shiny-react/shiny-events";

/**
* Client half of automatic renderer dependency discovery (issues #146/#203;
Expand Down Expand Up @@ -62,11 +63,11 @@ export function installDepDiscovery(): void {
// Wait for Shiny. NOT `{ once: true }`: `shiny:connected` can fire before
// `window.Shiny` is readable, and a one-shot listener would consume the
// event, leave `start()` a no-op, and never install discovery at all. Stay
// subscribed until Shiny is actually there, then unsubscribe.
const onConnected = (): void => {
// subscribed until Shiny is actually there, then unsubscribe. Shiny fires
// this through jQuery, so a plain addEventListener() would never see it.
const stop = onShinyEvent("shiny:connected", () => {
if (!getShiny()) return;
document.removeEventListener("shiny:connected", onConnected);
stop();
start();
};
document.addEventListener("shiny:connected", onConnected);
});
}
3 changes: 3 additions & 0 deletions pkg-js/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ShinyOutput } from "./shiny-output";
import {
useSetShinyInput,
useShinyBusy,
useShinyConnected,
useShinyInput,
useShinyInputValue,
useShinyOutputError,
Expand All @@ -31,6 +32,7 @@ declare global {
shinyreact: {
useSetShinyInput: typeof useSetShinyInput;
useShinyBusy: typeof useShinyBusy;
useShinyConnected: typeof useShinyConnected;
useShinyInput: typeof useShinyInput;
useShinyInputValue: typeof useShinyInputValue;
useShinyOutputError: typeof useShinyOutputError;
Expand Down Expand Up @@ -59,6 +61,7 @@ export function installGlobal(): void {
window.shinyreact = {
useSetShinyInput,
useShinyBusy,
useShinyConnected,
useShinyInput,
useShinyInputValue,
useShinyOutputError,
Expand Down
3 changes: 2 additions & 1 deletion pkg-js/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @groupDescription Messaging
* Receive `send_message()` payloads from the server.
* @groupDescription Session
* Observe the Shiny session itself — connected yet, busy right now.
* Observe the Shiny session itself — initialized yet, connected, busy right now.
* @groupDescription Components
* Render traditional Shiny outputs inside a React tree.
* @groupDescription Utilities
Expand Down Expand Up @@ -70,6 +70,7 @@ installDepDiscovery();
export {
useSetShinyInput,
useShinyBusy,
useShinyConnected,
useShinyInput,
useShinyInputValue,
useShinyOutputError,
Expand Down
131 changes: 131 additions & 0 deletions pkg-js/src/shiny-react/__tests__/use-shiny-connected.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

let mockShiny: any = undefined;
vi.mock("../get-shiny", () => ({
getShiny: () => mockShiny,
}));

import { __resetLifecycleStoreForTests } from "../lifecycle-store";
import { useShinyBusy, useShinyConnected } from "../use-shiny";

// A stand-in for the page's jQuery with the one property that matters here:
// `trigger()` calls jQuery handlers only. Real jQuery does the same for custom
// events, which is why Shiny's `$(document).trigger("shiny:disconnected")`
// never reaches an `addEventListener()` listener (#328).
function installFakeJQuery() {
const handlers = new Map<string, Set<() => void>>();
const $doc = {
on(type: string, fn: () => void) {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type)!.add(fn);
},
off(type: string, fn: () => void) {
handlers.get(type)?.delete(fn);
},
};
(window as any).jQuery = () => $doc;
return {
trigger: (type: string) =>
act(() => handlers.get(type)?.forEach((fn) => fn())),
count: (type: string) => handlers.get(type)?.size ?? 0,
};
}

beforeEach(() => {
mockShiny = undefined;
__resetLifecycleStoreForTests();
});

afterEach(() => {
__resetLifecycleStoreForTests();
delete (window as any).jQuery;
});

describe("useShinyConnected", () => {
it("is false before the socket opens", () => {
const { result } = renderHook(() => useShinyConnected());
expect(result.current).toBe(false);
});

it("follows connect, disconnect, and reconnect", () => {
const { result } = renderHook(() => useShinyConnected());

act(() => {
document.dispatchEvent(new Event("shiny:connected"));
});
expect(result.current).toBe(true);

act(() => {
document.dispatchEvent(new Event("shiny:disconnected"));
});
expect(result.current).toBe(false);

act(() => {
document.dispatchEvent(new Event("shiny:connected"));
});
expect(result.current).toBe(true);
});

it("seeds true when the socket is already open at first mount", () => {
mockShiny = {
initializedPromise: new Promise(() => {}),
shinyapp: { isConnected: () => true },
};
const { result } = renderHook(() => useShinyConnected());
expect(result.current).toBe(true);
});
});

describe("lifecycle events fired through jQuery (#328)", () => {
it("useShinyConnected sees a jQuery-only shiny:disconnected", () => {
const $ = installFakeJQuery();
const { result } = renderHook(() => useShinyConnected());

$.trigger("shiny:connected");
expect(result.current).toBe(true);
$.trigger("shiny:disconnected");
expect(result.current).toBe(false);
});

it("useShinyBusy sees jQuery-only shiny:busy / shiny:idle", () => {
const $ = installFakeJQuery();
const { result } = renderHook(() => useShinyBusy());

$.trigger("shiny:busy");
expect(result.current).toBe(true);
$.trigger("shiny:idle");
expect(result.current).toBe(false);
});

it("detaches its jQuery handlers on reset", () => {
const $ = installFakeJQuery();
renderHook(() => useShinyConnected());
expect($.count("shiny:disconnected")).toBe(1);

__resetLifecycleStoreForTests();
expect($.count("shiny:disconnected")).toBe(0);
});
});

describe("Shiny present without jQuery", () => {
it("warns, since native listeners never hear Shiny's events", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mockShiny = { initializedPromise: new Promise(() => {}) };
renderHook(() => useShinyConnected());
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("window.jQuery is not"),
);
warn.mockRestore();
});

it("stays quiet when jQuery is there", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
installFakeJQuery();
mockShiny = { initializedPromise: new Promise(() => {}) };
renderHook(() => useShinyConnected());
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
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 @@ -17,6 +17,7 @@ export type { OutputStatus, ErrorsMessageValue } from "./output-registry";
export {
useSetShinyInput,
useShinyBusy,
useShinyConnected,
useShinyInitialized,
useShinyInput,
useShinyInputValue,
Expand Down
Loading
Loading