From 176f9fa6fc70208e09855e9404e31d053d7dcb67 Mon Sep 17 00:00:00 2001 From: Rieranthony Date: Thu, 2 Jul 2026 17:21:41 +0200 Subject: [PATCH] Fix 85 SDK audit findings across react, next, and browser packages Full multi-agent audit of @cossistant/react, @cossistant/next, and @cossistant/browser targeting bugs, bundle footprint, and DX. The audit record (114 findings with evidence, verification verdicts, and the fix map) lives in audit/. Bugs: - Visitor identification never ran: IdentifySupportVisitor latched before the visitor loaded; now waits, retries, and re-identifies on payload changes - Failed sends lost the visitor's message; text/attachments/draft are restored and the error is surfaced - Provider re-pushed defaultOpen/size defaults on every effect re-run, force-closing the widget and defeating persisted open state; the controller also rebuilt on every render for inline support objects - Data hooks double-fetched on mount, leaked pagination cursors across conversation switches, and let fetch failures escape as unhandled rejections; missed events now resync after WebSocket reconnect - RealtimeProvider died permanently under StrictMode; reconnect backoff leaked duplicate sockets - Feedback failures were silent; onClick on triggers replaced internal toggles; Escape wiped typed feedback - asChild slot clobbered child handlers/styles, Enter submitted mid-IME composition, focus trap hijacked Tab document-wide, plus dialog/ radiogroup a11y and scroll anchoring fixes - SSR: localStorage reads moved out of render, hydration-stable timestamps and default messages Footprint: - zod v4 + @hono/zod-openapi excised from all consumer bundles by moving runtime helpers to zod-free @cossistant/types/support-onboarding (widget.js: 191KB -> 128.6KB gzip) - @floating-ui/react replaced with @floating-ui/react-dom; ulid removed - Library dist ships unminified with sourcemaps for consumer treeshaking DX: - pub:* scripts published a broken tarball (npm ignores publishConfig.directory); all packages now publish ./dist - CDN embed: documented snippet threw TypeError; new one-tag install via data-public-key, throw-resilient queue replay, React moved to peers - @cossistant/next now mirrors all ~41 react subpaths (was 12) and its d.ts no longer breaks skipLibCheck:false - "use client" on every entry, prop types exported, Button collision resolved via SupportButton, docs snippets verified to compile, new CDN embed docs page 301 react / 137 core / 32 types / 18 browser / 3 next tests pass (+65 new regression tests), tsc and biome clean. Co-Authored-By: Claude Fable 5 --- apps/web/content/docs/quickstart/api-keys.mdx | 23 +- apps/web/content/docs/quickstart/browser.mdx | 163 +++ apps/web/content/docs/quickstart/meta.json | 2 +- apps/web/content/docs/quickstart/react.mdx | 23 +- .../content/docs/support-component/hooks.mdx | 6 +- apps/web/content/docs/user-feedback/index.mdx | 12 +- apps/web/src/lib/support-integration-guide.ts | 15 +- audit/findings.md | 1023 +++++++++++++++++ audit/progress.md | 42 + audit/task_plan.md | 88 ++ bun.lock | 16 +- packages/browser/README.md | 69 +- packages/browser/package.json | 20 +- .../browser/scripts/rewrite-dist-types.ts | 100 ++ packages/browser/src/embed/auto-init.ts | 38 + .../browser/src/embed/loader-runtime.test.ts | 59 + packages/browser/src/embed/loader-runtime.ts | 23 +- .../browser/src/embed/widget-runtime.test.ts | 119 +- packages/browser/src/embed/widget-runtime.ts | 80 +- .../browser/src/mount-support-widget.test.ts | 25 + packages/browser/src/mount-support-widget.ts | 3 +- packages/browser/tsdown.config.ts | 6 +- packages/core/src/client.ts | 2 +- packages/core/src/realtime-client.test.ts | 75 ++ packages/core/src/realtime-client.ts | 12 +- .../core/src/store/support-state-store.ts | 10 +- packages/core/src/store/support-store.test.ts | 57 + packages/core/src/store/support-store.ts | 61 +- packages/core/src/support-config.test.ts | 2 +- packages/core/src/support-controller.test.ts | 85 ++ packages/core/src/support-controller.ts | 28 + packages/next/README.md | 2 +- packages/next/package.json | 38 +- packages/next/scripts/rewrite-dist-types.ts | 73 ++ .../next/src/hooks/use-create-conversation.ts | 3 + packages/next/src/hooks/use-feedback-form.ts | 3 + packages/next/src/hooks/use-file-upload.ts | 3 + packages/next/src/hooks/use-send-message.ts | 3 + .../next/src/hooks/use-submit-feedback.ts | 3 + packages/next/src/index.exports.test.ts | 77 ++ packages/next/src/internal/hooks.ts | 3 + packages/next/src/primitives/button.tsx | 3 + .../src/primitives/command-block-utils.ts | 3 + .../src/primitives/conversation-timeline.tsx | 3 + .../next/src/primitives/day-separator.tsx | 3 + .../src/primitives/feedback-comment-input.tsx | 3 + .../primitives/feedback-rating-selector.tsx | 3 + .../src/primitives/feedback-topic-select.tsx | 3 + .../next/src/primitives/multimodal-input.tsx | 3 + packages/next/src/primitives/router.tsx | 3 + .../src/primitives/timeline-code-block.tsx | 3 + .../src/primitives/timeline-command-block.tsx | 3 + .../primitives/timeline-item-attachments.tsx | 3 + .../src/primitives/timeline-item-group.tsx | 3 + .../next/src/primitives/timeline-item.tsx | 3 + .../src/primitives/timeline-message-layout.ts | 3 + .../src/primitives/timeline-read-receipts.tsx | 3 + .../next/src/primitives/tool-activity-row.tsx | 3 + packages/next/src/primitives/trigger.tsx | 3 + packages/next/src/primitives/window.tsx | 3 + packages/next/src/utils/conversation.ts | 3 + packages/next/src/utils/id.ts | 3 + packages/next/src/utils/merge-refs.ts | 3 + .../next/src/utils/use-render-element.tsx | 3 + packages/next/tsconfig.json | 4 +- packages/react/README.md | 9 +- packages/react/package.json | 15 +- .../react/src/feedback/components/content.tsx | 2 +- .../react/src/feedback/components/panel.tsx | 84 +- packages/react/src/feedback/index.test.tsx | 33 +- packages/react/src/feedback/index.tsx | 11 +- .../src/feedback/internal/trigger.test.tsx | 159 +++ .../react/src/feedback/internal/trigger.tsx | 19 +- .../src/feedback/internal/window.test.tsx | 197 ++++ .../react/src/feedback/internal/window.tsx | 22 +- .../hooks/private/use-client-query.test.tsx | 281 +++++ .../src/hooks/private/use-client-query.ts | 39 +- .../private/use-default-messages.test.ts | 10 + .../src/hooks/private/use-default-messages.ts | 21 +- .../hooks/use-conversation-lifecycle.test.tsx | 183 +++ .../src/hooks/use-conversation-lifecycle.ts | 33 +- .../react/src/hooks/use-conversation-page.ts | 62 +- .../src/hooks/use-create-conversation.ts | 2 + .../src/hooks/use-feedback-form.test.tsx | 101 +- packages/react/src/hooks/use-feedback-form.ts | 47 +- .../src/hooks/use-message-composer.test.tsx | 221 ++++ .../react/src/hooks/use-message-composer.ts | 9 +- packages/react/src/hooks/use-send-message.ts | 2 + .../react/src/hooks/use-sound-effect.test.tsx | 223 ++++ packages/react/src/hooks/use-sound-effect.ts | 18 + .../src/hooks/use-transition-swap.test.tsx | 151 +++ .../react/src/hooks/use-transition-swap.ts | 3 + packages/react/src/identify-visitor.tsx | 64 +- packages/react/src/internal/hooks.ts | 2 + .../react/src/primitives/avatar/avatar.tsx | 2 + .../react/src/primitives/avatar/fallback.tsx | 10 +- .../react/src/primitives/avatar/image.tsx | 2 + .../src/primitives/avatar/index.parts.ts | 2 + packages/react/src/primitives/avatar/index.ts | 2 + packages/react/src/primitives/button.tsx | 2 + .../primitives/command-block-utils.test.ts | 24 + .../src/primitives/command-block-utils.ts | 9 +- .../conversation-timeline-internal.test.ts | 45 + .../conversation-timeline-internal.ts | 25 + .../src/primitives/conversation-timeline.tsx | 29 + .../src/primitives/day-separator.test.tsx | 40 + .../react/src/primitives/day-separator.tsx | 44 +- .../src/primitives/feedback-comment-input.tsx | 2 + .../src/primitives/feedback-controls.test.tsx | 52 +- .../primitives/feedback-rating-selector.tsx | 52 +- .../src/primitives/feedback-topic-select.tsx | 37 +- packages/react/src/primitives/index.parts.ts | 40 +- packages/react/src/primitives/index.ts | 2 + .../src/primitives/multimodal-input.test.tsx | 170 +++ .../react/src/primitives/multimodal-input.tsx | 7 +- packages/react/src/primitives/router.tsx | 2 + .../src/primitives/timeline-code-block.tsx | 2 + .../src/primitives/timeline-command-block.tsx | 6 +- .../src/primitives/timeline-item-group.tsx | 2 + .../src/primitives/timeline-item.test.ts | 68 +- .../react/src/primitives/timeline-item.tsx | 14 +- .../src/primitives/tool-activity-row.tsx | 2 + .../react/src/primitives/trigger.test.tsx | 69 ++ packages/react/src/primitives/trigger.tsx | 19 +- packages/react/src/primitives/window.test.tsx | 52 + packages/react/src/primitives/window.tsx | 34 +- .../provider.controller-regression.test.ts | 283 +++++ packages/react/src/provider.tsx | 366 +++++- .../src/realtime/provider.lifecycle.test.tsx | 388 +++++++ packages/react/src/realtime/provider.tsx | 64 +- packages/react/src/realtime/use-realtime.ts | 4 +- .../react/src/support/components/button.tsx | 2 +- .../react/src/support/components/content.tsx | 2 +- .../react/src/support/components/header.tsx | 11 +- .../src/support/components/image-lightbox.tsx | 10 +- .../src/support/components/theme-wrapper.tsx | 9 +- .../components/timeline-message-item.tsx | 3 +- .../timeline-search-knowledge-tool.test.tsx | 380 +++--- .../timeline-search-knowledge-tool.tsx | 23 +- .../react/src/support/components/trigger.tsx | 7 + .../src/support/context/websocket.test.tsx | 282 +++++ .../react/src/support/context/websocket.tsx | 76 +- packages/react/src/support/index.tsx | 10 +- packages/react/src/support/support.css | 7 +- packages/react/src/support/text/index.test.ts | 58 +- packages/react/src/support/text/index.tsx | 37 + .../react/src/support/text/locales/en.tsx | 18 + .../react/src/support/text/locales/es.tsx | 21 + .../react/src/support/text/locales/fr.tsx | 21 + .../react/src/support/text/locales/keys.ts | 23 + packages/react/src/support/text/runtime.ts | 20 +- packages/react/src/support/utils/time.test.ts | 52 + packages/react/src/support/utils/time.ts | 46 +- packages/react/src/utils/index.ts | 2 + packages/react/src/utils/merge-refs.ts | 2 + .../react/src/utils/use-render-element.tsx | 55 +- packages/react/tsdown.config.ts | 9 +- packages/types/package.json | 3 +- packages/types/src/api/support.ts | 96 +- packages/types/src/support-onboarding.test.ts | 48 + packages/types/src/support-onboarding.ts | 94 ++ 161 files changed, 7382 insertions(+), 609 deletions(-) create mode 100644 apps/web/content/docs/quickstart/browser.mdx create mode 100644 audit/findings.md create mode 100644 audit/progress.md create mode 100644 audit/task_plan.md create mode 100644 packages/browser/scripts/rewrite-dist-types.ts create mode 100644 packages/browser/src/embed/auto-init.ts create mode 100644 packages/next/scripts/rewrite-dist-types.ts create mode 100644 packages/next/src/hooks/use-create-conversation.ts create mode 100644 packages/next/src/hooks/use-feedback-form.ts create mode 100644 packages/next/src/hooks/use-file-upload.ts create mode 100644 packages/next/src/hooks/use-send-message.ts create mode 100644 packages/next/src/hooks/use-submit-feedback.ts create mode 100644 packages/next/src/index.exports.test.ts create mode 100644 packages/next/src/internal/hooks.ts create mode 100644 packages/next/src/primitives/button.tsx create mode 100644 packages/next/src/primitives/command-block-utils.ts create mode 100644 packages/next/src/primitives/conversation-timeline.tsx create mode 100644 packages/next/src/primitives/day-separator.tsx create mode 100644 packages/next/src/primitives/feedback-comment-input.tsx create mode 100644 packages/next/src/primitives/feedback-rating-selector.tsx create mode 100644 packages/next/src/primitives/feedback-topic-select.tsx create mode 100644 packages/next/src/primitives/multimodal-input.tsx create mode 100644 packages/next/src/primitives/router.tsx create mode 100644 packages/next/src/primitives/timeline-code-block.tsx create mode 100644 packages/next/src/primitives/timeline-command-block.tsx create mode 100644 packages/next/src/primitives/timeline-item-attachments.tsx create mode 100644 packages/next/src/primitives/timeline-item-group.tsx create mode 100644 packages/next/src/primitives/timeline-item.tsx create mode 100644 packages/next/src/primitives/timeline-message-layout.ts create mode 100644 packages/next/src/primitives/timeline-read-receipts.tsx create mode 100644 packages/next/src/primitives/tool-activity-row.tsx create mode 100644 packages/next/src/primitives/trigger.tsx create mode 100644 packages/next/src/primitives/window.tsx create mode 100644 packages/next/src/utils/conversation.ts create mode 100644 packages/next/src/utils/id.ts create mode 100644 packages/next/src/utils/merge-refs.ts create mode 100644 packages/next/src/utils/use-render-element.tsx create mode 100644 packages/react/src/feedback/internal/trigger.test.tsx create mode 100644 packages/react/src/feedback/internal/window.test.tsx create mode 100644 packages/react/src/hooks/private/use-client-query.test.tsx create mode 100644 packages/react/src/hooks/use-conversation-lifecycle.test.tsx create mode 100644 packages/react/src/hooks/use-message-composer.test.tsx create mode 100644 packages/react/src/hooks/use-sound-effect.test.tsx create mode 100644 packages/react/src/hooks/use-transition-swap.test.tsx create mode 100644 packages/react/src/primitives/day-separator.test.tsx create mode 100644 packages/react/src/primitives/multimodal-input.test.tsx create mode 100644 packages/react/src/primitives/window.test.tsx create mode 100644 packages/react/src/realtime/provider.lifecycle.test.tsx create mode 100644 packages/react/src/support/context/websocket.test.tsx create mode 100644 packages/react/src/support/utils/time.test.ts create mode 100644 packages/types/src/support-onboarding.test.ts create mode 100644 packages/types/src/support-onboarding.ts diff --git a/apps/web/content/docs/quickstart/api-keys.mdx b/apps/web/content/docs/quickstart/api-keys.mdx index 133a90c2..9454f33b 100644 --- a/apps/web/content/docs/quickstart/api-keys.mdx +++ b/apps/web/content/docs/quickstart/api-keys.mdx @@ -31,10 +31,25 @@ NEXT_PUBLIC_COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx +For frameworks without a public env variable convention (CRA, Remix, etc.), +pass the key directly via the `publicKey` prop: + +```tsx + +``` + +The generic env variable only works when your bundler or server inlines +`process.env.COSSISTANT_API_KEY` into browser code (for example a custom +webpack `DefinePlugin`, or SSR that renders the provider with the key): + ```bash title=".env" COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx ``` +CRA only exposes `REACT_APP_*` variables and Remix does not expose +`process.env` to the client at all, so on those frameworks prefer the +`publicKey` prop. + @@ -42,10 +57,10 @@ COSSISTANT_API_KEY=pk_live_xxxxxxxxxxxx Auto-detection - The SDK automatically detects your framework and checks - `VITE_COSSISTANT_API_KEY` (Vite), `NEXT_PUBLIC_COSSISTANT_API_KEY` - (Next.js), or `COSSISTANT_API_KEY` (other). You can also pass the key - explicitly through `publicKey`. + The SDK automatically reads `VITE_COSSISTANT_API_KEY` (Vite), + `NEXT_PUBLIC_COSSISTANT_API_KEY` (Next.js), or `COSSISTANT_API_KEY` + (only when your bundler or server inlines `process.env` into client code). + You can also pass the key explicitly through `publicKey`. diff --git a/apps/web/content/docs/quickstart/browser.mdx b/apps/web/content/docs/quickstart/browser.mdx new file mode 100644 index 00000000..508b2f3a --- /dev/null +++ b/apps/web/content/docs/quickstart/browser.mdx @@ -0,0 +1,163 @@ +--- +title: Script Tag +description: Embed the Cossistant support widget on any website with a single script tag. +--- + +Use the CDN embed when your site is not built with React — plain HTML, +WordPress, Webflow, Rails, PHP, or any other stack. The loader downloads the +widget bundle, mounts it into a shadow root, and keeps your page styles +untouched. + +## 1. Add the script tag + +Paste one tag anywhere in `` or `` with your public key: + +```html + +``` + +That is the whole install. The loader reads `data-public-key` from its own +script tag and initializes the widget automatically once the bundle loads. The +widget waits for the DOM to be ready before mounting, so placement does not +matter. + +Get your public key from the dashboard under **Settings → Developers** — see +[API Keys](/docs/quickstart/api-keys). Only use public keys (`pk_live_*`, +`pk_test_*`) in browser code. + +## 2. Pin a version (optional) + +`latest/` always serves the newest release. To pin an exact version, put it in +the URL — the loader derives `widget.js` and `widget.css` from its own URL, so +both forms work the same way: + +```html + +``` + +Self-hosted deployments can also point the widget at their own API: + +```html + +``` + +## The `window.Cossistant` API + +Once the widget runtime has loaded, `window.Cossistant` exposes: + +| Method | Description | +| ----------------------- | ------------------------------------------------------------------------------ | +| `init(options?)` | Mount the widget. Not needed when `data-public-key` is set on the script tag. | +| `show()` | Open the widget window. | +| `hide()` | Close the widget window. | +| `toggle()` | Toggle the widget window. | +| `identify(params)` | Link the visitor to a contact (`externalId`, `email`, `name`, `image`, `metadata`). | +| `updateConfig(options)` | Update config at runtime (`open`, `defaultMessages`, `quickOptions`, `size`, `theme`, `widget`). | +| `on(type, handler)` | Subscribe to an event. Returns an unsubscribe function. | +| `off(type, handler)` | Remove an event handler. | +| `destroy()` | Unmount the widget and remove it from the page. | + +Events for `on`/`off`: `conversationStart`, `conversationEnd`, `messageSent`, +`messageReceived`, and `error`. + +`init()` accepts: `publicKey`, `apiUrl`, `wsUrl`, `autoConnect`, `container`, +`defaultMessages`, `defaultOpen`, `quickOptions`, `size` (`"normal"` or +`"larger"`), `theme`, `host`, and `widget`. + +```js +window.Cossistant.identify({ + externalId: "user_123", + email: "jane@acme.com", + name: "Jane Doe", +}); + +window.Cossistant.on("messageReceived", function (event) { + console.log("New message in", event.conversationId); +}); +``` + +The loader tag is `async`, so inline scripts usually run before +`window.Cossistant` exists — install the queue stub below before calling any +of these methods from inline code. + +## Calling the API before the widget loads + +`window.Cossistant` only exists once `loader.js` executes, and the script tag +is `async`. Inline scripts that call widget methods earlier must install a +small command queue first; every queued call is replayed in order (after any +`data-public-key` auto-init) once the widget runtime loads: + +```html + + + +``` + +With `data-public-key` on the loader tag you never need to call `init()` +yourself — the queue stub is only required when calling widget methods before +the bundle has loaded. + +## Theming + +The widget renders inside a shadow root, so your page CSS cannot leak in. +Set `--co-theme-*` variables through `init()` or `updateConfig()` instead: + +```js +window.Cossistant.updateConfig({ + theme: { + mode: "auto", // "auto" | "light" | "dark" + variables: { + "--co-theme-primary": "#111827", + "--co-theme-background": "#ffffff", + "--co-theme-radius": "8px", + }, + }, +}); +``` + +With `mode: "auto"` (the default) the widget follows your page's color scheme: +it checks the `dark` class, `data-color-scheme`, and `data-theme` attributes on +``, the computed `color-scheme`, and the OS `prefers-color-scheme`, and +reacts to changes live. The full variable list is in +[Match Your Brand](/docs/support-component/theme). + +## Next steps + +1. [API Keys](/docs/quickstart/api-keys) to allowlist your production domains. +2. [Match Your Brand](/docs/support-component/theme) for the full theming reference. +3. Using React? Prefer the [React quickstart](/docs/quickstart/react) or [Next.js quickstart](/docs/quickstart) for the native SDK. diff --git a/apps/web/content/docs/quickstart/meta.json b/apps/web/content/docs/quickstart/meta.json index 22ad05b0..3767a9fe 100644 --- a/apps/web/content/docs/quickstart/meta.json +++ b/apps/web/content/docs/quickstart/meta.json @@ -1,4 +1,4 @@ { - "pages": ["index", "react", "api-keys"], + "pages": ["index", "react", "browser", "api-keys"], "title": "Quickstart" } diff --git a/apps/web/content/docs/quickstart/react.mdx b/apps/web/content/docs/quickstart/react.mdx index 4cc71a49..2b0bc989 100644 --- a/apps/web/content/docs/quickstart/react.mdx +++ b/apps/web/content/docs/quickstart/react.mdx @@ -86,18 +86,20 @@ NEXT_PUBLIC_COSSISTANT_API_KEY=pk_test_xxxx -For other frameworks (CRA, Remix, etc.), either set the env variable: - -```bash title=".env" -COSSISTANT_API_KEY=pk_test_xxxx -``` - -Or pass the key directly via the `publicKey` prop: +For other frameworks (CRA, Remix, etc.), pass the key directly via the +`publicKey` prop: ```tsx ``` +The generic `COSSISTANT_API_KEY` env variable only works when your bundler or +server inlines `process.env.COSSISTANT_API_KEY` into browser code (for example +a custom webpack `DefinePlugin`, or SSR that renders the provider with the key). +CRA only exposes `REACT_APP_*` variables and Remix does not expose `process.env` +to the client at all, so on those frameworks the env variable never reaches the +widget — use the `publicKey` prop instead. + @@ -105,9 +107,10 @@ Or pass the key directly via the `publicKey` prop: Auto-detection - The SDK automatically detects your framework and reads the right - environment variable (`VITE_COSSISTANT_API_KEY`, `NEXT_PUBLIC_COSSISTANT_API_KEY`, - or `COSSISTANT_API_KEY`). You can also pass the key explicitly through `publicKey`. + The SDK automatically reads `VITE_COSSISTANT_API_KEY` (Vite), + `NEXT_PUBLIC_COSSISTANT_API_KEY` (Next.js), or `COSSISTANT_API_KEY` + (only when your bundler or server inlines `process.env` into client code). + You can also pass the key explicitly through `publicKey`. diff --git a/apps/web/content/docs/support-component/hooks.mdx b/apps/web/content/docs/support-component/hooks.mdx index ab4804d9..7d9309f3 100644 --- a/apps/web/content/docs/support-component/hooks.mdx +++ b/apps/web/content/docs/support-component/hooks.mdx @@ -472,7 +472,11 @@ app. import { CossistantClient } from "@cossistant/core"; import { useFileUpload } from "@cossistant/react/hooks/use-file-upload"; -const client = new CossistantClient({ publicKey: "pk_test_xxxx" }); +const client = new CossistantClient({ + publicKey: "pk_test_xxxx", + apiUrl: "https://api.cossistant.com/v1", + wsUrl: "wss://api.cossistant.com/ws", +}); export function FileUploader() { const upload = useFileUpload({ client }); diff --git a/apps/web/content/docs/user-feedback/index.mdx b/apps/web/content/docs/user-feedback/index.mdx index 00e42ed1..7abc8a8a 100644 --- a/apps/web/content/docs/user-feedback/index.mdx +++ b/apps/web/content/docs/user-feedback/index.mdx @@ -215,7 +215,11 @@ The most common options are: import { CossistantClient } from "@cossistant/core"; import { useFeedbackForm } from "@cossistant/react/hooks/use-feedback-form"; -const client = new CossistantClient({ publicKey: "pk_test_xxxx" }); +const client = new CossistantClient({ + publicKey: "pk_test_xxxx", + apiUrl: "https://api.cossistant.com/v1", + wsUrl: "wss://api.cossistant.com/ws", +}); export function ProviderFreeFeedback({ visitorId }: { visitorId: string }) { const feedback = useFeedbackForm({ @@ -282,7 +286,11 @@ For provider-free forms, pass a client and visitor explicitly: import { CossistantClient } from "@cossistant/core"; import { useSubmitFeedback } from "@cossistant/react/hooks/use-submit-feedback"; -const client = new CossistantClient({ publicKey: "pk_test_xxxx" }); +const client = new CossistantClient({ + publicKey: "pk_test_xxxx", + apiUrl: "https://api.cossistant.com/v1", + wsUrl: "wss://api.cossistant.com/ws", +}); export function HeadlessFeedbackSubmit({ visitorId }: { visitorId: string }) { const feedback = useSubmitFeedback({ client }); diff --git a/apps/web/src/lib/support-integration-guide.ts b/apps/web/src/lib/support-integration-guide.ts index 01d56f4b..9ef33dde 100644 --- a/apps/web/src/lib/support-integration-guide.ts +++ b/apps/web/src/lib/support-integration-guide.ts @@ -208,7 +208,7 @@ export default function RootLayout({ framework: "react", frameworkLabel: "React", packageName: "@cossistant/react", - envVarName: "COSSISTANT_API_KEY", + envVarName: "VITE_COSSISTANT_API_KEY", envFileName: ".env", docsQuickstartPath: "/docs/quickstart/react", providerFileName: "src/main.tsx", @@ -220,7 +220,7 @@ import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + @@ -306,7 +306,7 @@ import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + @@ -405,6 +405,13 @@ export function buildSupportAiSetupPrompt({ ? `Use this exact public key value: ${publicApiKey}` : "If the public key is missing, fetch it from Cossistant dashboard > Settings > Developers and replace the placeholder value."; + // The react guide assumes a Vite app; other bundlers need their own + // public-env mechanism or an explicit publicKey prop. + const envVarNote = + guide.framework === "react" + ? `\n "${guide.envVarName}" assumes Vite. If the project uses another bundler (CRA, Remix, etc.), use that bundler's public env variable mechanism or pass the key directly via .` + : ""; + return `You are a senior ${guide.frameworkLabel} engineer. Integrate Cossistant into an existing ${guide.frameworkLabel} project. Project context: @@ -424,7 +431,7 @@ Required implementation: 1. Install dependency: ${installCommand} 2. Add/update ${guide.envFileName} with: - ${guide.envVarName}=${keyValue} + ${guide.envVarName}=${keyValue}${envVarNote} 3. ${keyInstruction} 4. Mount at the app root. 5. Import widget CSS: diff --git a/audit/findings.md b/audit/findings.md new file mode 100644 index 00000000..1b510f54 --- /dev/null +++ b/audit/findings.md @@ -0,0 +1,1023 @@ +# Audit Findings — @cossistant/react / next / browser + +Generated 2026-07-02 by 12-dimension multi-agent audit (98 agents). Findings ≥medium were adversarially verified; 28 verifications failed on session limits and are listed as UNCERTAIN (to be hand-verified). 0 findings were refuted. + +## Dimension summaries + +- **api-dx**: The @cossistant/react public surface is thoughtfully designed overall — explicit exports map (no wildcards, enforced by test), compound components with rich JSDoc, and a publint-clean dist. But there are two high-impact issues: the pub:release/pub:beta/pub:next scripts run `npm publish` from the package root where npm ignores publishConfig.directory (verified via `npm pack --dry-run`: the tarball nests dist/ under a root package.json whose exports point at missing ./src files, so the next scripted release would be a fully broken install), and every primitives module plus two of the five documented provider-optional hooks lack the "use client" directive, breaking the README's own deep-import examples in Next.js RSC. Secondary DX papercuts: "./styles.css" is the only export pointing into gitignored dist/ (unresolvable on fresh clone, breaking the react-vite example), the primitives barrel drops most *Props types (Avatar's are unreachable entirely — no subpath either), and the package root re-exports the internal styled CoButton as `Button`, colliding in name with the headless Button primitive. + +- **provider-lifecycle**: The provider/controller lifecycle architecture is mostly well thought out (deferred destroy for StrictMode replay, useSyncExternalStore bridging, updateOptions for live config), but it has several real bugs. The worst: IdentifySupportVisitor deterministically latches `hasIdentified` before the website/visitor has loaded, so visitor identification silently never happens on normal page loads; and the provider's always-defaulted props (`defaultOpen`, `size`, `autoConnect`) are re-pushed through `updateOptions` on every effect re-run, force-closing an open widget when any unstable prop changes identity, stomping externally injected controller config, and killing the localStorage persistence of isOpen/size. Additionally, putting the `support` object in the controller useMemo deps causes a full controller/client/WebSocket teardown-and-reboot on every parent render for the common inline-object case. + +- **realtime**: The realtime layer's store logic (seen/typing/processing) is solid, with TTL cleanup and monotonic timestamp guards, and the event filter is correct and well-tested. The serious problems are in the connection lifecycle: events missed during a disconnect are never backfilled (messages silently vanish from open conversations), RealtimeClient.connect()/reconnect() leak duplicate sockets by not clearing the pending backoff timer, connect() cannot recover after a permanent close, and the exported RealtimeProvider permanently kills its client under React StrictMode — the exact bug class already fixed in the sibling SupportProvider. Several smaller issues (stale callbacks, per-event context re-renders that also explain the act warnings, per-render resubscription in the store hooks, and tests that assert against copies of code) round out the picture. + +- **hooks**: The hooks layer is generally well-engineered (SSR guards, useSyncExternalStore bridging, timer cleanups, tested draft persistence), but the shared useClientQuery fetch abstraction has three verified correctness bugs that hit every consumer: background fetch failures escape as unhandled promise rejections into the host app, store-derived refetchOnMount flags cause a duplicate network request on every successful initial load (conversation list, conversation, and timeline all double-fetch), and caller-supplied refetch args poison argsRef so switching conversations after paginating fetches the new conversation with the old conversation's cursor. The most user-damaging finding is in the composer wiring: useMessageComposer fires sendMessage.mutate without awaiting, which defeats useMultimodalInput's tested rollback contract and permanently loses the visitor's typed message, attachments, and localStorage draft when a send fails. Secondary issues include a dedup key that can make fetchNextPage a no-op, a transition hook that strands the launcher icon invisible on rapid toggles, and sound effects that are silent in Chrome/Safari because the eagerly-created AudioContext is never resumed. All findings were verified by reading the code and, where behavioral, reproduced with temporary bun tests (since removed). + +- **primitives-a11y**: The primitives layer is generally well-structured (consistent forwardRef + useRenderElement pattern, sensible role/aria defaults on the timeline, working focus trap and Escape handling in Window), but the core composition utility has real merge bugs: the asChild Slot clobbers child event handlers and styles, and refs are re-created every render. Two user-visible input bugs stand out — Enter submits mid-IME composition for CJK users, and the Window primitive throws without a provider despite advertising controlled props. Accessibility is partially wired: the trigger/window pair lacks aria-controls and a dialog name, the star-rating selector exposes no selected state to AT, and several timeline aria-labels are nonsensical. Timeline correctness is solid for append/replace but has no scroll anchoring for the top-pagination path it explicitly exposes, and sender precedence differs between TimelineItem and TimelineItemGroup. + +- **feedback**: The feedback module is well-structured (clean context layering, helpful out-of-provider errors, good SSR safety, solid hook test coverage) but the default panel ships two real user-facing bugs: failed submissions are completely silent (the hook computes submitError but the panel never renders it), and passing onClick to Feedback.Trigger silently replaces the internal toggle so the widget stops opening. There are also meaningful a11y gaps (unnamed aria-modal dialog, rating state invisible to assistive tech), an input-wipe bug when topics/defaultTopic resolve asynchronously, and a footprint miss: the module imports the full @floating-ui/react interactions package when only the 10KB @floating-ui/react-dom positioning API is used. + +- **support-ui**: The default widget is well-architected (compound components, slots, persisted store, locale system), but several real defects undermine it: the provider re-applies `defaultOpen` (defaulted to `false`) through `updateOptions`, which force-closes the widget on host re-renders and makes the persisted open-state feature dead code; the window primitive traps Tab for the entire document while the non-modal floating widget is open; and `theme="light"` silently doesn't force light mode. A second tier of issues blocks i18n (hardcoded English relative times and tool strings, region-subtag content overrides never resolving) and degrades a11y/styling (non-`co-` Tailwind color utilities silently missing from the published stylesheet, icon-only buttons with no accessible names). + +- **next-parity**: @cossistant/next is a structurally sound thin wrapper: all 9 re-export entries carry "use client" banners that are preserved verbatim in the minified dist output (verified in both the committed dist and a fresh tsdown build), styles.css/support.css are one-line @import shims onto @cossistant/react rather than stale copies, and prepare-package.ts correctly resolves workspace:* to 0.2.0. The real problems are in publish output and parity: the shipped .d.ts files import a runtime helper module that has no declaration file (verified TS7016 for consumers with skipLibCheck:false), and next exposes only 12 of react's ~41 subpath exports, so the deep-import style that react's own README advertises fails with ERR_PACKAGE_PATH_NOT_EXPORTED when ported to @cossistant/next. Remaining issues are polish: root barrel divergence (next exports utils, react does not), a broken docs URL in a near-empty npm README, dangling sourceMappingURL comments after map deletion, and an orphaned @types/react peerDependenciesMeta entry. + +- **browser-embed**: The embed architecture is well designed — loader-derived asset URLs (no data-attribute injection surface), shadow-DOM CSS isolation with :root-free compiled CSS, a clean teardown API, and, contrary to the audit hypothesis, the preact/compat aliasing IS wired into tsdown.embed.config.ts and verified present in dist/embed/widget.js (no React/scheduler markers). However, the core "script-tag story" is broken as documented: the README's async-loader + immediate init() snippet throws a TypeError because no pre-load stub exists, and the queue replay hard-aborts (dropping init) if any method precedes init. The 191KB-gzip widget.js is dominated by avoidable payload — all of zod v4 + @hono/zod-openapi pulled in for two zod-free helper functions, plus ~24KB of inline base64 audio — and npm packaging has real hazards (react ^19 as a hard dependency vs @cossistant/react's >=18 peer range risks dual-React crashes; the tarball ships ~580KB of duplicated vendored d.ts). + +- **build-packaging**: The publish pipeline's core machinery (prepare-package.ts export rewriting, rewrite-dist-types.ts, check-package-output.ts) is solid — all ~40 export subpaths verified present and correctly mapped in dist/package.json — but the release scripts themselves are a landmine: npm ignores publishConfig.directory, so running the documented pub:release from the package root packs a broken tarball (dist/-prefixed files plus a root package.json whose main/exports point at ./src/*, absent from the tarball). On footprint, the biggest issue is that zod v4 (with its full i18n locale set) plus @hono/zod-openapi is bundled into widget.js (~200KB of the 679KB) and into every @cossistant/react consumer's app bundle via @cossistant/core's client importing runtime helpers from a zod-heavy @cossistant/types module. Smaller issues: ulid is a dead dependency of @cossistant/react, dist ships minified with no sourcemaps and stripped @__PURE__ annotations, and "headless" primitives pull the full 15KB icon registry plus tailwind-merge. + +- **ssr-safety**: No module-scope browser-API access exists (nothing crashes at import time in Node), and nearly all window/document/navigator/observer usage is correctly confined to effects and handlers. The two real SSR problems are structural: (1) the support store synchronously rehydrates persisted state (including isOpen) from localStorage during the first client render, guaranteeing hydration mismatches for returning visitors under Next.js SSR, and (2) every ./primitives/* subpath entry plus several hook/util entries ship without "use client" (verified in both src and dist), so importing them from a Server Component crashes — a major gap for a package advertised as Next.js-ready. A cluster of render-time Date/locale formatting sites (day separators, message timestamps, formatTimeAgo, default-message timestamps that render "Invalid Date" in SSR HTML) produce hydration mismatches whenever the widget is server-rendered open (defaultOpen or persisted-open). + +- **docs-accuracy**: Docs accuracy is generally strong: every named import, hook signature, slot prop, data attribute, theme token, and registry claim I checked in the React/Next quickstarts, support-component pages, primitives, and user-feedback docs matches the source. The real failures cluster in three areas: (1) every provider-free snippet constructs `new CossistantClient({ publicKey })`, which fails to compile and fetches "undefined/..." at runtime because `apiUrl`/`wsUrl` are required with no defaults; (2) the browser CDN embed is broken as documented (async loader + immediate `window.Cossistant.init()` throws) and has zero docs-site coverage; (3) env-var guidance outside Next.js/Vite (the "Other" tab and the AI-prompt/onboarding code that uses `process.env.COSSISTANT_API_KEY` in Vite entry files) produces configurations that can never resolve a key in the browser. + +- **state-consistency**: Cross-cutting data flow in @cossistant/react is architecturally sound — optimistic sends reuse client-generated ULIDs that the server preserves, so the store's merge-by-id cleanly dedupes WS echoes, and seen/read-receipt maps are keyed to avoid double counting. The serious problems are in failure and lifecycle paths: a failed send destroys the user's typed message (composer clears before the fire-and-forget mutation settles while core removes the optimistic item), client-clock createdAt sent during conversation creation gets 400-rejected for visitors with >5-minute clock skew, nothing resyncs the timeline after a WebSocket drop, and the standalone RealtimeProvider is permanently dead under StrictMode. Secondary issues include a sticky pagination cursor in useClientQuery that leaks into later refetches/conversation switches and prop-change desync in useConversationLifecycle. + +## CONFIRMED findings (verified against code) + +### C-01 [CRITICAL] [bug] IdentifySupportVisitor latches hasIdentified before visitor loads, so identification never runs +- **File**: packages/react/src/identify-visitor.tsx:83 | **Dimension**: provider-lifecycle +- **Description**: The identify effect runs on mount (child effects fire before the provider's start effect even kicks off fetchWebsite), so `website` is null and `useVisitor().identify` bails with a console.warn and returns null (use-visitor.ts:118-123 `if (!visitorId) { safeWarn(...); return null; }`). The component then unconditionally calls `setHasIdentified(true)` anyway. When the website/visitor loads moments later, the effect re-runs but Case 1 is permanently blocked by `hasIdentified`, so the visitor is never associated with a contact. The same latch also permanently swallows transient network failures (controller.identify catches and returns null), and there is no in-flight guard, so StrictMode's replayed effect can fire duplicate identify POSTs when the visitor is already loaded. There are zero tests for this exported component. +- **Evidence**: `await identify({ externalId, email, name: name ?? undefined, image: image ?? undefined }); +setHasIdentified(true);` +- **Suggested fix**: At the top of shouldIdentify, bail early when `!visitor` (visitor not yet loaded); only call `setHasIdentified(true)` when `identify()` returns a non-null result; and guard concurrent runs with a `useRef` in-flight flag. Add a regression test that mounts IdentifySupportVisitor before the website fetch resolves and asserts identify is called once after it resolves. +- **Verifier**: Confirmed by both code reading and an empirical reproduction against the real SupportProvider. Code chain: (1) provider.tsx renders children unconditionally and only calls controller.start() in its own effect (provider.tsx:263), which runs AFTER child effects; the controller's initial state is `website: null` with no synchronous cache hydration (core/support-controller.ts:390), so on first commit + +### C-02 [HIGH] [build] pub:release publishes a broken tarball: npm ignores publishConfig.directory +- **File**: packages/react/package.json:140 | **Dimension**: api-dx +- **Description**: The publish scripts run `npm publish` from the package root, relying on `"publishConfig": { "directory": "dist" }`. That key is a pnpm extension — npm (verified with npm 11.16.0) ignores it and packs the root layout. Verified with `npm pack --dry-run --json` in packages/react: the tarball contains 266 files all under `dist/` plus the ROOT package.json, whose `main`/`exports` point at `./src/index.ts` — which is excluded by `"files": ["dist"]`. Anyone installing that release gets a package where every import fails to resolve. The currently published 0.2.0 is dist-shaped (so a past publish was done differently, e.g. cd dist), but the checked-in scripts are the documented path and will ship a dead package next release. packages/next/package.json line 91 has the identical landmine. +- **Evidence**: `"pub:release": "bun run build && npm publish --access public",` +- **Suggested fix**: Publish the rewritten dist manifest directly: `"pub:release": "bun run build && npm publish ./dist --access public"` (npm packs a folder's own package.json when given a path). Apply the same to pub:beta/pub:next in both packages/react and packages/next, and delete the ignored publishConfig.directory or keep it only for pnpm users. +- **Verifier**: Verified end-to-end. packages/react/package.json:140 runs `npm publish --access public` from the package root, relying on `publishConfig.directory: "dist"` (lines 130-133), which npm (11.16.0 installed) ignores — it is a pnpm extension. A live `npm pack --dry-run --json` in packages/react reproduces the broken tarball: 266 files, all under dist/ plus the ROOT package.json, whose main/exports point + +### C-03 [HIGH] [ssr] All primitives and two documented provider-optional hooks are missing "use client" +- **File**: packages/react/src/primitives/trigger.tsx:1 | **Dimension**: api-dx +- **Description**: The root barrel (src/index.ts), ./hooks barrel, ./provider, ./support, ./feedback and ./realtime all carry "use client", but none of the subpath-exported primitives modules do (primitives/index.ts, index.parts.ts, button.tsx, trigger.tsx, window.tsx, multimodal-input.tsx, conversation-timeline.tsx, timeline-item.tsx, timeline-item-group.tsx, router.tsx, day-separator.tsx, avatar/*), nor do hooks/use-create-conversation.ts and hooks/use-send-message.ts (while their siblings use-file-upload/use-submit-feedback/use-feedback-form have it), nor internal/hooks.ts and utils/use-render-element.tsx. All of these call React hooks (trigger.tsx uses useCallback/useStoreSelector). The README's headless quickstart tells users to `import { SupportTrigger } from "@cossistant/react/primitives/trigger"` — pasting that into a Next.js App Router file yields the confusing "useState only works in a Client Component" error instead of an automatic client boundary. tsdown demonstrably preserves the directive per-module (dist/provider.js starts with "use client"; dist/primitives/trigger.js does not), so this is purely missing source banners. Radix/shadcn-quality peers ship the directive on every client entry. +- **Evidence**: `import * as React from "react"; +import { useStoreSelector } from "../hooks/private/store/use-store-selector";` +- **Suggested fix**: Add "use client"; as the first line of every subpath-exported client module: all files under src/primitives/ (including avatar/*), src/hooks/use-create-conversation.ts, src/hooks/use-send-message.ts, src/internal/hooks.ts, and src/utils/use-render-element.tsx. +- **Verifier**: Verified in source: only timeline-item-attachments.tsx among ~30 primitives files has "use client"; trigger.tsx calls React.useCallback (lines 120, 137) and useStoreSelector (line 135) with no directive. hooks/use-create-conversation.ts and use-send-message.ts lack it while sibling use-file-upload.ts has it at line 1, proving the omission is accidental. package.json exports expose all these as dir + +### C-04 [HIGH] [bug] Failed feedback submission is completely silent in the default panel +- **File**: packages/react/src/feedback/components/panel.tsx:248 | **Dimension**: feedback +- **Description**: useFeedbackForm exposes `submitError` and per-field `error` strings, but FeedbackPanel never renders any of them. On a network failure or thrown client error (e.g. 'Visitor context is unavailable.' from use-submit-feedback.ts:85), handleSubmit catches the rejection ('Error state is owned by useSubmitFeedback'), isPending flips back to false, and the button label returns to 'Send' with zero visible or announced feedback. The user believes their feedback was sent (or is confused why nothing happened) and their submission is lost. index.test.tsx:143 even asserts `expect(source).not.toContain('role="alert"')`, locking in the absence of an error surface. +- **Evidence**: `onClick={() => { + void feedback.handleSubmit(); +}}` +- **Suggested fix**: Render `feedback.submitError` (and field errors) in the form footer inside a `role="alert"` element, e.g. `{feedback.submitError ?

{feedback.submitError}

: null}`, and update the index.test.tsx assertion that currently forbids role="alert". +- **Verifier**: Verified in code: FeedbackPanel (packages/react/src/feedback/components/panel.tsx) never renders feedback.submitError or any fields.*.error — grep confirms submitError is consumed nowhere outside use-feedback-form.ts. handleSubmit swallows rejections (use-feedback-form.ts:373 'catch { // Error state is owned by useSubmitFeedback. }'), so on network/API failure or 'Visitor context is unavailable.' + +### C-05 [HIGH] [bug] Consumer onClick on Feedback.Trigger silently replaces the internal toggle, breaking open/close +- **File**: packages/react/src/feedback/internal/trigger.tsx:67 | **Dimension**: feedback +- **Description**: FeedbackTriggerPrimitive builds its props as `{ ..., onClick: toggle, ...props }` — the consumer's rest props are spread after the internal onClick. FeedbackTriggerProps extends ButtonHTMLAttributes, so `` type-checks fine, but the spread replaces `toggle` entirely and the widget can never open. The inverse happens in asChild mode: the Slot in use-render-element.tsx (line 74, `React.cloneElement(children, { ...props, ref, className })`) overwrites the child element's own onClick with toggle, silently discarding the consumer handler. Neither path composes handlers. +- **Evidence**: `"aria-expanded": isOpen, +onClick: toggle, +...props,` +- **Suggested fix**: Compose handlers instead of overwriting: extract the consumer's onClick and call both, e.g. `onClick: (e) => { props.onClick?.(e); if (!e.defaultPrevented) toggle(); }` (Radix composeEventHandlers pattern), and do the same for the child's handlers in the Slot's cloneElement. +- **Verifier**: Verified in code. (1) Non-asChild: packages/react/src/feedback/internal/trigger.tsx:63-68 builds props as `onClick: toggle, ...props` — the consumer rest-props spread comes after the internal handler, so a consumer onClick fully replaces toggle. The public Feedback.Trigger (src/feedback/index.tsx:213-218) is a passthrough whose props extend ButtonHTMLAttributes, so ` + +### C-06 [HIGH] [bug] Failed message send permanently loses the visitor's typed message and attachments +- **File**: packages/react/src/hooks/use-message-composer.ts:218 | **Dimension**: hooks +- **Description**: useMultimodalInput.submit() implements optimistic clearing with rollback: it snapshots the message/files, clears the composer, awaits onSubmit, and on rejection restores the message, files, and localStorage draft (this contract is explicitly tested in draft-persistence.test.tsx: 'restores the draft after a failed submit' and 'keeps the persisted draft while submit is in flight'). useMessageComposer breaks the contract: its async onSubmit calls the fire-and-forget `sendMessage.mutate(...)` (which internally does `void mutateAsync(opts).catch(() => {})`) and returns immediately. submit() therefore treats every send as success: it revokes file URLs and calls reset(), which clears the persisted draft, before the network request even completes. When the send later fails, the catch/rollback path never runs, and the core client also removes the optimistic timeline item (client.ts sendMessage catch -> removeTimelineItem), so the visitor's text and attachments vanish everywhere with no way to recover. It also makes the docstring on draftPersistenceId ('message text is restored after reloads/crashes until submit succeeds') false — the draft is cleared at call time, not on success. +- **Evidence**: `onSubmit: async ({ message: messageText, files }) => { + // Stop typing indicator + stopTyping(); + // Send the message + sendMessage.mutate({` +- **Suggested fix**: In useMessageComposer's onSubmit, await the send so failures propagate to useMultimodalInput's rollback: `await sendMessage.mutateAsync({ conversationId, message: messageText, files, ... })` (drop the inner onError wiring or dedupe the double onError call). This makes submit clear the draft only after the request succeeds and restores the composer on failure. +- **Verifier**: Verified end-to-end. useMultimodalInput.submit() (packages/react/src/hooks/private/use-multimodal-input.ts:193-224) implements snapshot/clear/await-onSubmit with restore-on-throw, and this contract is explicitly tested (draft-persistence.test.tsx:179, :224). useMessageComposer's onSubmit (use-message-composer.ts:218) calls sendMessage.mutate(), which is fire-and-forget: use-send-message.ts:310-314 + +### C-07 [HIGH] [bug] Every background fetch failure surfaces as an unhandled promise rejection in the host app +- **File**: packages/react/src/hooks/private/use-client-query.ts:197 | **Dimension**: hooks +- **Description**: execute() rethrows on failure (`throw normalized`, line 175) after storing the error in state, but all effect-driven call sites discard the promise without a catch: initial/dependency fetch (line 197 `void execute(argsRef.current)`), the refetchInterval timer (line 215), and the window-focus/visibility refetch (line 237). Any failed request from useConversations, useConversation, useConversationTimelineItems, useWebsiteStore, etc. (e.g. visitor goes offline, ad-blocker blocks the API, transient 5xx during polling) produces an unhandled promise rejection in the embedding application. Verified by mounting a probe: bun test reported the queryFn error escaping uncaught through use-client-query.ts:197. For an embeddable SDK this pollutes host error trackers (Sentry `onunhandledrejection`), spams the console, and can crash runtimes that treat unhandled rejections as fatal. +- **Evidence**: `void execute(argsRef.current); +... +const normalized = toError(raw); +setError(normalized); +setIsLoading(false); +throw normalized;` +- **Suggested fix**: Only rethrow for imperative callers: have the internal effect/interval/focus call sites use `execute(argsRef.current).catch(() => {})` (error is already captured in state), or add a `shouldThrow` flag to execute that is true only when invoked via the public `refetch`. +- **Verifier**: Verified in packages/react/src/hooks/private/use-client-query.ts: execute() rethrows after storing error in state (lines 171-175 'throw normalized'), and all three effect-driven call sites discard the promise with no catch — mount/dependency effect (line 197 'void execute(argsRef.current);'), refetchInterval timer (line 215), and focus/visibilitychange handler (line 237). The rejection path is rea + +### C-08 [HIGH] [bug] Store-derived refetchOnMount flag triggers a duplicate network request on every successful initial load +- **File**: packages/react/src/hooks/private/use-client-query.ts:198 | **Dimension**: hooks +- **Description**: The mount/refetch effect includes `refetchOnMount` in its dependency array, and once `hasMountedRef.current` is true any dep change unconditionally fetches (`shouldFetchInitially = hasMountedRef.current ? true : ...`). useConversation passes `refetchOnMount: !conversation`, useConversations passes `selection.conversations.length === 0`, and useConversationTimelineItems passes `selection.items.length === 0` — all flip from true to false exactly when the first fetch lands in the store. That flip re-runs the effect and issues a second, identical request (not deduplicated, because the first already completed and was removed from the in-flight map). Verified with a probe test: FETCH COUNT: 2. Every widget open therefore double-fetches the conversation list, and every conversation open double-fetches the conversation and its timeline page — doubling API load for all users. +- **Evidence**: `const shouldFetchInitially = hasMountedRef.current + ? true + : refetchOnMount || !hasFetchedRef.current; +... +}, [enabled, execute, refetchOnMount, ...dependencies]);` +- **Suggested fix**: Remove `refetchOnMount` from the effect dependency array and read it through a ref (`refetchOnMountRef.current`) inside the effect, so changes to the flag alone never re-trigger a fetch; only `enabled`, `execute`, and `dependencies` should re-run it. +- **Verifier**: Independently reproduced: a probe test mounting useClientQuery with a store-derived refetchOnMount flag (exactly the useConversation pattern) yielded FETCH COUNT: 2. Mechanism verified in use-client-query.ts:187-198 — refetchOnMount is in the effect dep array, and after first mount shouldFetchInitially is unconditionally true, so the true→false flip (caused by core client ingesting the first respo + +### C-09 [HIGH] [bug] Stale pagination cursor is reused when switching conversations, fetching the wrong timeline page +- **File**: packages/react/src/hooks/private/use-client-query.ts:141 | **Dimension**: hooks +- **Description**: execute() persists caller-supplied args into argsRef (`argsRef.current = nextArgs`), and argsRef is only re-synced when `initialArgs` identity changes (lines 125-127). In useConversationTimelineItems, initialArgs is `baseArgs`, memoized solely on `options.limit`/`options.cursor` — it does NOT change when the conversation switches. So after fetchNextPage({ cursor: nextCursor }) runs for conversation A, argsRef holds A's cursor; when the user switches to conversation B, the dependency-driven effect calls `execute(argsRef.current)` and queryFn issues `getConversationTimelineItems({ conversationId: B, cursor: })`, loading a wrong/partial page (or nothing) for the new conversation. Verified with a probe test mimicking the real memoized baseArgs: CALLS ended with {"cursor":"cursor-from-conv-A","convId":"conv-B"}. The same poisoning makes refetchInterval/focus refetches re-request the old paginated page instead of the newest items after any fetchNextPage. +- **Evidence**: `const nextArgs = args ?? argsRef.current; +argsRef.current = nextArgs;` +- **Suggested fix**: Do not persist explicit refetch args into argsRef (use them for that call only: `const nextArgs = args ?? argsRef.current;` without the assignment), or reset `argsRef.current = initialArgs` whenever the effect's `dependencies` change so a conversation switch always starts from the base args. +- **Verifier**: Verified by code reading and an independent runtime probe. use-client-query.ts:141 persists explicit refetch args into argsRef, which is only re-synced when initialArgs identity changes (lines 125-127). In useConversationTimelineItems, initialArgs (baseArgs) is memoized only on options.limit/options.cursor (use-conversation-timeline-items.ts:60-70), so a conversation switch leaves argsRef holding + +### C-10 [HIGH] [bug] Enter submits mid-IME-composition in MultimodalInput +- **File**: packages/react/src/primitives/multimodal-input.tsx:52 | **Dimension**: primitives-a11y +- **Description**: The keydown handler submits on Enter without checking KeyboardEvent.isComposing (or keyCode 229). For CJK users typing with an IME, pressing Enter to confirm a candidate word fires onSubmit, sending a half-composed message. grep confirms 'isComposing' appears nowhere in the package, and the default widget composer wires this primitive directly to send (support/components/multimodal-input.tsx:210). +- **Evidence**: `if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit?.();` +- **Suggested fix**: Guard the submit branch: `if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing)`. +- **Verifier**: Verified at packages/react/src/primitives/multimodal-input.tsx:51-55: handleKeyDown submits on Enter+!shiftKey with e.preventDefault() and onSubmit?.() with no isComposing/keyCode-229 guard; grep confirms 'isComposing' appears nowhere in packages/react/src. The default widget composer (src/support/components/multimodal-input.tsx:210) wires this primitive's onSubmit to handleSubmit which sends the + +### C-11 [HIGH] [bug] asChild Slot clobbers the child's own event handlers and style +- **File**: packages/react/src/utils/use-render-element.tsx:74 | **Dimension**: primitives-a11y +- **Description**: Slot spreads the parent-injected props over the child via cloneElement, so any prop the primitive injects (onClick, onKeyDown, onScroll, style, type...) silently replaces the child's identically named prop instead of composing. Example: `` — the child's onClick is dropped and replaced by the trigger's toggle; `
` loses the child's style because the mask style object replaces it. Radix Slot composes handlers (child first, then slot) and merges style; this Slot only merges className. +- **Evidence**: `return React.cloneElement(children, { + ...props, + ref: mergedRef, + className: [(children.props as any).className, props.className]` +- **Suggested fix**: In Slot, compose function props (`(...a) => { childProps[k]?.(...a); props[k]?.(...a); }` for keys matching /^on[A-Z]/), merge style objects (`{...props.style, ...childProps.style}`), keep the existing className merge. +- **Verifier**: Verified at packages/react/src/utils/use-render-element.tsx:74-80: Slot spreads parent-injected props over the child via cloneElement, merging only className and ref — any same-named child prop is replaced. Concretely reachable: primitives/trigger.tsx:181 injects `onClick: onClick ?? toggle`, so `
-

Thanks for the feedback

+

{text.successTitle}

- Your response was attached to the current visitor context and is - now available in Cossistant. + {text.successDescription}

@@ -148,10 +195,10 @@ export function FeedbackPanel({ type="button" variant="secondary" > - Send another + {text.sendAnotherLabel} - Done + {text.doneLabel}
@@ -165,7 +212,7 @@ export function FeedbackPanel({ {feedback.availableTopics.length > 0 ? (
{commentRequired ? (

- A short message is required for this form. + {text.commentRequiredHint}

) : null}
@@ -221,16 +268,27 @@ export function FeedbackPanel({ data-feedback-form-footer="true" data-slot="feedback-form-footer" > + {feedback.submitError ? ( +

+ {feedback.submitError} +

+ ) : null}

- Rate this experience + {text.ratingLabel}

`Rate ${rating} out of 5`} + labelForRating={text.ratingItemLabel} onBlur={feedback.fields.rating.handleBlur} onHoverChange={feedback.handleRatingHoverChange} onSelect={feedback.handleRatingSelect} diff --git a/packages/react/src/feedback/index.test.tsx b/packages/react/src/feedback/index.test.tsx index 145f19dc..9986609c 100644 --- a/packages/react/src/feedback/index.test.tsx +++ b/packages/react/src/feedback/index.test.tsx @@ -84,6 +84,36 @@ describe("Feedback widget", () => { expect(html).toContain("Select a topic..."); }); + it("exposes named non-modal dialog and radiogroup semantics", () => { + const html = renderWithSupportContext( + + ); + + expect(html).toContain('role="dialog"'); + expect(html).toContain('aria-label="Feedback"'); + expect(html).not.toContain("aria-modal"); + expect(html).toContain('aria-controls="cossistant-feedback-window"'); + expect(html).toContain('role="radiogroup"'); + expect(html).toContain('role="radio"'); + expect(html).toContain('aria-checked="false"'); + }); + + it("routes default panel copy through the strings prop", () => { + const html = renderWithSupportContext( + + ); + + expect(html).toContain("Partagez vos retours"); + expect(html).toContain("Note requise"); + expect(html).not.toContain("Share feedback"); + }); + it("keeps custom content hidden when the root is closed", () => { const html = renderWithSupportContext( @@ -140,7 +170,8 @@ describe("Feedback widget", () => { expect(source).toContain("feedback.handleTopicChange"); expect(source).toContain("feedback.handleRatingSelect"); expect(source).toContain("conversationId"); - expect(source).not.toContain('role="alert"'); + expect(source).toContain("feedback.submitError"); + expect(source).toContain('role="alert"'); expect(source).not.toContain("useSubmitFeedback"); expect(source).not.toContain("client.submitFeedback({"); expect(source).not.toContain("useFeedbackComposer"); diff --git a/packages/react/src/feedback/index.tsx b/packages/react/src/feedback/index.tsx index e8266501..917f208b 100644 --- a/packages/react/src/feedback/index.tsx +++ b/packages/react/src/feedback/index.tsx @@ -10,7 +10,7 @@ import { ConfigurationErrorDisplay } from "../support/components/configuration-e import { ThemeWrapper } from "../support/components/theme-wrapper"; import type { Align, CollisionPadding, Side } from "../support/types"; import { Content } from "./components/content"; -import { FeedbackPanel } from "./components/panel"; +import { FeedbackPanel, type FeedbackPanelStrings } from "./components/panel"; import { Root } from "./components/root"; import { DefaultTrigger } from "./components/trigger"; import { ControlledStateProvider } from "./context/controlled-state"; @@ -43,6 +43,7 @@ export type FeedbackProps = { topicPlaceholder?: string; commentPlaceholder?: string; commentRequired?: boolean; + strings?: Partial; children?: React.ReactNode; }; @@ -131,6 +132,7 @@ function FeedbackComponentInner( topicPlaceholder, commentPlaceholder, commentRequired = false, + strings, children, }: FeedbackProps, ref: React.Ref @@ -169,6 +171,7 @@ function FeedbackComponentInner( commentRequired={commentRequired} conversationId={conversationId} defaultTopic={defaultTopic} + strings={strings} topicPlaceholder={topicPlaceholder} topics={topics} trigger={trigger} @@ -237,6 +240,7 @@ export type FeedbackContentProps = { topicPlaceholder?: string; commentPlaceholder?: string; commentRequired?: boolean; + strings?: Partial; children?: React.ReactNode; }; @@ -254,6 +258,7 @@ const FeedbackContent: React.FC = ({ topicPlaceholder, commentPlaceholder, commentRequired = false, + strings, children, }) => ( = ({ commentRequired={commentRequired} conversationId={conversationId} defaultTopic={defaultTopic} + strings={strings} topicPlaceholder={topicPlaceholder} topics={topics} trigger={trigger} @@ -333,6 +339,7 @@ export type { FeedbackFormFields, FeedbackFormRatingFieldState, FeedbackFormSubmitEvent, + FeedbackFormSubmitLabels, FeedbackFormSubmitState, UseFeedbackFormOptions, UseFeedbackFormResult, @@ -349,6 +356,8 @@ export type { CollisionPadding, Side, } from "../support/types"; +export type { FeedbackPanelStrings } from "./components/panel"; +export { DEFAULT_FEEDBACK_PANEL_STRINGS } from "./components/panel"; export type { FeedbackHandle } from "./context/handle"; export { useFeedbackHandle } from "./context/handle"; export type { FeedbackTriggerRenderProps } from "./internal/trigger"; diff --git a/packages/react/src/feedback/internal/trigger.test.tsx b/packages/react/src/feedback/internal/trigger.test.tsx new file mode 100644 index 00000000..4c5a607f --- /dev/null +++ b/packages/react/src/feedback/internal/trigger.test.tsx @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type * as React from "react"; +import { Window } from "../../../../../apps/web/node_modules/happy-dom"; +import { FeedbackWidgetProvider, useFeedbackConfig } from "../context/widget"; +import { FeedbackTriggerPrimitive } from "./trigger"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MouseEvent", + "Node", + "SyntaxError", + "Text", + "getComputedStyle", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + (window as Window & { SyntaxError?: typeof Error }).SyntaxError = Error; + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MouseEvent", window.MouseEvent); + setGlobalValue("Node", window.Node); + setGlobalValue("SyntaxError", Error); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +async function renderWithWidgetProvider(node: React.ReactNode) { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render({node}); + }); +} + +function OpenStateProbe() { + const { isOpen } = useFeedbackConfig(); + + return
; +} + +function getBySlot(slot: string): HTMLElement { + const element = document.querySelector(`[data-slot="${slot}"]`); + + if (!element) { + throw new Error(`Could not find [data-slot="${slot}"]`); + } + + return element; +} + +describe("FeedbackTriggerPrimitive", () => { + beforeEach(() => { + activeRoot = null; + mountNode = null; + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("composes a consumer onClick with the internal toggle", async () => { + const onClick = mock(() => {}); + + await renderWithWidgetProvider( + <> + + Open feedback + + + + ); + + const { act } = await import("react"); + await act(async () => { + getBySlot("feedback-trigger").click(); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(getBySlot("open-state").dataset.open).toBe("true"); + }); + + it("skips the toggle when the consumer prevents default", async () => { + await renderWithWidgetProvider( + <> + event.preventDefault()}> + Open feedback + + + + ); + + const { act } = await import("react"); + await act(async () => { + getBySlot("feedback-trigger").click(); + }); + + expect(getBySlot("open-state").dataset.open).toBe("false"); + }); +}); diff --git a/packages/react/src/feedback/internal/trigger.tsx b/packages/react/src/feedback/internal/trigger.tsx index 37c8563f..f0e2de18 100644 --- a/packages/react/src/feedback/internal/trigger.tsx +++ b/packages/react/src/feedback/internal/trigger.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useRenderElement } from "../../utils/use-render-element"; import { useTriggerRef } from "../context/positioning"; import { useFeedbackConfig } from "../context/widget"; +import { FEEDBACK_WINDOW_ID } from "./window"; export type FeedbackTriggerRenderProps = { isOpen: boolean; @@ -24,7 +25,7 @@ export type InternalFeedbackTriggerProps = Omit< export const FeedbackTriggerPrimitive = React.forwardRef< HTMLButtonElement, InternalFeedbackTriggerProps ->(({ children, className, asChild = false, ...props }, ref) => { +>(({ children, className, asChild = false, onClick, ...props }, ref) => { const { isOpen, toggle } = useFeedbackConfig(); const triggerRefContext = useTriggerRef(); const setTriggerElement = triggerRefContext?.setTriggerElement; @@ -42,6 +43,19 @@ export const FeedbackTriggerPrimitive = React.forwardRef< [ref, setTriggerElement] ); + // Compose consumer onClick with the internal toggle instead of letting + // the props spread replace it; a prevented event skips the toggle. + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + + if (!event.defaultPrevented) { + toggle(); + } + }, + [onClick, toggle] + ); + const renderProps: FeedbackTriggerRenderProps = { isOpen, toggle, @@ -64,7 +78,8 @@ export const FeedbackTriggerPrimitive = React.forwardRef< type: "button", "aria-haspopup": "dialog", "aria-expanded": isOpen, - onClick: toggle, + "aria-controls": FEEDBACK_WINDOW_ID, + onClick: handleClick, ...props, "data-feedback-trigger": "true", "data-slot": "feedback-trigger", diff --git a/packages/react/src/feedback/internal/window.test.tsx b/packages/react/src/feedback/internal/window.test.tsx new file mode 100644 index 00000000..b39e112c --- /dev/null +++ b/packages/react/src/feedback/internal/window.test.tsx @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type * as React from "react"; +import { Window } from "../../../../../apps/web/node_modules/happy-dom"; +import { FeedbackWidgetProvider, useFeedbackConfig } from "../context/widget"; +import { FeedbackWindow } from "./window"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MouseEvent", + "Node", + "SyntaxError", + "Text", + "getComputedStyle", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + (window as Window & { SyntaxError?: typeof Error }).SyntaxError = Error; + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MouseEvent", window.MouseEvent); + setGlobalValue("Node", window.Node); + setGlobalValue("SyntaxError", Error); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +function OpenStateProbe() { + const { isOpen } = useFeedbackConfig(); + + return
; +} + +async function renderOpenWindow() { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render( + + + + + + + + ); + // Let the window's auto-focus timer settle before tests move focus. + await new Promise((resolve) => setTimeout(resolve, 60)); + }); +} + +function getBySlot(slot: string): HTMLElement { + const element = document.querySelector(`[data-slot="${slot}"]`); + + if (!element) { + throw new Error(`Could not find [data-slot="${slot}"]`); + } + + return element; +} + +function dispatchEscape({ prevented = false }: { prevented?: boolean } = {}) { + const win = window as unknown as { + KeyboardEvent: new ( + type: string, + options?: KeyboardEventInit + ) => KeyboardEvent; + dispatchEvent: (event: Event) => boolean; + }; + const event = new win.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + + if (prevented) { + event.preventDefault(); + } + + win.dispatchEvent(event); +} + +describe("FeedbackWindow escape handling", () => { + beforeEach(() => { + activeRoot = null; + mountNode = null; + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("closes on Escape when focus is inside the panel", async () => { + await renderOpenWindow(); + + const { act } = await import("react"); + await act(async () => { + getBySlot("inside").focus(); + }); + await act(async () => { + dispatchEscape(); + }); + + expect(getBySlot("open-state").dataset.open).toBe("false"); + }); + + it("ignores Escape when a higher layer already consumed it", async () => { + await renderOpenWindow(); + + const { act } = await import("react"); + await act(async () => { + getBySlot("inside").focus(); + }); + await act(async () => { + dispatchEscape({ prevented: true }); + }); + + expect(getBySlot("open-state").dataset.open).toBe("true"); + }); + + it("ignores Escape when focus is outside the panel", async () => { + await renderOpenWindow(); + + const { act } = await import("react"); + await act(async () => { + getBySlot("outside").focus(); + }); + await act(async () => { + dispatchEscape(); + }); + + expect(getBySlot("open-state").dataset.open).toBe("true"); + }); +}); diff --git a/packages/react/src/feedback/internal/window.tsx b/packages/react/src/feedback/internal/window.tsx index 290438ca..f7821813 100644 --- a/packages/react/src/feedback/internal/window.tsx +++ b/packages/react/src/feedback/internal/window.tsx @@ -23,6 +23,8 @@ export type WindowProps = Omit< id?: string; }; +export const FEEDBACK_WINDOW_ID = "cossistant-feedback-window"; + const FOCUSABLE_SELECTOR = [ "a[href]", "area[href]", @@ -59,7 +61,7 @@ export const FeedbackWindow = React.forwardRef( closeOnEscape = true, trapFocus = true, restoreFocus = true, - id = "cossistant-feedback-window", + id = FEEDBACK_WINDOW_ID, ...props }, ref @@ -115,9 +117,19 @@ export const FeedbackWindow = React.forwardRef( } const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closeWindow(); + if (event.defaultPrevented || event.key !== "Escape") { + return; + } + + // Only close when focus is inside the non-modal panel, so an + // Escape aimed at a host overlay does not destroy in-progress + // feedback. + const container = containerRef.current; + if (container && !container.contains(document.activeElement)) { + return; } + + closeWindow(); }; window.addEventListener("keydown", handleKeyDown); @@ -203,7 +215,9 @@ export const FeedbackWindow = React.forwardRef( state: renderProps, props: { role: "dialog", - "aria-modal": "true", + // Non-modal panel: the host page stays interactive, so no + // aria-modal. Default name is overridable via props. + "aria-label": "Feedback", "data-feedback-window": "true", "data-state": open ? "open" : "closed", id, diff --git a/packages/react/src/hooks/private/use-client-query.test.tsx b/packages/react/src/hooks/private/use-client-query.test.tsx new file mode 100644 index 00000000..daef19b0 --- /dev/null +++ b/packages/react/src/hooks/private/use-client-query.test.tsx @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { CossistantClient } from "@cossistant/core"; +import type React from "react"; +import { Window } from "../../../../../apps/web/node_modules/happy-dom"; +import { useClientQuery } from "./use-client-query"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MutationObserver", + "Node", + "Text", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MutationObserver", window.MutationObserver); + setGlobalValue("Node", window.Node); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ); + setGlobalValue("cancelAnimationFrame", (id: number) => + window.clearTimeout(id) + ); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +const fakeClient = {} as CossistantClient; + +async function mount(node: React.ReactNode) { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render(node); + }); +} + +async function rerender(node: React.ReactNode) { + const { act } = await import("react"); + + await act(async () => { + activeRoot?.render(node); + }); +} + +async function flush() { + const { act } = await import("react"); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("useClientQuery", () => { + beforeEach(() => { + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("does not refetch when a store-derived refetchOnMount flag flips after the first load", async () => { + let fetchCount = 0; + let latestData: string | undefined; + + function Harness({ refetchOnMount }: { refetchOnMount: boolean }) { + const query = useClientQuery({ + client: fakeClient, + queryFn: () => { + fetchCount += 1; + return Promise.resolve("data"); + }, + queryKey: "test:double-fetch", + refetchOnMount, + }); + latestData = query.data; + return null; + } + + await mount(); + await flush(); + + expect(fetchCount).toBe(1); + expect(latestData).toBe("data"); + + // Store now holds data, so the derived flag flips to false. This must + // not trigger a second, identical network request. + await rerender(); + await flush(); + + expect(fetchCount).toBe(1); + }); + + it("keeps background fetch failures out of the host app's unhandled rejections", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + let latestError: Error | null = null; + + function Harness() { + const query = useClientQuery({ + client: fakeClient, + queryFn: () => Promise.reject(new Error("network down")), + queryKey: "test:error-containment", + }); + latestError = query.error; + return null; + } + + try { + await mount(); + await flush(); + // Give the runtime a macrotask to report any escaped rejection. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(latestError?.message).toBe("network down"); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("does not leak refetch args into fetches triggered by dependency changes", async () => { + const { act } = await import("react"); + const calls: Array<{ conversationId: string; cursor?: string }> = []; + // Stable identity across renders, mirroring the memoised baseArgs in + // useConversationTimelineItems. + const baseArgs: { cursor?: string } = {}; + let latestRefetch: + | ((args?: { cursor?: string }) => Promise) + | null = null; + + function Harness({ conversationId }: { conversationId: string }) { + const query = useClientQuery({ + client: fakeClient, + queryFn: (_instance, args) => { + calls.push({ conversationId, cursor: args?.cursor }); + return Promise.resolve("page"); + }, + queryKey: `test:timeline:${conversationId}`, + initialArgs: baseArgs, + dependencies: [conversationId], + }); + latestRefetch = query.refetch; + return null; + } + + await mount(); + await flush(); + + expect(calls).toEqual([{ conversationId: "conv-A", cursor: undefined }]); + + // Paginate conversation A. + await act(async () => { + await latestRefetch?.({ cursor: "cursor-from-conv-A" }); + }); + + expect(calls.at(-1)).toEqual({ + conversationId: "conv-A", + cursor: "cursor-from-conv-A", + }); + + // Switching conversations must fetch with the base args, not with + // conversation A's pagination cursor. + await rerender(); + await flush(); + + expect(calls.at(-1)).toEqual({ + conversationId: "conv-B", + cursor: undefined, + }); + }); + + it("fetches the requested page even when a same-key request is in flight", async () => { + const { act } = await import("react"); + const calls: Array = []; + let resolveFirst: ((value: string) => void) | null = null; + let latestRefetch: + | ((args?: { cursor?: string }) => Promise) + | null = null; + + function Harness() { + const query = useClientQuery({ + client: fakeClient, + queryFn: (_instance, args) => { + calls.push(args?.cursor); + if (calls.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve("older-page-data"); + }, + queryKey: "test:dedup-bypass", + }); + latestRefetch = query.refetch; + return null; + } + + await mount(); + + // The first-page request is still in flight; an explicit pagination + // refetch must not be deduplicated into it. + let refetchResult: string | undefined; + await act(async () => { + const pending = latestRefetch?.({ cursor: "older-page" }); + resolveFirst?.("page-1"); + refetchResult = await pending; + }); + + expect(calls).toEqual([undefined, "older-page"]); + expect(refetchResult).toBe("older-page-data"); + }); +}); diff --git a/packages/react/src/hooks/private/use-client-query.ts b/packages/react/src/hooks/private/use-client-query.ts index f60924b7..7d772efc 100644 --- a/packages/react/src/hooks/private/use-client-query.ts +++ b/packages/react/src/hooks/private/use-client-query.ts @@ -112,8 +112,10 @@ export function useClientQuery( const hasFetchedRef = useRef(initialData !== undefined); const isMountedRef = useRef(true); const queryFnRef = useRef(queryFn); + const refetchOnMountRef = useRef(refetchOnMount); queryFnRef.current = queryFn; + refetchOnMountRef.current = refetchOnMount; useEffect(() => { isMountedRef.current = true; @@ -127,18 +129,19 @@ export function useClientQuery( }, [initialArgs]); const execute = useCallback( - async (args?: TArgs, ignoreEnabled = false): Promise => { + async (args?: TArgs, isManual = false): Promise => { // Handle null client (configuration error case) if (!client) { return dataRef.current; } - if (!(enabled || ignoreEnabled)) { + if (!(enabled || isManual)) { return dataRef.current; } + // Explicit args apply to this call only: persisting them would leak a + // pagination cursor into interval/focus refetches and key changes. const nextArgs = args ?? argsRef.current; - argsRef.current = nextArgs; const fetchId = fetchIdRef.current + 1; fetchIdRef.current = fetchId; @@ -147,10 +150,14 @@ export function useClientQuery( setError(null); try { - // Use deduplication to share in-flight requests with the same key - const result = await executeWithDeduplication(queryKey, () => - queryFnRef.current(client, nextArgs) - ); + // Manual refetches bypass deduplication: their args (e.g. a + // pagination cursor) can differ from the in-flight request that + // shares this key. + const result = isManual + ? await queryFnRef.current(client, nextArgs) + : await executeWithDeduplication(queryKey, () => + queryFnRef.current(client, nextArgs) + ); if (!isMountedRef.current || fetchId !== fetchIdRef.current) { return dataRef.current; @@ -184,9 +191,11 @@ export function useClientQuery( return; } + // Read refetchOnMount through a ref: store-derived flags flip when the + // first response lands, and re-running this effect would double-fetch. const shouldFetchInitially = hasMountedRef.current ? true - : refetchOnMount || !hasFetchedRef.current; + : refetchOnMountRef.current || !hasFetchedRef.current; hasMountedRef.current = true; @@ -194,8 +203,10 @@ export function useClientQuery( return; } - void execute(argsRef.current); - }, [enabled, execute, refetchOnMount, ...dependencies]); + // Errors are captured in state; swallow the rejection so background + // fetch failures never escape as unhandled rejections in the host app. + execute(argsRef.current).catch(() => {}); + }, [enabled, execute, ...dependencies]); useEffect(() => { if (!enabled) { @@ -212,7 +223,7 @@ export function useClientQuery( } const timer = window.setInterval(() => { - void execute(argsRef.current); + execute(argsRef.current).catch(() => {}); }, refetchInterval); return () => { @@ -234,16 +245,16 @@ export function useClientQuery( return; } - void execute(argsRef.current); + execute(argsRef.current).catch(() => {}); }; const onFocus = () => { - void handleRefetch(); + handleRefetch(); }; const onVisibilityChange = () => { if (document.visibilityState === "visible") { - void handleRefetch(); + handleRefetch(); } }; diff --git a/packages/react/src/hooks/private/use-default-messages.test.ts b/packages/react/src/hooks/private/use-default-messages.test.ts index 13c87f60..bbef9981 100644 --- a/packages/react/src/hooks/private/use-default-messages.test.ts +++ b/packages/react/src/hooks/private/use-default-messages.test.ts @@ -68,4 +68,14 @@ describe("reconcileDefaultMessageSeeds", () => { expect(ULID_REGEX.test(firstSeedId)).toBe(true); }); + + it("always generates a valid ISO createdAt, even without a window global", () => { + // Regression: server-side seeds used to get createdAt "", which + // rendered "Invalid Date" in SSR HTML. + const seeds = reconcileDefaultMessageSeeds([createDefaultMessage()], []); + const createdAt = seeds[0]?.createdAt ?? ""; + + expect(createdAt).not.toBe(""); + expect(Number.isNaN(new Date(createdAt).getTime())).toBe(false); + }); }); diff --git a/packages/react/src/hooks/private/use-default-messages.ts b/packages/react/src/hooks/private/use-default-messages.ts index 5cf8cf66..f720e4bc 100644 --- a/packages/react/src/hooks/private/use-default-messages.ts +++ b/packages/react/src/hooks/private/use-default-messages.ts @@ -2,7 +2,7 @@ import { generateMessageId } from "@cossistant/core/utils"; import type { DefaultMessage } from "@cossistant/types"; import type { TimelineItem } from "@cossistant/types/api/timeline-item"; import { SenderType } from "@cossistant/types/enums"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useSyncExternalStore } from "react"; import { useSupport } from "../../provider"; type UseDefaultMessagesParams = { @@ -15,8 +15,12 @@ type DefaultMessageSeed = { createdAt: string; }; +const emptySubscribe = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + function createDefaultMessageTimestamp(): string { - return typeof window !== "undefined" ? new Date().toISOString() : ""; + return new Date().toISOString(); } function createDefaultMessageSignature(message: DefaultMessage): string { @@ -55,8 +59,20 @@ export function useDefaultMessages({ const { defaultMessages, availableAIAgents, availableHumanAgents } = useSupport(); const defaultMessageSeedsRef = useRef([]); + // Seeds carry render-time ulids/timestamps that can never match between the + // server and client passes; render nothing until hydration completes so + // SSR HTML and the hydration render emit identical markup. + const isHydrated = useSyncExternalStore( + emptySubscribe, + getClientSnapshot, + getServerSnapshot + ); const memoisedDefaultTimelineItems = useMemo(() => { + if (!isHydrated) { + return []; + } + const nextSeeds = reconcileDefaultMessageSeeds( defaultMessages, defaultMessageSeedsRef.current @@ -90,6 +106,7 @@ export function useDefaultMessages({ } satisfies TimelineItem; }); }, [ + isHydrated, defaultMessages, availableHumanAgents[0]?.id, availableAIAgents[0]?.id, diff --git a/packages/react/src/hooks/use-conversation-lifecycle.test.tsx b/packages/react/src/hooks/use-conversation-lifecycle.test.tsx new file mode 100644 index 00000000..2c168cbd --- /dev/null +++ b/packages/react/src/hooks/use-conversation-lifecycle.test.tsx @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type React from "react"; +import { Window } from "../../../../apps/web/node_modules/happy-dom"; +import { PENDING_CONVERSATION_ID } from "../utils/id"; +import { + type UseConversationLifecycleReturn, + useConversationLifecycle, +} from "./use-conversation-lifecycle"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MutationObserver", + "Node", + "Text", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MutationObserver", window.MutationObserver); + setGlobalValue("Node", window.Node); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ); + setGlobalValue("cancelAnimationFrame", (id: number) => + window.clearTimeout(id) + ); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +let latest: UseConversationLifecycleReturn | null = null; + +function Harness({ + initialConversationId, + onConversationCreated, +}: { + initialConversationId?: string; + onConversationCreated?: (conversationId: string) => void; +}) { + latest = useConversationLifecycle({ + initialConversationId, + onConversationCreated, + }); + return null; +} + +async function mount(node: React.ReactNode) { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render(node); + }); +} + +async function rerender(node: React.ReactNode) { + const { act } = await import("react"); + + await act(async () => { + activeRoot?.render(node); + }); +} + +describe("useConversationLifecycle", () => { + beforeEach(() => { + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + latest = null; + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("resyncs when the initialConversationId prop changes without unmounting", async () => { + await mount(); + expect(latest?.conversationId).toBe("conv-a"); + + await rerender(); + expect(latest?.conversationId).toBe("conv-b"); + expect(latest?.realConversationId).toBe("conv-b"); + }); + + it("keeps a created conversation id across re-renders with an unchanged prop", async () => { + const { act } = await import("react"); + + await mount(); + + await act(async () => { + latest?.setConversationId("conv-created"); + }); + expect(latest?.conversationId).toBe("conv-created"); + + await rerender(); + expect(latest?.conversationId).toBe("conv-created"); + }); + + it("fires onConversationCreated once for the pending -> real transition", async () => { + const { act } = await import("react"); + const created: string[] = []; + + await mount( + { + created.push(conversationId); + }} + /> + ); + expect(latest?.isPending).toBe(true); + + await act(async () => { + latest?.setConversationId("conv-real"); + }); + expect(created).toEqual(["conv-real"]); + expect(latest?.isPending).toBe(false); + + await act(async () => { + latest?.setConversationId("conv-real-2"); + }); + expect(created).toEqual(["conv-real"]); + }); +}); diff --git a/packages/react/src/hooks/use-conversation-lifecycle.ts b/packages/react/src/hooks/use-conversation-lifecycle.ts index 0581e756..b422b432 100644 --- a/packages/react/src/hooks/use-conversation-lifecycle.ts +++ b/packages/react/src/hooks/use-conversation-lifecycle.ts @@ -97,6 +97,19 @@ export function useConversationLifecycle( const [conversationId, setConversationIdState] = useState( initialConversationId ); + const [previousInitialConversationId, setPreviousInitialConversationId] = + useState(initialConversationId); + const conversationIdRef = useRef(conversationId); + conversationIdRef.current = conversationId; + + // Resync when the caller navigates to a different conversation (URL param + // or router state change) without unmounting this hook. + if (previousInitialConversationId !== initialConversationId) { + setPreviousInitialConversationId(initialConversationId); + setConversationIdState(initialConversationId); + conversationIdRef.current = initialConversationId; + } + const onConversationCreatedRef = useRef(onConversationCreated); // Keep callback ref up to date @@ -105,16 +118,16 @@ export function useConversationLifecycle( }, [onConversationCreated]); const setConversationId = useCallback((newId: string) => { - setConversationIdState((current) => { - // Only trigger callback if transitioning from pending to real - if ( - current === PENDING_CONVERSATION_ID && - newId !== PENDING_CONVERSATION_ID - ) { - onConversationCreatedRef.current?.(newId); - } - return newId; - }); + // The callback runs outside the setState updater: updaters must stay + // side-effect free (StrictMode invokes them twice). + const wasPending = conversationIdRef.current === PENDING_CONVERSATION_ID; + conversationIdRef.current = newId; + setConversationIdState(newId); + + // Only trigger callback if transitioning from pending to real + if (wasPending && newId !== PENDING_CONVERSATION_ID) { + onConversationCreatedRef.current?.(newId); + } }, []); const isPending = conversationId === PENDING_CONVERSATION_ID; diff --git a/packages/react/src/hooks/use-conversation-page.ts b/packages/react/src/hooks/use-conversation-page.ts index 2b1e2fa3..9e299beb 100644 --- a/packages/react/src/hooks/use-conversation-page.ts +++ b/packages/react/src/hooks/use-conversation-page.ts @@ -5,7 +5,13 @@ import { ConversationTimelineType, TimelineItemVisibility, } from "@cossistant/types/enums"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; import { useSupport } from "../provider"; import { useIdentificationState } from "../support/context/identification"; import { useWebSocketSafe } from "../support/context/websocket"; @@ -114,6 +120,10 @@ function isNotFoundError(error: Error | null): boolean { return error instanceof CossistantAPIError && error.code === "HTTP_404"; } +const emptySubscribe = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + /** * Main orchestrator hook for the conversation page. * @@ -164,6 +174,14 @@ export function useConversationPage( const { client, visitor, availableAIAgents } = useSupport(); const websocket = useWebSocketSafe(); const identificationState = useIdentificationState(); + const isRealtimeConnected = websocket?.isConnected ?? false; + // The identification item embeds a render-time timestamp that can never + // match between the server and client passes; wait for hydration. + const isHydrated = useSyncExternalStore( + emptySubscribe, + getClientSnapshot, + getServerSnapshot + ); const trimmedInitialMessage = initialMessage?.trim() ?? ""; const hasInitialMessage = trimmedInitialMessage.length > 0; @@ -200,6 +218,37 @@ export function useConversationPage( const timelineQuery = useConversationTimelineItems(conversationLifecycleId, { enabled: shouldEnableConversationNetworkSync, }); + const refetchTimelineItems = timelineQuery.refetch; + + // Backfill timeline items that arrived while the realtime connection was + // down: refetch on a disconnected -> connected transition (but not on the + // initial connect, which happens right after the mount fetch). + const wasRealtimeConnectedRef = useRef(isRealtimeConnected); + const hadRealtimeConnectionRef = useRef(isRealtimeConnected); + useEffect(() => { + const wasConnected = wasRealtimeConnectedRef.current; + wasRealtimeConnectedRef.current = isRealtimeConnected; + + if (!isRealtimeConnected || wasConnected) { + return; + } + + if (!hadRealtimeConnectionRef.current) { + hadRealtimeConnectionRef.current = true; + return; + } + + if (!shouldEnableConversationNetworkSync) { + return; + } + + // Errors are surfaced through the query state; swallow the rejection. + refetchTimelineItems().catch(() => {}); + }, [ + isRealtimeConnected, + shouldEnableConversationNetworkSync, + refetchTimelineItems, + ]); // 4. Determine which items to display const baseItems = useMemo(() => { @@ -231,6 +280,10 @@ export function useConversationPage( ]); const shouldShowIdentificationTool = useMemo(() => { + if (!isHydrated) { + return false; + } + if (isPendingConversation) { return false; } @@ -255,6 +308,7 @@ export function useConversationPage( ); }, [ baseItems, + isHydrated, isPendingConversation, visitor?.contact, identificationState?.isIdentifying, @@ -283,7 +337,9 @@ export function useConversationPage( userId: null, visitorId: visitor?.id ?? null, aiAgentId: null, - createdAt: typeof window !== "undefined" ? new Date().toISOString() : "", + // shouldShowIdentificationTool is gated on hydration, so this only + // runs client-side and always yields a valid timestamp. + createdAt: new Date().toISOString(), deletedAt: null, }; @@ -331,7 +387,7 @@ export function useConversationPage( onConversationInitiated: handleConversationInitiated, // Pass WebSocket connection for real-time typing events realtimeSend: websocket?.send ?? null, - isRealtimeConnected: websocket?.isConnected ?? false, + isRealtimeConnected, }); const initialMessageSubmittedRef = useRef(false); diff --git a/packages/react/src/hooks/use-create-conversation.ts b/packages/react/src/hooks/use-create-conversation.ts index db55fede..1f08fa1a 100644 --- a/packages/react/src/hooks/use-create-conversation.ts +++ b/packages/react/src/hooks/use-create-conversation.ts @@ -1,3 +1,5 @@ +"use client"; + import type { CossistantClient } from "@cossistant/core"; import type { CreateConversationResponseBody } from "@cossistant/types/api/conversation"; import type { TimelineItem } from "@cossistant/types/api/timeline-item"; diff --git a/packages/react/src/hooks/use-feedback-form.test.tsx b/packages/react/src/hooks/use-feedback-form.test.tsx index 84163b49..ff65e8b0 100644 --- a/packages/react/src/hooks/use-feedback-form.test.tsx +++ b/packages/react/src/hooks/use-feedback-form.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import type { SubmitFeedbackResponse } from "@cossistant/types/api/feedback"; -import type * as React from "react"; +import * as React from "react"; import { Window } from "../../../../apps/web/node_modules/happy-dom"; import { SupportControllerContext } from "../controller-context"; import { type CossistantContextValue, SupportContext } from "../provider"; @@ -293,6 +293,23 @@ function FeedbackFormProbe({ options }: { options?: UseFeedbackFormOptions }) { ); } +function AsyncTopicsProbe() { + const [topics, setTopics] = React.useState([]); + + return ( + <> + + + + ); +} + function getBySlot(slot: string): HTMLElement { const element = document.querySelector(`[data-slot="${slot}"]`); @@ -633,6 +650,88 @@ describe("useFeedbackForm", () => { expect(getState().submitted).toBe("true"); }); + it("keeps in-progress input when topics resolve asynchronously", async () => { + await renderWithSupportContext( + , + createSupportContextValue() + ); + + const { act } = await import("react"); + await act(async () => { + click("comment"); + click("rating"); + }); + + expect(getState().topics).toBe(""); + expect(getState().rating).toBe("5"); + + await act(async () => { + click("load-topics"); + }); + + expect(getState().topics).toBe("Bug|Other"); + expect(getState().topic).toBe("Bug"); + expect(getState().comment).toBe(" The nav jumps. "); + expect(getState().rating).toBe("5"); + expect(getState().ratingDirty).toBe("true"); + }); + + it("keeps a user-selected topic when the default topic changes", async () => { + await renderWithSupportContext( + , + createSupportContextValue() + ); + + const { act } = await import("react"); + await act(async () => { + click("load-topics"); + }); + await act(async () => { + click("topic-other"); + }); + + expect(getState().topic).toBe("Other"); + }); + + it("ignores duplicate submits while one is in flight", async () => { + let submitCount = 0; + let resolveSubmit: (response: SubmitFeedbackResponse) => void = () => {}; + const context = createSupportContextValue({ + client: { + submitFeedback: () => { + submitCount += 1; + return new Promise((resolve) => { + resolveSubmit = resolve; + }); + }, + } as CossistantContextValue["client"], + }); + + await renderWithSupportContext( + , + context + ); + + const { act } = await import("react"); + await act(async () => { + click("topic-bug"); + click("rating"); + }); + await act(async () => { + click("submit-direct"); + click("submit-direct"); + }); + + expect(submitCount).toBe(1); + + await act(async () => { + resolveSubmit(createFeedbackResponse()); + await Promise.resolve(); + }); + + expect(getState().submitted).toBe("true"); + }); + it("resets, sends another, closes, and reports open changes", async () => { const onOpenChange = mock(() => {}); const context = createSupportContextValue(); diff --git a/packages/react/src/hooks/use-feedback-form.ts b/packages/react/src/hooks/use-feedback-form.ts index e1f4ca59..1d6a5be4 100644 --- a/packages/react/src/hooks/use-feedback-form.ts +++ b/packages/react/src/hooks/use-feedback-form.ts @@ -5,6 +5,12 @@ import type { SubmitFeedbackResponse } from "@cossistant/types/api/feedback"; import * as React from "react"; import { useSubmitFeedback } from "./use-submit-feedback"; +export type FeedbackFormSubmitLabels = { + idle?: string; + pending?: string; + ratingRequired?: string; +}; + export type UseFeedbackFormOptions = { client?: CossistantClient | null; topics?: string[]; @@ -16,6 +22,7 @@ export type UseFeedbackFormOptions = { contactId?: string; commentRequired?: boolean; defaultOpen?: boolean; + submitLabels?: FeedbackFormSubmitLabels; onOpenChange?: (open: boolean) => void; onSuccess?: (data: SubmitFeedbackResponse) => void; onError?: (error: Error) => void; @@ -48,7 +55,7 @@ export type FeedbackFormSubmitState = { canSubmit: boolean; canAttemptSubmit: boolean; disabled: boolean; - label: "Rating needed" | "Send" | "Sending..."; + label: string; }; type FeedbackFormFieldName = keyof FeedbackFormFields; @@ -138,6 +145,7 @@ export function useFeedbackForm({ contactId, commentRequired = false, defaultOpen = false, + submitLabels, onOpenChange, onSuccess, onError, @@ -152,6 +160,7 @@ export function useFeedbackForm({ React.useState(EMPTY_FIELD_INTERACTION); const [touchedFields, setTouchedFields] = React.useState(EMPTY_FIELD_INTERACTION); + const submitInFlightRef = React.useRef(false); const { error, isPending, @@ -267,10 +276,10 @@ export function useFeedbackForm({ canAttemptSubmit, disabled: !canSubmit, label: isPending - ? "Sending..." + ? (submitLabels?.pending ?? "Sending...") : rawIsRatingMissing - ? "Rating needed" - : "Send", + ? (submitLabels?.ratingRequired ?? "Rating needed") + : (submitLabels?.idle ?? "Send"), }; const resetForm = React.useCallback(() => { @@ -285,10 +294,29 @@ export function useFeedbackForm({ resetSubmitFeedback(); }, [resetSubmitFeedback, resolvedDefaultTopic]); + // Only reset when the conversation actually changes; resetForm identity + // also churns when topics/defaultTopic resolve asynchronously and that + // must not wipe in-progress input. + const previousConversationIdRef = React.useRef(conversationId); React.useEffect(() => { + if (previousConversationIdRef.current === conversationId) { + return; + } + + previousConversationIdRef.current = conversationId; resetForm(); }, [conversationId, resetForm]); + // Apply a late-resolving default topic only while the field is untouched. + const topicInteracted = dirtyFields.topic || touchedFields.topic; + React.useEffect(() => { + if (topicInteracted) { + return; + } + + setTopic(resolvedDefaultTopic); + }, [resolvedDefaultTopic, topicInteracted]); + const handleOpenChange = React.useCallback( (nextOpen: boolean) => { setOpenState(nextOpen); @@ -346,6 +374,13 @@ export function useFeedbackForm({ const handleSubmit = React.useCallback( async (event?: FeedbackFormSubmitEvent) => { event?.preventDefault(); + + // Ref-based in-flight guard: isPending is stale inside this + // closure, so duplicate calls before a re-render would double-POST. + if (submitInFlightRef.current) { + return; + } + setHasAttemptedSubmit(true); resetSubmitFeedback(); @@ -358,6 +393,8 @@ export function useFeedbackForm({ return; } + submitInFlightRef.current = true; + try { await submitFeedback({ rating, @@ -372,6 +409,8 @@ export function useFeedbackForm({ setHasSubmitted(true); } catch { // Error state is owned by useSubmitFeedback. + } finally { + submitInFlightRef.current = false; } }, [ diff --git a/packages/react/src/hooks/use-message-composer.test.tsx b/packages/react/src/hooks/use-message-composer.test.tsx new file mode 100644 index 00000000..3adeacd3 --- /dev/null +++ b/packages/react/src/hooks/use-message-composer.test.tsx @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { CossistantClient } from "@cossistant/core"; +import type * as React from "react"; +import { Window } from "../../../../apps/web/node_modules/happy-dom"; +import { getLocalStorageDraftStorageKey } from "./use-local-storage-draft-value"; +import { useMessageComposer } from "./use-message-composer"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "CustomEvent", + "EventTarget", + "HTMLElement", + "MutationObserver", + "Node", + "StorageEvent", + "Text", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("CustomEvent", window.CustomEvent); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MutationObserver", window.MutationObserver); + setGlobalValue("Node", window.Node); + setGlobalValue("StorageEvent", window.StorageEvent); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ); + setGlobalValue("cancelAnimationFrame", (id: number) => + window.clearTimeout(id) + ); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +async function mount(node: React.ReactNode) { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render(node); + }); +} + +type FakeClientOptions = { + sendMessage?: () => Promise; +}; + +function createFakeClient({ sendMessage }: FakeClientOptions = {}) { + return { + setVisitorTyping: mock(async () => ({})), + generateUploadUrl: mock(async () => ({ + uploadUrl: "https://uploads.example.com/upload", + publicUrl: "https://uploads.example.com/file.png", + })), + uploadFile: mock(async () => ({})), + sendMessage: mock(sendMessage ?? (async () => ({ item: { id: "msg_1" } }))), + } as unknown as CossistantClient; +} + +describe("useMessageComposer", () => { + beforeEach(() => { + activeRoot = null; + mountNode = null; + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("restores the message, attachments and persisted draft when the send fails", async () => { + const draftPersistenceId = "conversation-composer:acme:conv_fail"; + const client = createFakeClient({ + sendMessage: async () => { + throw new Error("Network error"); + }, + }); + const onError = mock(() => {}); + let hookValue: ReturnType | null = null; + + function Harness() { + hookValue = useMessageComposer({ + client, + conversationId: "conv_fail", + draftPersistenceId, + onError, + visitorId: "visitor_1", + }); + return null; + } + + await mount(); + + const storageKey = getLocalStorageDraftStorageKey(draftPersistenceId); + const attachment = new File(["binary"], "photo.png", { + type: "image/png", + }); + const { act } = await import("react"); + + await act(async () => { + hookValue?.setMessage("Message that must survive a failed send"); + }); + await act(async () => { + hookValue?.addFiles([attachment]); + }); + + await act(async () => { + await hookValue?.submit(); + }); + + expect(hookValue?.message).toBe("Message that must survive a failed send"); + expect(hookValue?.files).toHaveLength(1); + expect(hookValue?.files[0]?.name).toBe("photo.png"); + expect(window.localStorage.getItem(storageKey)).toBe( + "Message that must survive a failed send" + ); + expect(hookValue?.error?.message).toBe("Network error"); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("clears the composer and persisted draft only after a successful send", async () => { + const draftPersistenceId = "conversation-composer:acme:conv_ok"; + const client = createFakeClient(); + const onMessageSent = mock(() => {}); + let hookValue: ReturnType | null = null; + + function Harness() { + hookValue = useMessageComposer({ + client, + conversationId: "conv_ok", + draftPersistenceId, + onMessageSent, + visitorId: "visitor_1", + }); + return null; + } + + await mount(); + + const storageKey = getLocalStorageDraftStorageKey(draftPersistenceId); + const { act } = await import("react"); + + await act(async () => { + hookValue?.setMessage("Message that should be sent"); + }); + + expect(window.localStorage.getItem(storageKey)).toBe( + "Message that should be sent" + ); + + await act(async () => { + await hookValue?.submit(); + }); + + expect(hookValue?.message).toBe(""); + expect(hookValue?.files).toHaveLength(0); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + expect(hookValue?.error).toBeNull(); + expect(onMessageSent).toHaveBeenCalledWith("conv_ok", "msg_1"); + }); +}); diff --git a/packages/react/src/hooks/use-message-composer.ts b/packages/react/src/hooks/use-message-composer.ts index 04806860..ac17656c 100644 --- a/packages/react/src/hooks/use-message-composer.ts +++ b/packages/react/src/hooks/use-message-composer.ts @@ -214,8 +214,10 @@ export function useMessageComposer( // Stop typing indicator stopTyping(); - // Send the message - sendMessage.mutate({ + // Await the send so a failure propagates to useMultimodalInput's + // rollback, restoring the draft text/files instead of losing them. + // onError is fired once by useMultimodalInput's catch, not here. + await sendMessage.mutateAsync({ conversationId, message: messageText, files, @@ -228,9 +230,6 @@ export function useMessageComposer( onSuccess: (resultConversationId, messageId) => { onMessageSent?.(resultConversationId, messageId); }, - onError: (err) => { - onError?.(err); - }, }); }, onError, diff --git a/packages/react/src/hooks/use-send-message.ts b/packages/react/src/hooks/use-send-message.ts index db16b55d..81df3130 100644 --- a/packages/react/src/hooks/use-send-message.ts +++ b/packages/react/src/hooks/use-send-message.ts @@ -1,3 +1,5 @@ +"use client"; + import type { CossistantClient } from "@cossistant/core/client"; import { isImageMimeType, diff --git a/packages/react/src/hooks/use-sound-effect.test.tsx b/packages/react/src/hooks/use-sound-effect.test.tsx new file mode 100644 index 00000000..36955e05 --- /dev/null +++ b/packages/react/src/hooks/use-sound-effect.test.tsx @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type React from "react"; +import { Window } from "../../../../apps/web/node_modules/happy-dom"; +import { type UseSoundEffectReturn, useSoundEffect } from "./use-sound-effect"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; +let originalFetch: typeof fetch; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MutationObserver", + "Node", + "Text", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +class MockSourceNode { + buffer: unknown = null; + loop = false; + playbackRate = { value: 1 }; + onended: (() => void) | null = null; + connect() { + return this; + } + start() {} + stop() {} + disconnect() {} +} + +class MockGainNode { + gain = { value: 1 }; + connect() { + return this; + } +} + +class MockAudioContext { + static instances: MockAudioContext[] = []; + state: "suspended" | "running" | "closed" = "suspended"; + resumeCalls = 0; + closeCalls = 0; + destination = {}; + + constructor() { + MockAudioContext.instances.push(this); + } + + resume() { + this.resumeCalls += 1; + this.state = "running"; + return Promise.resolve(); + } + + close() { + this.closeCalls += 1; + this.state = "closed"; + return Promise.resolve(); + } + + decodeAudioData(_buffer: ArrayBuffer) { + return Promise.resolve({} as AudioBuffer); + } + + createBufferSource() { + return new MockSourceNode(); + } + + createGain() { + return new MockGainNode(); + } +} + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MutationObserver", window.MutationObserver); + setGlobalValue("Node", window.Node); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ); + setGlobalValue("cancelAnimationFrame", (id: number) => + window.clearTimeout(id) + ); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +describe("useSoundEffect", () => { + beforeEach(() => { + windowInstance = new Window({ url: "https://example.com" }); + installDomGlobals(windowInstance); + MockAudioContext.instances = []; + (globalThis.window as unknown as { AudioContext: unknown }).AudioContext = + MockAudioContext; + originalFetch = globalThis.fetch; + setGlobalValue("fetch", () => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)), + }) + ); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + setGlobalValue("fetch", originalFetch); + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + async function renderSound() { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + let result: UseSoundEffectReturn | null = null; + + function Harness() { + result = useSoundEffect("/sounds/notification.wav"); + return null; + } + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render(); + }); + + // Let the fetch/decode promises settle + await act(async () => { + await Promise.resolve(); + }); + + return { + act, + getResult: () => { + if (!result) { + throw new Error("Hook did not render"); + } + return result; + }, + }; + } + + it("resumes a suspended AudioContext before playing", async () => { + const { act, getResult } = await renderSound(); + + expect(getResult().isLoading).toBe(false); + const [context] = MockAudioContext.instances; + expect(context?.state).toBe("suspended"); + + await act(async () => { + getResult().play(); + }); + + expect(context?.resumeCalls).toBe(1); + expect(context?.state).toBe("running"); + expect(getResult().isPlaying).toBe(true); + }); + + it("closes the AudioContext on unmount", async () => { + const { act } = await renderSound(); + const [context] = MockAudioContext.instances; + + await act(async () => { + activeRoot?.unmount(); + }); + activeRoot = null; + + expect(context?.closeCalls).toBe(1); + expect(context?.state).toBe("closed"); + }); +}); diff --git a/packages/react/src/hooks/use-sound-effect.ts b/packages/react/src/hooks/use-sound-effect.ts index a88de4a0..63afb639 100644 --- a/packages/react/src/hooks/use-sound-effect.ts +++ b/packages/react/src/hooks/use-sound-effect.ts @@ -102,6 +102,14 @@ export function useSoundEffect( return; } + // Autoplay policy: contexts created before a user gesture start + // suspended and stay silent until resume() is called. + if (audioContext.state === "suspended") { + void audioContext.resume().catch(() => { + // Resume is rejected until the user interacts with the page + }); + } + // Stop any currently playing sound if (sourceNodeRef.current) { try { @@ -159,6 +167,16 @@ export function useSoundEffect( useEffect( () => () => { stop(); + const audioContext = audioContextRef.current; + audioContextRef.current = null; + audioBufferRef.current = null; + // Browsers cap concurrent AudioContexts, so release the hardware + // handle instead of leaking one per remount + if (audioContext && audioContext.state !== "closed") { + void audioContext.close().catch(() => { + // Ignore errors from contexts already being closed + }); + } }, [stop] ); diff --git a/packages/react/src/hooks/use-transition-swap.test.tsx b/packages/react/src/hooks/use-transition-swap.test.tsx new file mode 100644 index 00000000..ddd8bf21 --- /dev/null +++ b/packages/react/src/hooks/use-transition-swap.test.tsx @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type React from "react"; +import { Window } from "../../../../apps/web/node_modules/happy-dom"; +import { useTransitionSwap } from "./use-transition-swap"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MutationObserver", + "Node", + "Text", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MutationObserver", window.MutationObserver); + setGlobalValue("Node", window.Node); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("requestAnimationFrame", (callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0) + ); + setGlobalValue("cancelAnimationFrame", (id: number) => + window.clearTimeout(id) + ); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +const EXIT_DURATION = 20; + +describe("useTransitionSwap", () => { + beforeEach(() => { + windowInstance = new Window({ url: "https://example.com" }); + installDomGlobals(windowInstance); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + async function setup() { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + let result: ReturnType> = { + displayedKey: "", + phase: "enter", + }; + + function Harness({ activeKey }: { activeKey: string }) { + result = useTransitionSwap(activeKey, EXIT_DURATION); + return null; + } + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + const renderKey = async (key: string) => { + await act(async () => { + activeRoot?.render(); + }); + }; + + const waitForSwap = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION * 3)); + }); + }; + + return { renderKey, waitForSwap, getResult: () => result }; + } + + it("swaps to the new key after the exit duration", async () => { + const { renderKey, waitForSwap, getResult } = await setup(); + + await renderKey("chat"); + expect(getResult()).toEqual({ displayedKey: "chat", phase: "enter" }); + + await renderKey("typing"); + expect(getResult().phase).toBe("exit"); + + await waitForSwap(); + expect(getResult()).toEqual({ displayedKey: "typing", phase: "enter" }); + }); + + it("recovers to the enter phase when the key toggles back before the swap fires", async () => { + const { renderKey, waitForSwap, getResult } = await setup(); + + await renderKey("chat"); + // Toggle away and back within the exit duration + await renderKey("typing"); + await renderKey("chat"); + + await waitForSwap(); + expect(getResult()).toEqual({ displayedKey: "chat", phase: "enter" }); + }); +}); diff --git a/packages/react/src/hooks/use-transition-swap.ts b/packages/react/src/hooks/use-transition-swap.ts index 7b41ee12..016684d2 100644 --- a/packages/react/src/hooks/use-transition-swap.ts +++ b/packages/react/src/hooks/use-transition-swap.ts @@ -24,6 +24,9 @@ export function useTransitionSwap( useEffect(() => { if (activeKey === displayedKey) { + // A pending swap was cancelled (key returned to the displayed one + // before the exit timeout fired) — recover from the exit phase. + setPhase((current) => (current === "exit" ? "enter" : current)); return; } diff --git a/packages/react/src/identify-visitor.tsx b/packages/react/src/identify-visitor.tsx index 1cd981d6..1d48ca4c 100644 --- a/packages/react/src/identify-visitor.tsx +++ b/packages/react/src/identify-visitor.tsx @@ -1,7 +1,7 @@ "use client"; import type { VisitorMetadata } from "@cossistant/types"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useRef, useState } from "react"; import { useVisitor } from "./hooks"; import { useIdentificationState } from "./support/context/identification"; import { computeMetadataHash } from "./utils/metadata-hash"; @@ -44,9 +44,16 @@ export const IdentifySupportVisitor = ({ }: IdentifySupportVisitorProps): ReactElement | null => { const { visitor, identify, setVisitorMetadata } = useVisitor(); const identificationState = useIdentificationState(); - const [hasIdentified, setHasIdentified] = useState(false); + // Key of the payload that last identified successfully. Latching the key + // (instead of a boolean) re-identifies when the payload meaningfully + // changes and never latches on failed or skipped attempts. + const [identifiedKey, setIdentifiedKey] = useState(null); + const identifyInFlightRef = useRef(false); const [lastMetadataHash, setLastMetadataHash] = useState(null); + const identityKey = `${externalId ?? ""}|${email ?? ""}|${name ?? ""}|${image ?? ""}`; + const hasIdentified = identifiedKey === identityKey; + // Signal that identification is pending when we have identification data but no contact yet useEffect(() => { const hasIdentificationData = Boolean(externalId || email); @@ -71,16 +78,39 @@ export const IdentifySupportVisitor = ({ return; } + // Wait for the visitor session to load; identifying earlier would + // silently no-op inside identify(). The effect re-runs once the + // website/visitor becomes available. + if (!visitor) { + return; + } + + // Guard concurrent runs (e.g. StrictMode effect replay) + if (identifyInFlightRef.current) { + return; + } + // Case 1: No contact exists yet - if (!visitor?.contact) { - if (!hasIdentified) { - await identify({ + if (!visitor.contact) { + if (hasIdentified) { + return; + } + + identifyInFlightRef.current = true; + try { + const result = await identify({ externalId, email, name: name ?? undefined, image: image ?? undefined, }); - setHasIdentified(true); + + // Only latch on success so transient failures can retry + if (result) { + setIdentifiedKey(identityKey); + } + } finally { + identifyInFlightRef.current = false; // Clear identifying state after identification completes if (identificationState?.setIsIdentifying) { identificationState.setIsIdentifying(false); @@ -97,24 +127,30 @@ export const IdentifySupportVisitor = ({ const hasChanges = nameChanged || emailChanged || imageChanged; if (hasChanges) { - await identify({ - externalId, - email, - name: name ?? undefined, - image: image ?? undefined, - }); + identifyInFlightRef.current = true; + try { + await identify({ + externalId, + email, + name: name ?? undefined, + image: image ?? undefined, + }); + } finally { + identifyInFlightRef.current = false; + } } }; - shouldIdentify(); + void shouldIdentify(); }, [ - visitor?.contact, + visitor, externalId, email, name, image, identify, hasIdentified, + identityKey, identificationState, ]); diff --git a/packages/react/src/internal/hooks.ts b/packages/react/src/internal/hooks.ts index 66800b7f..f4e07524 100644 --- a/packages/react/src/internal/hooks.ts +++ b/packages/react/src/internal/hooks.ts @@ -1,3 +1,5 @@ +"use client"; + export type { ConversationItem, GroupedActivity, diff --git a/packages/react/src/primitives/avatar/avatar.tsx b/packages/react/src/primitives/avatar/avatar.tsx index f44f7ada..642f218b 100644 --- a/packages/react/src/primitives/avatar/avatar.tsx +++ b/packages/react/src/primitives/avatar/avatar.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { useRenderElement } from "../../utils/use-render-element"; diff --git a/packages/react/src/primitives/avatar/fallback.tsx b/packages/react/src/primitives/avatar/fallback.tsx index f41f39bf..d19fa245 100644 --- a/packages/react/src/primitives/avatar/fallback.tsx +++ b/packages/react/src/primitives/avatar/fallback.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { useRenderElement } from "../../utils/use-render-element"; import { useAvatarContext } from "./avatar"; @@ -73,10 +75,10 @@ export const AvatarFallback = (() => { initials, }; - const shouldRender = - canRender && - imageLoadingStatus !== "loaded" && - imageLoadingStatus !== "loading"; + // Keep showing the fallback while the image is still loading so slow + // connections don't get a blank avatar (delayMs handles fast-load + // flashes) + const shouldRender = canRender && imageLoadingStatus !== "loaded"; const content = children || initials; diff --git a/packages/react/src/primitives/avatar/image.tsx b/packages/react/src/primitives/avatar/image.tsx index 15777311..598a2dd1 100644 --- a/packages/react/src/primitives/avatar/image.tsx +++ b/packages/react/src/primitives/avatar/image.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { useRenderElement } from "../../utils/use-render-element"; import { useAvatarContext } from "./avatar"; diff --git a/packages/react/src/primitives/avatar/index.parts.ts b/packages/react/src/primitives/avatar/index.parts.ts index 975b95ee..69aaca64 100644 --- a/packages/react/src/primitives/avatar/index.parts.ts +++ b/packages/react/src/primitives/avatar/index.parts.ts @@ -1,3 +1,5 @@ +"use client"; + export type { AvatarProps } from "./avatar"; export { Avatar } from "./avatar"; export type { AvatarFallbackProps } from "./fallback"; diff --git a/packages/react/src/primitives/avatar/index.ts b/packages/react/src/primitives/avatar/index.ts index 5806b415..2a9e7fa2 100644 --- a/packages/react/src/primitives/avatar/index.ts +++ b/packages/react/src/primitives/avatar/index.ts @@ -1 +1,3 @@ +"use client"; + export * from "./index.parts"; diff --git a/packages/react/src/primitives/button.tsx b/packages/react/src/primitives/button.tsx index a41b838c..a5e1cfec 100644 --- a/packages/react/src/primitives/button.tsx +++ b/packages/react/src/primitives/button.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { useRenderElement } from "../utils/use-render-element"; diff --git a/packages/react/src/primitives/command-block-utils.test.ts b/packages/react/src/primitives/command-block-utils.test.ts index 9c801655..8ee73e6b 100644 --- a/packages/react/src/primitives/command-block-utils.test.ts +++ b/packages/react/src/primitives/command-block-utils.test.ts @@ -108,4 +108,28 @@ describe("mapCommandVariants", () => { expect(mapCommandVariants("yarn")).toBeNull(); expect(mapCommandVariants("pnpm --help")).toBeNull(); }); + + it("returns null for multi-line snippets starting with a package-manager command", () => { + expect(mapCommandVariants("npm install foo\ncd foo")).toBeNull(); + expect(mapCommandVariants("npm install foo\r\ncd foo")).toBeNull(); + expect( + mapCommandVariants("pnpm add zod\npnpm add @hono/zod-openapi") + ).toBeNull(); + }); + + it("still maps single commands with surrounding whitespace or trailing newline", () => { + expect(mapCommandVariants("npm install zod\n")).toEqual({ + npm: "npm install zod", + yarn: "yarn add zod", + pnpm: "pnpm add zod", + bun: "bun add zod", + }); + + expect(mapCommandVariants(" npm install zod ")).toEqual({ + npm: "npm install zod", + yarn: "yarn add zod", + pnpm: "pnpm add zod", + bun: "bun add zod", + }); + }); }); diff --git a/packages/react/src/primitives/command-block-utils.ts b/packages/react/src/primitives/command-block-utils.ts index 45af090f..78cfc9af 100644 --- a/packages/react/src/primitives/command-block-utils.ts +++ b/packages/react/src/primitives/command-block-utils.ts @@ -35,8 +35,15 @@ export type InlineParagraphCommand = { }; function normalizeInput(input: string): string | null { + // Reject multi-line snippets before collapsing whitespace, otherwise a + // fenced block starting with a package-manager command would be mangled + // into a bogus one-line command + if (/[\n\r]/.test(input.trim())) { + return null; + } + const normalized = input.replace(/\s+/g, " ").trim(); - if (!normalized || normalized.includes("\n") || normalized.includes("\r")) { + if (!normalized) { return null; } diff --git a/packages/react/src/primitives/conversation-timeline-internal.test.ts b/packages/react/src/primitives/conversation-timeline-internal.test.ts index 7a08d255..6ee6d5f0 100644 --- a/packages/react/src/primitives/conversation-timeline-internal.test.ts +++ b/packages/react/src/primitives/conversation-timeline-internal.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, mock } from "bun:test"; import type * as React from "react"; import { composeConversationTimelineScrollHandlers, + isConversationTimelinePrepend, mergeConversationTimelineStyles, } from "./conversation-timeline-internal"; @@ -91,3 +92,47 @@ describe("composeConversationTimelineScrollHandlers", () => { expect(handler).toBe(internal); }); }); + +describe("isConversationTimelinePrepend", () => { + it("detects older items prepended during top pagination", () => { + expect( + isConversationTimelinePrepend({ + previousItemCount: 20, + nextItemCount: 40, + previousLastItemKey: "item-20", + nextLastItemKey: "item-20", + }) + ).toBe(true); + }); + + it("ignores appended items (last item changed)", () => { + expect( + isConversationTimelinePrepend({ + previousItemCount: 20, + nextItemCount: 21, + previousLastItemKey: "item-20", + nextLastItemKey: "item-21", + }) + ).toBe(false); + }); + + it("ignores the initial load and removals", () => { + expect( + isConversationTimelinePrepend({ + previousItemCount: 0, + nextItemCount: 20, + previousLastItemKey: null, + nextLastItemKey: "item-20", + }) + ).toBe(false); + + expect( + isConversationTimelinePrepend({ + previousItemCount: 20, + nextItemCount: 19, + previousLastItemKey: "item-20", + nextLastItemKey: "item-20", + }) + ).toBe(false); + }); +}); diff --git a/packages/react/src/primitives/conversation-timeline-internal.ts b/packages/react/src/primitives/conversation-timeline-internal.ts index d92ec305..48fd47e5 100644 --- a/packages/react/src/primitives/conversation-timeline-internal.ts +++ b/packages/react/src/primitives/conversation-timeline-internal.ts @@ -22,6 +22,31 @@ export function mergeConversationTimelineStyles( }; } +/** + * Detects a top-of-list prepend (older items loaded via pagination): the list + * grew while the last item stayed the same. + */ +export function isConversationTimelinePrepend(params: { + previousItemCount: number; + nextItemCount: number; + previousLastItemKey: string | number | null; + nextLastItemKey: string | number | null; +}): boolean { + const { + previousItemCount, + nextItemCount, + previousLastItemKey, + nextLastItemKey, + } = params; + + return ( + previousItemCount > 0 && + nextItemCount > previousItemCount && + nextLastItemKey !== null && + nextLastItemKey === previousLastItemKey + ); +} + export function composeConversationTimelineScrollHandlers( internalOnScroll: React.UIEventHandler, externalOnScroll?: React.UIEventHandler diff --git a/packages/react/src/primitives/conversation-timeline.tsx b/packages/react/src/primitives/conversation-timeline.tsx index ca893648..9543ca7d 100644 --- a/packages/react/src/primitives/conversation-timeline.tsx +++ b/packages/react/src/primitives/conversation-timeline.tsx @@ -1,9 +1,12 @@ +"use client"; + import type { TimelineItem as TimelineItemType } from "@cossistant/types/api/timeline-item"; import * as React from "react"; import { useScrollMask } from "../hooks/use-scroll-mask"; import { useRenderElement } from "../utils/use-render-element"; import { composeConversationTimelineScrollHandlers, + isConversationTimelinePrepend, mergeConversationTimelineStyles, } from "./conversation-timeline-internal"; @@ -124,6 +127,32 @@ export const ConversationTimeline = (() => { const lastItemKey = getLastItemKey(items); + // Preserve the scroll position when older items are prepended via top + // pagination. Runs on every commit (before the auto-scroll effect + // updates the previous-* refs) so the stored scrollHeight is always + // the one from the last paint. + const previousScrollHeightRef = React.useRef(0); + React.useLayoutEffect(() => { + const element = internalRef.current; + if (!element) { + return; + } + + const prepended = isConversationTimelinePrepend({ + previousItemCount: previousItemCount.current, + nextItemCount: items.length, + previousLastItemKey: previousLastItemKey.current, + nextLastItemKey: lastItemKey, + }); + + if (prepended && !isPinnedToBottom.current) { + element.scrollTop += + element.scrollHeight - previousScrollHeightRef.current; + } + + previousScrollHeightRef.current = element.scrollHeight; + }); + // Auto-scroll to bottom when new timeline items are added React.useEffect(() => { const element = internalRef.current; diff --git a/packages/react/src/primitives/day-separator.test.tsx b/packages/react/src/primitives/day-separator.test.tsx new file mode 100644 index 00000000..ef4ca8e1 --- /dev/null +++ b/packages/react/src/primitives/day-separator.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { DaySeparator } from "./day-separator"; + +describe("DaySeparator SSR output", () => { + it("renders a deterministic absolute date on the server instead of Today", () => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const html = renderToStaticMarkup( + + {({ formattedDate, isToday }) => ( + {formattedDate} + )} + + ); + + expect(html).not.toContain("Today"); + expect(html).toContain('data-is-today="false"'); + expect(html).toContain( + today.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }) + ); + }); + + it("applies consumer-provided formatters on the server as-is", () => { + const html = renderToStaticMarkup( + "custom label"} + /> + ); + + expect(html).toContain("custom label"); + }); +}); diff --git a/packages/react/src/primitives/day-separator.tsx b/packages/react/src/primitives/day-separator.tsx index d482c3c0..dc608da5 100644 --- a/packages/react/src/primitives/day-separator.tsx +++ b/packages/react/src/primitives/day-separator.tsx @@ -1,6 +1,34 @@ +"use client"; + import * as React from "react"; import { useRenderElement } from "../utils/use-render-element"; +const emptySubscribe = () => () => { + // No-op: only used to detect hydration via useSyncExternalStore +}; + +/** + * False on the server and during the hydration render, true afterwards. + */ +function useIsHydrated(): boolean { + return React.useSyncExternalStore( + emptySubscribe, + () => true, + () => false + ); +} + +/** + * Deterministic absolute date label used for SSR and the hydration render so + * server markup never depends on the server clock, timezone offset or locale. + */ +const formatAbsoluteDate = (date: Date): string => + date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + /** * Helper to check if a date is today */ @@ -87,9 +115,19 @@ export const DaySeparator = (() => { }, ref ) => { - const formattedDate = formatDate(date); - const isTodayValue = isToday(date); - const isYesterdayValue = isYesterday(date); + const isHydrated = useIsHydrated(); + + // Before hydration the default formatter would compare against the + // server clock and use the server locale, mismatching the client's + // first render. Render a deterministic absolute date instead. + // Consumer-provided formatters are applied as-is. + const useDeterministicLabel = + !isHydrated && formatDate === defaultFormatDate; + const formattedDate = useDeterministicLabel + ? formatAbsoluteDate(date) + : formatDate(date); + const isTodayValue = isHydrated && isToday(date); + const isYesterdayValue = isHydrated && isYesterday(date); const renderProps: DaySeparatorRenderProps = { date, diff --git a/packages/react/src/primitives/feedback-comment-input.tsx b/packages/react/src/primitives/feedback-comment-input.tsx index 58055115..9195f1f8 100644 --- a/packages/react/src/primitives/feedback-comment-input.tsx +++ b/packages/react/src/primitives/feedback-comment-input.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { cn } from "../support/utils"; diff --git a/packages/react/src/primitives/feedback-controls.test.tsx b/packages/react/src/primitives/feedback-controls.test.tsx index b5562512..78046740 100644 --- a/packages/react/src/primitives/feedback-controls.test.tsx +++ b/packages/react/src/primitives/feedback-controls.test.tsx @@ -51,11 +51,59 @@ describe("feedback primitives", () => { thirdButton?.props.onMouseLeave(); expect(onBlur).toHaveBeenCalledTimes(1); - expect(onHoverChange).toHaveBeenNthCalledWith(1, 3); - expect(onHoverChange).toHaveBeenNthCalledWith(2, null); + // Blur clears the hover preview before forwarding the event. + expect(onHoverChange).toHaveBeenNthCalledWith(1, null); + expect(onHoverChange).toHaveBeenNthCalledWith(2, 3); + expect(onHoverChange).toHaveBeenNthCalledWith(3, null); expect(onSelect).toHaveBeenCalledWith(3); }); + it("mirrors the hover preview on focus", () => { + const onHoverChange = mock(() => {}); + const element = FeedbackRatingSelector({ + value: 2, + onHoverChange, + }); + const buttons = getElementChildren(element); + const fourthButton = buttons[3]; + + fourthButton?.props.onFocus(); + + expect(onHoverChange).toHaveBeenCalledWith(4); + }); + + it("exposes radiogroup semantics with the checked rating", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="radiogroup"'); + expect(html).toContain('aria-label="Rating"'); + expect(countOccurrences(html, 'role="radio"')).toBe(5); + expect(countOccurrences(html, 'aria-checked="true"')).toBe(1); + expect(countOccurrences(html, 'aria-checked="false"')).toBe(4); + // Roving tabindex: only the selected star is tabbable. + expect(countOccurrences(html, 'tabindex="0"')).toBe(1); + }); + + it("moves the selection with arrow keys", () => { + const onSelect = mock(() => {}); + const element = FeedbackRatingSelector({ + value: 2, + onSelect, + }); + const buttons = getElementChildren(element); + const secondButton = buttons[1]; + const keyboardEvent = { + currentTarget: { parentElement: null }, + key: "ArrowRight", + preventDefault: mock(() => {}), + }; + + secondButton?.props.onKeyDown(keyboardEvent); + + expect(onSelect).toHaveBeenCalledWith(3); + expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + it("renders the topic select through the shared primitive", () => { const html = renderToStaticMarkup( diff --git a/packages/react/src/primitives/feedback-rating-selector.tsx b/packages/react/src/primitives/feedback-rating-selector.tsx index d369c14b..569ac43c 100644 --- a/packages/react/src/primitives/feedback-rating-selector.tsx +++ b/packages/react/src/primitives/feedback-rating-selector.tsx @@ -1,3 +1,5 @@ +"use client"; + import type * as React from "react"; import { Icon } from "../support/components/icons"; import { cn } from "../support/utils"; @@ -18,8 +20,28 @@ export type FeedbackRatingSelectorProps = { iconClassName?: string; size?: FeedbackRatingSelectorSize; labelForRating?: (value: number) => string; + "aria-label"?: string; }; +function getNextRating(current: number | null, key: string): number | null { + const base = current ?? 0; + + switch (key) { + case "ArrowRight": + case "ArrowDown": + return base >= STAR_COUNT ? 1 : base + 1; + case "ArrowLeft": + case "ArrowUp": + return base <= 1 ? STAR_COUNT : base - 1; + case "Home": + return 1; + case "End": + return STAR_COUNT; + default: + return null; + } +} + export function FeedbackRatingSelector({ value, hoveredValue = null, @@ -32,20 +54,41 @@ export function FeedbackRatingSelector({ iconClassName, size = "md", labelForRating = (rating) => `Rate ${rating} out of ${STAR_COUNT}`, + "aria-label": ariaLabel = "Rating", }: FeedbackRatingSelectorProps): React.ReactElement { const displayRating = hoveredValue ?? value; + // Roving tabindex: the selected star (or the first when nothing is + // selected) is the group's single tab stop. + const tabbableRating = value ?? 1; + + const handleKeyDown = (event: React.KeyboardEvent) => { + const nextRating = getNextRating(value, event.key); + if (nextRating == null) { + return; + } + + event.preventDefault(); + onSelect?.(nextRating); + event.currentTarget.parentElement + ?.querySelector(`[data-rating-value="${nextRating}"]`) + ?.focus(); + }; return (
{Array.from({ length: STAR_COUNT }).map((_, index) => { const ratingValue = index + 1; const isFilled = displayRating ? ratingValue <= displayRating : false; return ( + // biome-ignore lint/a11y/useSemanticElements: styled star buttons implement the WAI-ARIA radiogroup pattern + + ); + + const { act } = await import("react"); + await act(async () => { + getTrigger().click(); + }); + + expect(childOnClick).toHaveBeenCalledTimes(1); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react/src/primitives/trigger.tsx b/packages/react/src/primitives/trigger.tsx index 3dcdbc1d..3986c0af 100644 --- a/packages/react/src/primitives/trigger.tsx +++ b/packages/react/src/primitives/trigger.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { useStoreSelector } from "../hooks/private/store/use-store-selector"; import { isWidgetVisibleTypingEntry } from "../hooks/private/typing"; @@ -165,6 +167,18 @@ export const SupportTrigger = React.forwardRef( const content = typeof children === "function" ? children(renderProps) : children; + // Compose the consumer handler with the internal toggle instead of + // letting one silently replace the other + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + toggle(); + } + }, + [onClick, toggle] + ); + return useRenderElement( "button", { @@ -178,7 +192,10 @@ export const SupportTrigger = React.forwardRef( type: "button", "aria-haspopup": "dialog", "aria-expanded": isOpen, - onClick: onClick ?? toggle, + // Matches SupportWindow's default id; override via props when + // a custom window id is used + "aria-controls": "cossistant-window", + onClick: handleClick, ...props, children: content, }, diff --git a/packages/react/src/primitives/window.test.tsx b/packages/react/src/primitives/window.test.tsx new file mode 100644 index 00000000..7e77f659 --- /dev/null +++ b/packages/react/src/primitives/window.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SupportWindow } from "./window"; + +describe("SupportWindow primitive", () => { + it("renders outside SupportProvider with controlled props", () => { + const html = renderToStaticMarkup( + {}}> + {({ isOpen }) => {isOpen ? "Open" : "Closed"}} + + ); + + expect(html).toContain("Open"); + expect(html).toContain('role="dialog"'); + }); + + it("stays hidden outside SupportProvider when closed", () => { + const html = renderToStaticMarkup( + {}}> + Hidden + + ); + + expect(html).toBe(""); + }); + + it("exposes a default accessible name and id", () => { + const html = renderToStaticMarkup( + {}}> + Body + + ); + + expect(html).toContain('aria-label="Support"'); + expect(html).toContain('id="cossistant-window"'); + }); + + it("lets consumers override the accessible name", () => { + const html = renderToStaticMarkup( + {}} + > + Body + + ); + + expect(html).toContain('aria-label="Chat with us"'); + expect(html).not.toContain('aria-label="Support"'); + }); +}); diff --git a/packages/react/src/primitives/window.tsx b/packages/react/src/primitives/window.tsx index a1555bb4..5b3eb31b 100644 --- a/packages/react/src/primitives/window.tsx +++ b/packages/react/src/primitives/window.tsx @@ -1,5 +1,7 @@ +"use client"; + import * as React from "react"; -import { useSupportConfig } from "../support/store/support-store"; +import { useOptionalSupportConfig } from "../support/store/support-store"; import { useRenderElement } from "../utils/use-render-element"; export type WindowRenderProps = { @@ -89,11 +91,13 @@ export const SupportWindow = (() => { }, ref ) => { - const { isOpen, close } = useSupportConfig(); + // Optional so controlled usage (isOpen/onOpenChange) works without a provider + const supportConfig = useOptionalSupportConfig(); + const close = supportConfig?.close; const containerRef = React.useRef(null); const previouslyFocusedRef = React.useRef(null); - const open = isOpenProp ?? isOpen ?? false; + const open = isOpenProp ?? supportConfig?.isOpen ?? false; const closeFn = React.useCallback(() => { if (onOpenChange) { @@ -140,7 +144,12 @@ export const SupportWindow = (() => { return; } const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { + // Only close when focus is within the widget so Escape keeps + // working for host-page modals while the floating widget is open + if ( + e.key === "Escape" && + containerRef.current?.contains(document.activeElement) + ) { closeFn(); } }; @@ -164,6 +173,14 @@ export const SupportWindow = (() => { return; } + const active = document.activeElement; + + // The floating widget is non-modal: only wrap focus while it is + // already inside the widget, never hijack Tab on the host page + if (!container.contains(active)) { + return; + } + const focusable = getFocusableElements(container); if (focusable.length === 0) { e.preventDefault(); @@ -172,7 +189,6 @@ export const SupportWindow = (() => { const first = focusable[0]; const last = focusable.at(-1); - const active = document.activeElement; // Shift+Tab from first element wraps to last if (e.shiftKey && active === first) { @@ -185,13 +201,6 @@ export const SupportWindow = (() => { if (!e.shiftKey && active === last) { e.preventDefault(); first?.focus(); - return; - } - - // If focus is outside the container, bring it back - if (!container.contains(active)) { - e.preventDefault(); - first?.focus(); } }; @@ -229,6 +238,7 @@ export const SupportWindow = (() => { props: { role: "dialog", "aria-modal": "true", + "aria-label": "Support", id, tabIndex: -1, // Allow container to receive focus ...props, diff --git a/packages/react/src/provider.controller-regression.test.ts b/packages/react/src/provider.controller-regression.test.ts index 7df87506..24920987 100644 --- a/packages/react/src/provider.controller-regression.test.ts +++ b/packages/react/src/provider.controller-regression.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Window } from "../../../apps/web/node_modules/happy-dom"; +import { IdentifySupportVisitor } from "./identify-visitor"; import { SupportProvider, useSupport } from "./provider"; import { processingStoreSingleton } from "./realtime/processing-store"; import { seenStoreSingleton } from "./realtime/seen-store"; @@ -272,4 +273,286 @@ describe("provider controller regression coverage", () => { restoreGlobals(); } }); + + it("skips owned controller creation for injected controllers and revives after deferred destroy", () => { + const source = readFileSync( + new URL("./provider.tsx", import.meta.url), + "utf8" + ); + + // C-44: no orphan owned controller when a controller is injected + expect(source).toMatch(/if \(externalController\) \{\s*return null;/); + // C-46: a controller whose deferred destroy already ran is recreated + // instead of being started as a permanent no-op + expect(source).toContain("destroyedOwnedControllersRef"); + expect(source).toContain( + "setOwnedGeneration((generation) => generation + 1)" + ); + }); + + it("does not force-close an open widget when unstable props change identity", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const windowInstance = new Window({ + url: "https://example.com", + }); + const restoreGlobals = installDomGlobals( + windowInstance, + createWebsiteFetchMock() + ); + const mountNode = document.createElement("div"); + const root = createRoot(mountNode) as RootHandle; + let support: ReturnType | null = null; + + function Harness() { + const current = useSupport(); + support = current; + + return React.createElement("div", { + "data-open": String(current.isOpen), + }); + } + + const renderProvider = () => + React.createElement( + SupportProvider, + { + autoConnect: false, + // New identities on every render, mirroring inline consumer JSX + defaultMessages: [], + defaultOpen: false, + onWsConnect: () => {}, + publicKey: "pk_test_widget", + }, + React.createElement(Harness) + ); + + try { + document.body.appendChild(mountNode); + + await act(async () => { + root.render(renderProvider()); + }); + + await act(async () => { + support?.open(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + mountNode.querySelector("[data-open]")?.getAttribute("data-open") + ).toBe("true"); + + await act(async () => { + root.render(renderProvider()); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + mountNode.querySelector("[data-open]")?.getAttribute("data-open") + ).toBe("true"); + } finally { + await act(async () => { + root.unmount(); + }); + mountNode.remove(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + restoreGlobals(); + } + }); + + it("restores persisted open state after mount without reading storage during render", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const windowInstance = new Window({ + url: "https://example.com", + }); + windowInstance.localStorage.setItem( + "cossistant-support-store", + JSON.stringify({ + config: { isOpen: true, size: "normal" }, + navigation: { current: { page: "HOME" }, previousPages: [] }, + }) + ); + const restoreGlobals = installDomGlobals( + windowInstance, + createWebsiteFetchMock() + ); + const mountNode = document.createElement("div"); + const root = createRoot(mountNode) as RootHandle; + + function Harness() { + const current = useSupport(); + + return React.createElement("div", { + "data-open": String(current.isOpen), + }); + } + + const buildElement = () => + React.createElement( + SupportProvider, + { + autoConnect: false, + publicKey: "pk_test_widget", + }, + React.createElement(Harness) + ); + + try { + // Render output matches the server: persisted state must not be + // read during render (avoids SSR hydration mismatches). + expect(renderToStaticMarkup(buildElement())).toContain( + 'data-open="false"' + ); + + document.body.appendChild(mountNode); + + await act(async () => { + root.render(buildElement()); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + mountNode.querySelector("[data-open]")?.getAttribute("data-open") + ).toBe("true"); + } finally { + await act(async () => { + root.unmount(); + }); + mountNode.remove(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + restoreGlobals(); + } + }); + + it("identifies the visitor once the website loads, and only once", async () => { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const windowInstance = new Window({ + url: "https://example.com", + }); + + let releaseWebsite: (() => void) | undefined; + const websiteGate = new Promise((resolve) => { + releaseWebsite = resolve; + }); + let identifyCalls = 0; + let contact: Record | null = null; + + const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status: 200, + }); + + const fetchMock: typeof fetch = async (input) => { + const url = String(input); + + if (url.includes("/contacts/identify")) { + identifyCalls += 1; + contact = { + createdAt: "2026-01-01T00:00:00.000Z", + email: "visitor@example.com", + id: "contact_123", + image: null, + metadata: {}, + metadataHash: "", + name: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + return jsonResponse({ contact, visitorId: "visitor_123" }); + } + + if (url.includes("/websites")) { + await websiteGate; + return jsonResponse({ + availableAIAgents: [], + availableHumanAgents: [], + defaultLanguage: "en", + description: null, + domain: "acme.test", + id: "website_123", + lastOnlineAt: null, + logoUrl: null, + name: "Acme", + organizationId: "org_123", + status: "online", + visitor: { + contact, + id: "visitor_123", + isBlocked: false, + language: "en", + }, + }); + } + + return jsonResponse({}); + }; + + const restoreGlobals = installDomGlobals(windowInstance, fetchMock); + const mountNode = document.createElement("div"); + const root = createRoot(mountNode) as RootHandle; + + const flush = async () => { + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + }; + + try { + document.body.appendChild(mountNode); + + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement( + SupportProvider, + { + autoConnect: false, + publicKey: "pk_test_widget", + }, + React.createElement(IdentifySupportVisitor, { + email: "visitor@example.com", + externalId: "user_123", + }) + ) + ) + ); + await flush(); + }); + + // Website not loaded yet: identification must neither run nor latch + expect(identifyCalls).toBe(0); + + await act(async () => { + releaseWebsite?.(); + await flush(); + await flush(); + await flush(); + }); + + expect(identifyCalls).toBe(1); + + // No duplicate identify calls on later effect replays/re-renders + await act(async () => { + await flush(); + }); + + expect(identifyCalls).toBe(1); + } finally { + await act(async () => { + root.unmount(); + }); + mountNode.remove(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + restoreGlobals(); + } + }); }); diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index f5453720..ea67db56 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -5,23 +5,32 @@ import type { CossistantClientOptions, } from "@cossistant/core/client"; import { normalizeLocale } from "@cossistant/core/locale-utils"; +import type { + SupportConfig, + SupportStoreState, + SupportStoreStorage, +} from "@cossistant/core/store/support-store"; import type { AnySupportConfig } from "@cossistant/core/support-config"; import { createSupportController, type SupportController, type SupportControllerConfigurationError, + type SupportControllerOptions, + type SupportControllerSnapshot, } from "@cossistant/core/support-controller"; import type { DefaultMessage } from "@cossistant/types"; import type { PublicWebsiteResponse } from "@cossistant/types/api/website"; import React from "react"; -import { SupportControllerContext } from "./controller-context"; +import { + SupportControllerContext, + useOptionalSupportController, +} from "./controller-context"; import { useStoreSelector } from "./hooks/private/store/use-store-selector"; import { processingStoreSingleton } from "./realtime/processing-store"; import { seenStoreSingleton } from "./realtime/seen-store"; import { typingStoreSingleton } from "./realtime/typing-store"; import { IdentificationProvider } from "./support/context/identification"; import { WebSocketProvider } from "./support/context/websocket"; -import { useSupportStore } from "./support/store/support-store"; export type SupportProviderProps = { children?: React.ReactNode; @@ -181,6 +190,172 @@ export function useOptionalSupportContext(): CossistantContextValue | null { return React.useContext(SupportContext) ?? null; } +// Mirrors the persisted payload written by @cossistant/core's support store +// (see core/src/store/support-store.ts persistState). The key and shape are +// frozen: changing them in core would already break existing visitors. +const SUPPORT_STORE_STORAGE_KEY = "cossistant-support-store"; + +type PersistedSupportState = { + navigation?: SupportStoreState["navigation"]; + config?: Partial>; +}; + +type DeferredSupportStorage = SupportStoreStorage & { + activate: () => void; +}; + +/** + * Storage adapter that stays inert until activated after mount. The server + * render and the first client render therefore see identical (default) state + * — avoiding hydration mismatches — and render stays free of localStorage + * side effects. Persisted state is merged back in an effect. + */ +function createDeferredBrowserStorage(): DeferredSupportStorage { + let active = false; + + const resolveStorage = () => + typeof window === "undefined" ? null : window.localStorage; + + return { + getItem(key) { + return active ? (resolveStorage()?.getItem(key) ?? null) : null; + }, + setItem(key, value) { + if (active) { + resolveStorage()?.setItem(key, value); + } + }, + removeItem(key) { + if (active) { + resolveStorage()?.removeItem(key); + } + }, + activate() { + active = true; + }, + }; +} + +function readPersistedSupportState(): PersistedSupportState | null { + if (typeof window === "undefined") { + return null; + } + + try { + const raw = window.localStorage.getItem(SUPPORT_STORE_STORAGE_KEY); + return raw ? (JSON.parse(raw) as PersistedSupportState) : null; + } catch { + return null; + } +} + +const UPDATABLE_OPTION_KEYS = [ + "autoConnect", + "defaultMessages", + "quickOptions", + "size", + "defaultOpen", + "onWsConnect", + "onWsDisconnect", + "onWsError", + "support", +] as const; + +type UpdatableOptionKey = (typeof UPDATABLE_OPTION_KEYS)[number]; + +type UpdatableOptions = Pick; + +function setUpdatableOption( + patch: Partial, + key: K, + value: UpdatableOptions[K] +): void { + patch[key] = value; +} + +type OwnedControllerHandle = { + controller: SupportController; + storage: DeferredSupportStorage; + // Options already applied at creation; the options effect only pushes + // values that changed relative to the last applied ones. + appliedOptions: UpdatableOptions; + hydrated: boolean; +}; + +/** + * Merges the persisted widget state into a freshly created owned controller + * after mount. Persisted state wins over `defaultOpen` (a default only for + * visitors without stored state); an explicit `size` prop stays authoritative. + */ +function hydrateOwnedController(handle: OwnedControllerHandle): void { + if (handle.hydrated) { + return; + } + + handle.hydrated = true; + handle.storage.activate(); + + const persisted = readPersistedSupportState(); + if (!persisted) { + return; + } + + const explicitSize = handle.appliedOptions.size; + + handle.controller.supportStore.setState((current) => { + const config = { ...current.config }; + + if (typeof persisted.config?.isOpen === "boolean") { + config.isOpen = persisted.config.isOpen; + } + + if (persisted.config?.size !== undefined && explicitSize === undefined) { + config.size = persisted.config.size; + } + + const navigation = persisted.navigation?.current + ? { + current: persisted.navigation.current, + previousPages: persisted.navigation.previousPages ?? [], + } + : current.navigation; + + return { config, navigation }; + }); +} + +function resolveActiveController( + externalController: SupportController | undefined, + owned: OwnedControllerHandle | null +): SupportController { + if (externalController) { + return externalController; + } + + if (!owned) { + throw new Error("SupportProvider could not resolve a support controller"); + } + + return owned.controller; +} + +function shallowEqual>( + previous: T, + next: T +): boolean { + if (Object.is(previous, next)) { + return true; + } + + const keys = Object.keys(previous) as (keyof T)[]; + + if (keys.length !== Object.keys(next).length) { + return false; + } + + return keys.every((key) => Object.is(previous[key], next[key])); +} + /** * Internal implementation that wires the REST client and websocket provider * together before exposing the combined context. @@ -198,46 +373,108 @@ function SupportProviderInner({ onWsConnect, onWsDisconnect, onWsError, - size = "normal", - defaultOpen = false, + size, + defaultOpen, }: SupportProviderProps) { - const ownedController = React.useMemo( - () => - createSupportController({ + // Bumped to recreate the owned controller when a deferred destroy already + // ran (e.g. an offscreen/Activity remount past one macrotask). + const [ownedGeneration, setOwnedGeneration] = React.useState(0); + + // Only recreated when the connection identity changes; every other option + // (including `support`) is synced in place through updateOptions. + const owned = React.useMemo(() => { + if (externalController) { + return null; + } + + const storage = createDeferredBrowserStorage(); + const appliedOptions: UpdatableOptions = { + autoConnect, + defaultMessages, + defaultOpen, + onWsConnect, + onWsDisconnect, + onWsError, + quickOptions, + size, + support, + }; + + return { + appliedOptions, + controller: createSupportController({ apiUrl, - wsUrl, - publicKey, - support, - clientOptions: sharedClientOptions, autoConnect, + clientOptions: sharedClientOptions, defaultMessages: defaultMessages ?? [], - quickOptions: quickOptions ?? [], - size, defaultOpen, onWsConnect, onWsDisconnect, onWsError, + publicKey, + quickOptions: quickOptions ?? [], + size, + storage, + support, + wsUrl, }), - [apiUrl, publicKey, support, wsUrl] - ); - const controller = externalController ?? ownedController; + hydrated: false, + storage, + }; + }, [apiUrl, externalController, ownedGeneration, publicKey, wsUrl]); + const controller = resolveActiveController(externalController, owned); const ownsController = externalController === undefined; const pendingOwnedControllerDisposalsRef = React.useRef( new Map>() ); + const destroyedOwnedControllersRef = React.useRef( + new WeakSet() + ); + const lastAppliedOptionsRef = React.useRef<{ + controller: SupportController; + options: UpdatableOptions; + } | null>(null); React.useEffect(() => { - controller.updateOptions({ + const next: UpdatableOptions = { autoConnect, - defaultMessages: defaultMessages ?? [], - quickOptions: quickOptions ?? [], - size, + defaultMessages, defaultOpen, onWsConnect, onWsDisconnect, onWsError, + quickOptions, + size, support, - }); + }; + const lastApplied = lastAppliedOptionsRef.current; + let previous: UpdatableOptions = {}; + + if (lastApplied?.controller === controller) { + previous = lastApplied.options; + } else if (owned?.controller === controller) { + previous = owned.appliedOptions; + } + + // Only push options the consumer explicitly provided and that changed + // since they were last applied. Re-pushing defaults would stomp runtime + // state (e.g. `defaultOpen` force-closing an open widget) and external + // controller configuration. + const patch: Partial = {}; + + for (const key of UPDATABLE_OPTION_KEYS) { + const value = next[key]; + + if (value !== undefined && value !== previous[key]) { + setUpdatableOption(patch, key, value); + } + } + + lastAppliedOptionsRef.current = { controller, options: next }; + + if (Object.keys(patch).length > 0) { + controller.updateOptions(patch); + } }, [ autoConnect, controller, @@ -246,43 +483,75 @@ function SupportProviderInner({ onWsConnect, onWsDisconnect, onWsError, + owned, quickOptions, size, support, ]); React.useEffect(() => { - const pendingDisposal = - pendingOwnedControllerDisposalsRef.current.get(controller); + const pendingDisposals = pendingOwnedControllerDisposalsRef.current; + const pendingDisposal = pendingDisposals.get(controller); if (pendingDisposal !== undefined) { globalThis.clearTimeout(pendingDisposal); - pendingOwnedControllerDisposalsRef.current.delete(controller); + pendingDisposals.delete(controller); + } + + if (destroyedOwnedControllersRef.current.has(controller)) { + // The deferred destroy already ran, so this controller can never be + // started again — recreate it instead of mounting a dead widget. + setOwnedGeneration((generation) => generation + 1); + return; + } + + if (owned?.controller === controller) { + hydrateOwnedController(owned); } controller.start(); return () => { - if (ownsController) { - const pendingDisposals = pendingOwnedControllerDisposalsRef.current; - const existingDisposal = pendingDisposals.get(controller); - - if (existingDisposal !== undefined) { - globalThis.clearTimeout(existingDisposal); - } + if (!ownsController) { + return; + } - const timeoutId = globalThis.setTimeout(() => { - pendingDisposals.delete(controller); - controller.destroy(); - }, 0); + const existingDisposal = pendingDisposals.get(controller); - pendingDisposals.set(controller, timeoutId); + if (existingDisposal !== undefined) { + globalThis.clearTimeout(existingDisposal); } + + const timeoutId = globalThis.setTimeout(() => { + pendingDisposals.delete(controller); + destroyedOwnedControllersRef.current.add(controller); + controller.destroy(); + }, 0); + + pendingDisposals.set(controller, timeoutId); }; - }, [controller, ownsController]); + }, [controller, owned, ownsController]); + // Narrow selection + shallow equality so unrelated store changes (e.g. + // widget navigation) do not churn the context value and re-render every + // useSupport consumer. const snapshot = useStoreSelector( controller, - React.useCallback((state) => state, []) + React.useCallback( + (state: SupportControllerSnapshot) => ({ + client: state.client, + configurationError: state.configurationError, + defaultMessages: state.defaultMessages, + error: state.error, + isLoading: state.isLoading, + isOpen: state.isOpen, + isVisitorBlocked: state.isVisitorBlocked, + quickOptions: state.quickOptions, + unreadCount: state.unreadCount, + website: state.website, + }), + [] + ), + shallowEqual ); const value = React.useMemo( @@ -312,7 +581,7 @@ function SupportProviderInner({ + state?.size ?? "normal"; + /** * Convenience hook that exposes the aggregated support context. Throws when it * is consumed outside of `SupportProvider` to catch integration mistakes. */ export function useSupport(): UseSupportValue { const context = useOptionalSupportContext(); + const controller = useOptionalSupportController(); + // Narrow size selection so navigation/open-state changes alone do not + // re-render every useSupport consumer. + const size = useStoreSelector(controller, selectWidgetSize); + if (!context) { throw new Error( "useSupport must be used within a cossistant SupportProvider" @@ -395,9 +672,6 @@ export function useSupport(): UseSupportValue { const availableAIAgents = context.website?.availableAIAgents || []; const visitorLanguage = context.website?.visitor?.language || null; - // Get additional config from support store - const { config } = useSupportStore(); - // Create visitor object with normalized locale const visitor = context.website?.visitor ? { @@ -411,7 +685,7 @@ export function useSupport(): UseSupportValue { availableHumanAgents, availableAIAgents, visitor, - size: config.size, + size, }; } diff --git a/packages/react/src/realtime/provider.lifecycle.test.tsx b/packages/react/src/realtime/provider.lifecycle.test.tsx new file mode 100644 index 00000000..9bdb4c2d --- /dev/null +++ b/packages/react/src/realtime/provider.lifecycle.test.tsx @@ -0,0 +1,388 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { RealtimeAuthConfig } from "@cossistant/core/realtime-client"; +import * as React from "react"; +import { Window } from "../../../../apps/web/node_modules/happy-dom"; +import { RealtimeProvider, useRealtimeConnection } from "./provider"; +import { useRealtime } from "./use-realtime"; + +type RootHandle = { + render(node: React.ReactNode): void; + unmount(): void; +}; + +const installedGlobalKeys = [ + "window", + "self", + "document", + "navigator", + "Document", + "DocumentFragment", + "Element", + "Event", + "EventTarget", + "HTMLElement", + "MouseEvent", + "Node", + "SyntaxError", + "Text", + "getComputedStyle", + "IS_REACT_ACT_ENVIRONMENT", + "WebSocket", +] as const; + +let activeRoot: RootHandle | null = null; +let mountNode: HTMLElement | null = null; +let windowInstance: Window | null = null; + +function setGlobalValue(key: string, value: unknown) { + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); +} + +function installDomGlobals(window: Window) { + (window as Window & { SyntaxError?: typeof Error }).SyntaxError = Error; + setGlobalValue("window", window); + setGlobalValue("self", window); + setGlobalValue("document", window.document); + setGlobalValue("navigator", window.navigator); + setGlobalValue("Document", window.Document); + setGlobalValue("DocumentFragment", window.DocumentFragment); + setGlobalValue("Element", window.Element); + setGlobalValue("Event", window.Event); + setGlobalValue("EventTarget", window.EventTarget); + setGlobalValue("HTMLElement", window.HTMLElement); + setGlobalValue("MouseEvent", window.MouseEvent); + setGlobalValue("Node", window.Node); + setGlobalValue("SyntaxError", Error); + setGlobalValue("Text", window.Text); + setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window)); + setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true); +} + +// -- Mock WebSocket --------------------------------------------------------- + +type MockWebSocketInstance = { + url: string; + readyState: number; + onopen: ((event: Event) => void) | null; + onclose: ((event: CloseEvent) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: Event) => void) | null; + close: ReturnType; + send: ReturnType; + simulateOpen(): void; + simulateMessage(data: string): void; +}; + +let mockSockets: MockWebSocketInstance[] = []; + +function createMockWebSocket(url: string): MockWebSocketInstance { + const instance: MockWebSocketInstance = { + url, + readyState: 0, + onopen: null, + onclose: null, + onmessage: null, + onerror: null, + close: mock(function (this: MockWebSocketInstance) { + this.readyState = 3; + }), + send: mock(() => {}), + simulateOpen() { + this.readyState = 1; + this.onopen?.({} as Event); + }, + simulateMessage(data: string) { + this.onmessage?.({ data } as MessageEvent); + }, + }; + mockSockets.push(instance); + return instance; +} + +function installMockWebSocket() { + setGlobalValue( + "WebSocket", + class MockWS { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + constructor(url: string) { + const inst = createMockWebSocket(url); + return inst as unknown as WebSocket; + } + } + ); +} + +function lastSocket(): MockWebSocketInstance { + const socket = mockSockets[mockSockets.length - 1]; + if (!socket) { + throw new Error("No mock sockets created"); + } + return socket; +} + +// -- Harness ---------------------------------------------------------------- + +const VISITOR_AUTH: RealtimeAuthConfig = { + kind: "visitor", + visitorId: "vis_123", + websiteId: "ws_456", + publicKey: "pk_test", +}; + +const EVENT_MESSAGE = JSON.stringify({ + type: "conversationUpdated", + payload: { + conversationId: "conv_1", + updates: { title: "New title" }, + organizationId: "org_1", + websiteId: "ws_456", + visitorId: "vis_123", + userId: "", + aiAgentId: "", + }, +}); + +async function render(node: React.ReactNode) { + const { act } = await import("react"); + const { createRoot } = await import("react-dom/client"); + + mountNode = document.createElement("div"); + document.body.appendChild(mountNode); + activeRoot = createRoot(mountNode); + + await act(async () => { + activeRoot?.render(node); + }); +} + +async function rerender(node: React.ReactNode) { + const { act } = await import("react"); + await act(async () => { + activeRoot?.render(node); + }); +} + +async function flushMacrotasks() { + const { act } = await import("react"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + +function getBySlot(slot: string): HTMLElement { + const element = document.querySelector(`[data-slot="${slot}"]`); + + if (!element) { + throw new Error(`Could not find [data-slot="${slot}"]`); + } + + return element; +} + +let probeRenderCount = 0; +let latestConnection: ReturnType | null = null; + +function ConnectionProbe() { + const connection = useRealtimeConnection(); + probeRenderCount += 1; + latestConnection = connection; + + return ( +
+ {connection.isConnected ? "connected" : "idle"} +
+ ); +} + +function UseRealtimeProbe({ onEvent }: { onEvent: (type: string) => void }) { + useRealtime({ + events: { + conversationUpdated: (_data, meta) => { + onEvent(meta.event.type); + }, + }, + websiteId: "ws_456", + }); + + return
; +} + +describe("RealtimeProvider lifecycle", () => { + beforeEach(() => { + activeRoot = null; + mountNode = null; + mockSockets = []; + probeRenderCount = 0; + latestConnection = null; + windowInstance = new Window({ + url: "https://example.com", + }); + installDomGlobals(windowInstance); + installMockWebSocket(); + }); + + afterEach(async () => { + const { act } = await import("react"); + + if (activeRoot) { + await act(async () => { + activeRoot?.unmount(); + }); + } + + mountNode?.remove(); + activeRoot = null; + mountNode = null; + windowInstance = null; + + for (const key of installedGlobalKeys) { + Reflect.deleteProperty(globalThis, key); + } + }); + + it("survives StrictMode effect replay and still connects", async () => { + await render( + + + + + + ); + + // Let the deferred disposal timeout fire if it was not cancelled + await flushMacrotasks(); + + expect(mockSockets).toHaveLength(1); + + const { act } = await import("react"); + await act(async () => { + lastSocket().simulateOpen(); + }); + + expect(getBySlot("status").textContent).toBe("connected"); + }); + + it("destroys the client after a real unmount", async () => { + await render( + + + + + + ); + + const { act } = await import("react"); + await act(async () => { + lastSocket().simulateOpen(); + }); + + const socket = lastSocket(); + + await act(async () => { + activeRoot?.unmount(); + }); + activeRoot = null; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(socket.close).toHaveBeenCalled(); + }); + + it("invokes the latest onConnect callback after prop updates", async () => { + const firstOnConnect = mock(() => {}); + const secondOnConnect = mock(() => {}); + + await render( + + + + ); + + await rerender( + + + + ); + + const { act } = await import("react"); + await act(async () => { + lastSocket().simulateOpen(); + }); + + expect(firstOnConnect).not.toHaveBeenCalled(); + expect(secondOnConnect).toHaveBeenCalledTimes(1); + }); + + it("delivers events without re-rendering context consumers", async () => { + await render( + + + + ); + + const { act } = await import("react"); + await act(async () => { + lastSocket().simulateOpen(); + }); + + const received: string[] = []; + const unsubscribe = latestConnection?.subscribe((event) => { + received.push(event.type); + }); + const rendersBeforeEvent = probeRenderCount; + + await act(async () => { + lastSocket().simulateMessage(EVENT_MESSAGE); + }); + + expect(received).toEqual(["conversationUpdated"]); + expect(probeRenderCount).toBe(rendersBeforeEvent); + expect(latestConnection?.lastEvent?.type).toBe("conversationUpdated"); + + unsubscribe?.(); + }); + + it("keeps useRealtime handlers subscribed across connection state changes", async () => { + const receivedTypes: string[] = []; + + await render( + + { + receivedTypes.push(type); + }} + /> + + ); + + const { act } = await import("react"); + + // Connection state change re-renders the provider; the handler + // subscription must survive it and keep delivering events. + await act(async () => { + lastSocket().simulateOpen(); + }); + + await act(async () => { + lastSocket().simulateMessage(EVENT_MESSAGE); + }); + + expect(receivedTypes).toEqual(["conversationUpdated"]); + }); +}); diff --git a/packages/react/src/realtime/provider.tsx b/packages/react/src/realtime/provider.tsx index 7729895f..9ede52cb 100644 --- a/packages/react/src/realtime/provider.tsx +++ b/packages/react/src/realtime/provider.tsx @@ -11,7 +11,6 @@ import { useEffect, useMemo, useRef, - useState, useSyncExternalStore, } from "react"; @@ -36,6 +35,10 @@ type RealtimeContextValue = { send: (event: AnyRealtimeEvent) => void; sendRaw: (data: string) => void; subscribe: (handler: SubscribeHandler) => () => void; + /** + * Latest event received on the connection. Read-on-demand: updating it does + * not re-render consumers. Use `subscribe` to react to incoming events. + */ lastEvent: AnyRealtimeEvent | null; connectionId: string | null; reconnect: () => void; @@ -84,19 +87,26 @@ export function RealtimeProvider({ onDisconnect, onError, }: RealtimeProviderProps): React.ReactElement { - const [lastEvent, setLastEvent] = useState(null); + const lastEventRef = useRef(null); + const onConnectRef = useRef(onConnect); + const onDisconnectRef = useRef(onDisconnect); + const onErrorRef = useRef(onError); const clientRef = useRef(null); + const pendingDisposalRef = useRef | null>(null); if (!clientRef.current) { clientRef.current = new RealtimeClient({ wsUrl, onEvent: (event) => { - setLastEvent(event); + lastEventRef.current = event; }, - onConnect, - onDisconnect, - onError, + // Route through refs so the latest callback props are always invoked. + onConnect: () => onConnectRef.current?.(), + onDisconnect: () => onDisconnectRef.current?.(), + onError: (error) => onErrorRef.current?.(error), }); } @@ -104,8 +114,9 @@ export function RealtimeProvider({ // Update callbacks without recreating client useEffect(() => { - // Callbacks are captured in the RealtimeClient constructor closures, - // but the onEvent writes to refs/state which are always current. + onConnectRef.current = onConnect; + onDisconnectRef.current = onDisconnect; + onErrorRef.current = onError; }, [onConnect, onDisconnect, onError]); // Connect/disconnect based on auth + autoConnect @@ -117,14 +128,27 @@ export function RealtimeProvider({ } }, [client, auth, autoConnect]); - // Cleanup on unmount - useEffect( - () => () => { - clientRef.current?.destroy(); - clientRef.current = null; - }, - [] - ); + // Cleanup on unmount. Disposal is deferred one macrotask so StrictMode's + // synchronous cleanup/setup replay cancels it instead of permanently + // destroying the client. + useEffect(() => { + if (pendingDisposalRef.current !== null) { + globalThis.clearTimeout(pendingDisposalRef.current); + pendingDisposalRef.current = null; + } + + return () => { + if (pendingDisposalRef.current !== null) { + globalThis.clearTimeout(pendingDisposalRef.current); + } + + pendingDisposalRef.current = globalThis.setTimeout(() => { + pendingDisposalRef.current = null; + clientRef.current?.destroy(); + clientRef.current = null; + }, 0); + }; + }, []); // Subscribe to state changes via useSyncExternalStore const connectionState = useSyncExternalStore( @@ -160,14 +184,18 @@ export function RealtimeProvider({ send, sendRaw, subscribe, - lastEvent, + // Delivered via a ref instead of state so a busy socket does not + // re-render every context consumer per event. + get lastEvent() { + return lastEventRef.current; + }, connectionId: connectionState.connectionId, reconnect, visitorId: identity.visitorId, websiteId: identity.websiteId, userId: identity.userId, }), - [connectionState, send, sendRaw, subscribe, lastEvent, reconnect, identity] + [connectionState, send, sendRaw, subscribe, reconnect, identity] ); return ( diff --git a/packages/react/src/realtime/use-realtime.ts b/packages/react/src/realtime/use-realtime.ts index 7acda071..41325274 100644 --- a/packages/react/src/realtime/use-realtime.ts +++ b/packages/react/src/realtime/use-realtime.ts @@ -123,7 +123,9 @@ export function useRealtime< }); } }), - [connection] + // Depend on the stable subscribe function rather than the whole context + // value so connection-state updates do not tear down the subscription. + [connection.subscribe] ); return connection; diff --git a/packages/react/src/support/components/button.tsx b/packages/react/src/support/components/button.tsx index 11351df6..ab25b127 100644 --- a/packages/react/src/support/components/button.tsx +++ b/packages/react/src/support/components/button.tsx @@ -4,7 +4,7 @@ import { Button as ButtonPrimitive } from "../../primitives/button"; import { cn } from "../utils"; export const coButtonVariants = cva( - "group/btn inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-co border font-medium text-sm outline-none transition-all hover:cursor-pointer focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "group/btn inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-co border font-medium text-sm outline-none transition-all hover:cursor-pointer focus-visible:border-co-ring focus-visible:ring-[3px] focus-visible:ring-co-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-co-destructive aria-invalid:ring-co-destructive/20 dark:aria-invalid:ring-co-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { diff --git a/packages/react/src/support/components/content.tsx b/packages/react/src/support/components/content.tsx index 86d2382a..b7dc03a1 100644 --- a/packages/react/src/support/components/content.tsx +++ b/packages/react/src/support/components/content.tsx @@ -7,7 +7,7 @@ import { type Placement, shift, useFloating, -} from "@floating-ui/react"; +} from "@floating-ui/react-dom"; import * as React from "react"; import * as Primitive from "../../primitives"; import { useSupportMode } from "../context/mode"; diff --git a/packages/react/src/support/components/header.tsx b/packages/react/src/support/components/header.tsx index bd6fb880..3dd58a0d 100644 --- a/packages/react/src/support/components/header.tsx +++ b/packages/react/src/support/components/header.tsx @@ -1,5 +1,6 @@ import { useSupportSlotOverrides } from "../context/slot-overrides"; import { useSupportConfig } from "../store"; +import { useSupportTextSafe } from "../text"; import { cn } from "../utils"; import { CoButton } from "./button"; import Icon from "./icons"; @@ -20,6 +21,7 @@ export const Header: React.FC = ({ page, }) => { const { close } = useSupportConfig(); + const text = useSupportTextSafe(); const { slots, slotProps } = useSupportSlotOverrides(); const HeaderSlot = slots.header; const headerSlotProps = slotProps.header; @@ -50,6 +52,7 @@ export const Header: React.FC = ({
{onGoBack && ( = ({ {children}
{actions &&
{actions}
} - +
diff --git a/packages/react/src/support/components/image-lightbox.tsx b/packages/react/src/support/components/image-lightbox.tsx index bc1a9da1..daa06234 100644 --- a/packages/react/src/support/components/image-lightbox.tsx +++ b/packages/react/src/support/components/image-lightbox.tsx @@ -4,6 +4,7 @@ import type { TimelinePartImage } from "@cossistant/types/api/timeline-item"; import type * as React from "react"; import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; +import { useSupportText } from "../text"; import { cn } from "../utils"; import Icon from "./icons"; @@ -41,6 +42,7 @@ export function ImageLightbox({ onClose, className, }: ImageLightboxProps): React.ReactElement | null { + const text = useSupportText(); const [currentIndex, setCurrentIndex] = useState(initialIndex); const [mounted, setMounted] = useState(false); @@ -126,7 +128,7 @@ export function ImageLightbox({ return createPortal( // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Dialog backdrop needs click handler for closing
{/* Close button */}