diff --git a/FEATURES.md b/FEATURES.md index 434ec6a..0499cc8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -322,6 +322,9 @@ registers a second `shiny.reactOutput` binding. - `useShinyOutputValue(id, defaultValue?, options?)` → `value` - `defaultValue` is returned until the server delivers the first value, and defaults to `undefined` + - a server `NULL` / `None` arrives as `null`, and the return type is + `T | null | undefined` so TypeScript makes callers handle it (#329) + - passing a `defaultValue` narrows it to `T | null` (an overload) - it subscribes to the value channel only — status and error changes do not re-render it - the held value **is** reset when the id or namespace changes, so the @@ -1381,8 +1384,11 @@ initial page. - it exports `PROTOCOL_VERSION` - it does **not** re-export React / ReactDOM - the stylesheet ships as `dist-npm/style.css`, reachable as - `@posit-dev/shinyreact/styles` (#229; the build emitted no CSS at all before, - leaving `ImageOutput`'s spinner without its `@keyframes spin`) + `@posit-dev/shinyreact/styles.css` and `@posit-dev/shinyreact/styles` + - the `.css` name lets TypeScript match a `*.css` module declaration + (e.g. `vite/client`) with no hand-written `declare module` (#329) + - before #229 the build emitted no CSS at all, leaving `ImageOutput`'s + spinner without its `@keyframes spin` - Vite does not inject a CSS import into a lib-mode ESM bundle, so consumers opt in rather than having it forced on them - a missing `#shinyreact-config` tag is tolerated, as in the IIFE bundle, so diff --git a/pkg-js/CHANGELOG.md b/pkg-js/CHANGELOG.md index b88d48f..caf8357 100644 --- a/pkg-js/CHANGELOG.md +++ b/pkg-js/CHANGELOG.md @@ -8,6 +8,13 @@ IDs were found" warning grew and the console logged `Output not found` (#330). +- `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). + +- The stylesheet is also exported as `@posit-dev/shinyreact/styles.css`, so + TypeScript resolves the import with no `declare module` (#329). + ## 0.1.1 - First release published through CI with npm trusted publishing diff --git a/pkg-js/README.md b/pkg-js/README.md index d39ad16..e6dc1f8 100644 --- a/pkg-js/README.md +++ b/pkg-js/README.md @@ -43,7 +43,7 @@ Skip that and the app still works — the registries are page-scoped, so both co ```jsx import { useShinyInput, useShinyOutputValue } from "@posit-dev/shinyreact"; -import "@posit-dev/shinyreact/styles"; +import "@posit-dev/shinyreact/styles.css"; import { createRoot } from "react-dom/client"; function App() { diff --git a/pkg-js/package.json b/pkg-js/package.json index 6c46e44..8114f7a 100644 --- a/pkg-js/package.json +++ b/pkg-js/package.json @@ -19,7 +19,8 @@ "types": "./dist-npm/types/npm.d.ts", "import": "./dist-npm/index.js" }, - "./styles": "./dist-npm/style.css" + "./styles": "./dist-npm/style.css", + "./styles.css": "./dist-npm/style.css" }, "scripts": { "build": "npm run build:iife && npm run build:npm", diff --git a/pkg-js/src/shiny-react/__tests__/use-shiny-output-value.test.tsx b/pkg-js/src/shiny-react/__tests__/use-shiny-output-value.test.tsx index 24f0d5c..4e3e137 100644 --- a/pkg-js/src/shiny-react/__tests__/use-shiny-output-value.test.tsx +++ b/pkg-js/src/shiny-react/__tests__/use-shiny-output-value.test.tsx @@ -1,6 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + it, + vi, +} from "vitest"; import { _resetReactRegistryForTesting, getReactRegistry, @@ -104,3 +112,39 @@ describe("useShinyOutputValue when the id changes", () => { expect(result.current).toBe("second value"); }); }); + +describe("useShinyOutputValue when the server sends NULL", () => { + it("returns null, and its type says so", async () => { + // #329. A server `NULL` / `None` arrives as `null`. The return type used to + // be `T | undefined`, so `x !== undefined` narrowed to `T` and `x.n` + // crashed at runtime. + const registry = getReactRegistry(); + const { result } = renderHook(() => + useShinyOutputValue<{ n: number }>("x"), + ); + await flush(); + + registry.outputs.get("x")!.setValue(null); + await flush(); + + expect(result.current).toBeNull(); + expectTypeOf(result.current).toEqualTypeOf< + { n: number } | null | undefined + >(); + }); +}); + +describe("useShinyOutputValue return type", () => { + it("drops undefined when a defaultValue is passed", () => { + // Type-only: a default means the value is never `undefined`, but a server + // NULL can still arrive as `null`. + expectTypeOf( + renderHook(() => useShinyOutputValue("x", 0)).result.current, + ).toEqualTypeOf(); + expectTypeOf( + renderHook(() => + useShinyOutputValue("x", undefined, { namespace: null }), + ).result.current, + ).toEqualTypeOf(); + }); +}); diff --git a/pkg-js/src/shiny-react/use-shiny.ts b/pkg-js/src/shiny-react/use-shiny.ts index feb2dc0..d68dac7 100644 --- a/pkg-js/src/shiny-react/use-shiny.ts +++ b/pkg-js/src/shiny-react/use-shiny.ts @@ -215,9 +215,27 @@ const NOOP_SETTER = () => {}; * @param options Optional configuration object. * @param options.namespace Module namespace to apply (or `null` to suppress * the surrounding `ShinyModuleProvider` namespace). - * @returns The current output value. + * @returns The current output value. `null` when the server sent `NULL` / + * `None` (or a silent `req()` error), `undefined` (or `defaultValue`) before + * the first value arrives. Passing a `defaultValue` drops `undefined` from the + * return type. * @group Outputs */ +export function useShinyOutputValue( + outputId: string, + defaultValue?: undefined, + options?: { namespace?: string | null }, +): T | null | undefined; +/** + * With a `defaultValue`, the value is never `undefined`: it is `defaultValue` + * until the server answers, then the server's value (`null` for `NULL` / + * `None`). + */ +export function useShinyOutputValue( + outputId: string, + defaultValue: T, + options?: { namespace?: string | null }, +): T | null; export function useShinyOutputValue( outputId: string, defaultValue: T | undefined = undefined, @@ -226,8 +244,8 @@ export function useShinyOutputValue( }: { namespace?: string | null; } = {}, -): T | undefined { - const [value, setValue] = useState(defaultValue); +): T | null | undefined { + const [value, setValue] = useState(defaultValue); const shinyInitialized = useShinyInitialized(); ensureShinyReactInitialized();