diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index c1f95b87fa..1aae11f63d 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { @@ -705,6 +706,21 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps the host reveal hook on the deprecated client/react-native entry", () => { + const plugin = evaluatePluginClientBundle( + "legacy-reveal", + bundle(` + const legacy = require("@getpaseo/plugin/client/react-native"); + const ui = require("@getpaseo/plugin/client/ui"); + if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook"); + if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports"); + if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI"); + plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText }); + `), + ); + expect(plugin.surfaces[0]?.Component).toBe(useRevealedText); + }); + it("keeps shared and client runtime exports separate", () => { expect(() => evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 44bcbec57d..ea37209f57 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -2,6 +2,7 @@ import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { useSettings } from "./settings/use-settings"; import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginClientRuntime from "@getpaseo/plugin/client"; @@ -114,6 +115,10 @@ export type PluginClientRuntime = Pick< | "addHeaderButton" > & { hosts: ReturnType }; +// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 +// The 0.12 entry exposed the reveal hook as well as UI components. +const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText }; + export function runPluginClientBundle( id: string, bundle: string, @@ -503,7 +508,7 @@ export function runPluginClientBundle( }; const runtimeRequire = (name: string): unknown => { // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 - if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime; + if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime; if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index e2547154bc..c9b54ecdb7 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -101,12 +101,29 @@ describe("plugin SDK import boundaries", () => { ); }); - it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => { - const source = readFileSync( - path.resolve(sourceDirectory, "..", manifest.exports[entry].source), - "utf8", - ); - expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";'); + it.each(compatibilityEntries)("%s respects the client boundary", (entry) => { + expect( + boundaryViolations( + path.resolve(sourceDirectory, "..", manifest.exports[entry].source), + "client", + ), + ).toEqual([]); + }); + + it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => { + expect( + typeErrors(` + import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native"; + import { StreamedText } from "@getpaseo/plugin/client/ui"; + // @ts-expect-error the legacy hook is not part of the current UI API + import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui"; + import { Text } from "react-native"; + function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) { + const revealed: string = useRevealedText(text, phase); + return {revealed}; + } + `), + ).toEqual([]); }); it.each(Object.entries(entries))( diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts index e1eb92f17b..13e8f88370 100644 --- a/packages/plugin/src/client/react-native.ts +++ b/packages/plugin/src/client/react-native.ts @@ -1,3 +1,6 @@ // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 /** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */ export * from "./ui.js"; + +/** @deprecated Render StreamedText from @getpaseo/plugin/client/ui. Removed after 2027-04-20 with this entry. */ +export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index f430dff92a..fdefda5971 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -176,8 +176,9 @@ its registration; that registration belongs in the client entry. ### UI imports in v0.13 -Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an -undocumented compatibility alias until 2027-04-20. +Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated +`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins +until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`. For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with `` from `/client/ui`.