Skip to content

feat(react): add <ChatCops> component and useChatCops() hook (#573) - #583

Open
Saifjafri212213 wants to merge 3 commits into
codercops:devfrom
Saifjafri212213:feat/react-component
Open

Saifjafri212213 wants to merge 3 commits into
codercops:devfrom
Saifjafri212213:feat/react-component

Conversation

@Saifjafri212213

@Saifjafri212213 Saifjafri212213 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds the @chatcops/react package with a <ChatCops> component and useChatCops() hook for React and Next.js applications (F-12 tasks 2 to 4).

Closes #573

What was done

  • New package @chatcops/react:
    • Follows CONTRIBUTING.md:244 ("private": true, "type": "module", package exports, build, typecheck, and test scripts).
    • Version starts at 0.1.0.
    • Added 'use client'; directive to support Next.js server component layout usage.
    • Configures @chatcops/widget (workspace:^) and react (>=18) as peer dependencies.
  • <ChatCops {...props} />:
    • Renders null, safe for Server-Side Rendering (SSR).
    • Dynamically loads @chatcops/widget on mount inside useEffect without touching window during SSR.
    • Subscribes to widget events (open, close, message, error, leadCaptured, preChatSubmit) using ChatCops.on(), keeping callbacks in a ref to avoid resubscribing on re-render.
    • Automatically handles StrictMode unmount/remount cycles leaving exactly one widget instance and one set of listeners. If unmounted before dynamic import resolves, drops late init.
    • Function props (userToken, onConsentRequest) are kept in refs and passed as stable wrappers to init(), preventing re-renders from re-initializing the widget.
    • container is compared using Object.is (avoiding circular JSON issues on React fiber elements).
    • Routes prop updates accepted by ChatCops.update() (branding, welcomeMessage, suggestions, placeholder, strings, theme, privacyUrl, disclosure) through update().
    • Removing update props (e.g. welcomeMessage) sends undefined to update().
    • When branding, strings, or theme loses a field, re-initializes cleanly.
    • Re-initializes on structural prop changes (apiUrl, mode, container, or theme.position).
  • useChatCops() hook:
    • Returns { open, close, toggle, send, update } controlling the widget instance.
    • Before the widget bundle loads on the client, all calls safely no-op (send() returns a resolved promise).
  • Size Budget:
    • Configured in .size-limit.json with a 1.5 kB gzipped budget.
    • Package builds at 1.36 kB gzipped.
  • Documentation:
    • Added website/src/content/docs/widget/react.mdx documenting setup with Next.js client component pattern, metadata export support, props, updates, and useChatCops().
    • Registered under Widget in website/astro.config.mjs.
    • Passes pnpm docs:check.

How I tested this

  • Unit tests: Ran Vitest unit tests in packages/react/tests/react.test.tsx (21 tests) and packages/react/tests/real-widget.test.tsx (1 test against the real @chatcops/widget) covering:
    • Mount, StrictMode (toHaveBeenCalledTimes(1)), and unmount cleanup.
    • Unmounting before module loads drops late init.
    • Real widget test verifying one .chatcops-root appears and is removed on unmount.
    • Re-rendering with inline userToken function prop leaves init at 1 call.
    • Moving container to another element inits again; same container does not re-init.
    • Removing welcomeMessage calls update({ welcomeMessage: undefined }).
    • branding losing avatar calls init().
    • theme or strings losing a field inits again.
    • Update diffing, reinit diffing, ref callback persistence, handle controls, and SSR renderToString.
  • Coverage: Ran pnpm test:coverage verifying 100% statements, 85.71% branches, and 100% lines on @chatcops/react (exceeding the 85/80 threshold).
  • Checks and linters:
    • pnpm lint (0 errors)
    • pnpm publint (All good)
    • pnpm api:check (Snapshots up to date)
    • pnpm size (1.36 kB gzipped vs 1.5 kB limit)
    • pnpm docs:check (All 205 doc samples validated)
    • pnpm -r build (Full workspace built clean)

AI assistance

Written with an AI coding assistant (Google Antigravity / Gemini) for boilerplate generation, diff logic, and test authoring. I reviewed all lines, updated diff/ref handling, verified test coverage, and ran the checks above.

@anurag629 anurag629 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Saifjafri212213, this mostly works. The StrictMode handling is right: the act flag at packages/react/src/index.ts:77-81 drops the first effect's late import, and because ChatCops.on() handlers outlive init() (packages/widget/src/index.ts:42-46), a re-init keeps the listeners without subscribing again. Server rendering never touches window, and the package setup follows CONTRIBUTING.md:244: the peers, exports, scripts, tsconfig, coverage floor, api/react.api.md and the size entry are all there.

A few things need to change before it can go in:

  1. A function prop re-inits the widget on every render. eq() at packages/react/src/index.ts:37-38 sees two different functions as a change, and userToken and onConsentRequest aren't in the update list, so diff() returns reinit at line 63. With userToken={async () => getToken()}, the form the server docs recommend, any re-render of the parent calls ChatCops.init(), which destroys the running widget (packages/widget/src/index.ts:38-40) and stops the reply in progress (packages/widget/src/widget.ts:648). An open chat closes because a cart count changed, and website/src/content/docs/widget/react.mdx:87 promises the opposite. Keep the latest function in ref the way the event props work, and give init() a wrapper that calls it, so a new arrow isn't a change. container has a related problem: two elements go through JSON.stringify, which gives {} for any element React didn't render (so a new container is missed) and throws "Converting circular structure to JSON" for one it did, because of React's __reactFiber$ property. Compare container with Object.is. Tests: re-rendering with a new inline userToken leaves init at one call, and moving container to another element inits again. Please also say on the docs page that any other prop change starts the widget over, which closes the panel and stops a reply in progress.

  2. Removing a prop doesn't reach the widget. Line 60 leaves out keys whose new value is undefined, so going from welcomeMessage="Hi" to no welcomeMessage calls update({}) and "Hi" stays, though the widget shows the locale's welcome for undefined (widget.ts:762-763). The same goes for suggestions, placeholder, privacyUrl and disclosure. Send those as undefined: update() spreads changes, so that puts the default back. branding, strings and theme are merged instead (widget.ts:569-580), so dropping avatar from branding={{ name: 'Acme', avatar: '/a.png' }} keeps the avatar. When one of those three loses a field, init again. Tests: removing welcomeMessage calls update({ welcomeMessage: undefined }), and branding losing avatar calls init().

  3. The package needs 'use client'. Without it, <ChatCops apiUrl="/api/chat" /> can't be rendered from a Next.js server component, which app/layout.tsx is by default, because the component calls useEffect. The docs get around that by putting 'use client' on the root layout (react.mdx:21), but then the layout can't export metadata, and most root layouts do. Put 'use client'; on the first line of src/index.ts (tsc keeps the directive in dist/index.js). Then change the docs example to a small client component that holds the event props, since functions can't be passed from a server component, and import it from the server layout.

  4. packages/react/package.json:3 starts at 1.1.1, copied from the widget. @chatcops/ingest and @chatcops/cli started at 0.1.0 with a minor changeset, and at 1.1.1 this one would go out as 1.2.0. Start it at 0.1.0.

  5. Two gaps in the tests. The StrictMode test checks init with toHaveBeenCalled() (packages/react/tests/react.test.tsx:111), so two inits would pass. Make it toHaveBeenCalledTimes(1). Nothing covers unmounting before the import resolves, the if (!act) return at index.ts:81, which is what a fast route change does: render, unmount straight away, wait, and expect no init(). The whole file mocks @chatcops/widget, so one StrictMode test in a second file against the real widget would also help: expect one .chatcops-root in document.body after mounting and none after unmounting.

  6. The "AI assistance" section doesn't name the tool you used. CONTRIBUTING asks for the tool and what you used it for.

These fixes will probably take dist/index.js past the 1 kB budget (your description has it at 999 B, and the directive alone adds bytes). Write them with readable names anyway and post the new size here, and we'll look at the budget then. The branch is also behind dev, with no conflicts, and dev only merges branches that are up to date, so merge dev in when you push. I'll review it again then.

@Saifjafri212213

Copy link
Copy Markdown
Collaborator Author

Thanks @anurag629 for the thorough review! I've addressed all the requested changes:

  1. Function props & container:

    • userToken and onConsentRequest functions are now wrapped with stable wrappers that read the latest function from ref.current. Inline function props on re-render no longer cause re-initialization.
    • container is compared using Object.is, preventing circular structure JSON errors on React fiber elements and correctly detecting container element moves.
    • Added docs explaining that other prop changes start the widget over.
  2. Prop removal:

    • Removing an update prop (e.g. welcomeMessage) sends undefined to ChatCops.update(), resetting it to default.
    • If branding, strings, or theme loses any property/token, diff() triggers ChatCops.init() cleanly.
  3. 'use client':

    • Added 'use client'; directive to line 1 of packages/react/src/index.ts.
    • Updated documentation examples to demonstrate a client component wrapper (app/chat.tsx) imported by a server layout (app/layout.tsx), preserving metadata exports.
  4. Package Version:

    • Set @chatcops/react version to 0.1.0 in package.json.
  5. Tests:

    • Changed StrictMode check to expect(mockWidget.init).toHaveBeenCalledTimes(1).
    • Added test verifying unmounting before dynamic import resolves drops late init.
    • Added test with the real widget (packages/react/tests/real-widget.test.tsx) asserting one .chatcops-root appears and is removed on unmount.
    • Added tests for inline userToken re-renders, container element moves, removing welcomeMessage, and branding losing avatar.
  6. Size & AI assistance:

    • With the directive, function wrappers, and prop removal handling, the bundle size is 1.36 kB gzipped. I've bumped the limit in .size-limit.json to 1.5 kB so CI size check passes.
    • Updated the PR description with the AI tool and usage details.
  7. Merged dev:

    • Merged latest origin/dev into the branch.

@anurag629 anurag629 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Saifjafri212213, all six points are in. A new inline userToken or onConsentRequest goes through the wrappers at packages/react/src/index.ts:58-66 instead of starting the widget over, container is compared with Object.is, and removing welcomeMessage sends update({ welcomeMessage: undefined }). branding, strings and theme losing a field inits again, which covers exactly the keys update() merges (packages/widget/src/widget.ts:569-580). The new unmount test covers the if (!act) return at line 137, the real-widget test checks for one .chatcops-root under StrictMode, 'use client' is on line 1, and the package starts at 0.1.0. All checks pass, including e2e.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@chatcops/react: a <ChatCops> component and useChatCops() hook

2 participants