Skip to main content
HUMΛN
Engineering
Engineering

Inside the Companion React wrapper: Shadow DOM, stable refs, and zero conversation loss on route changes

HUMΛN Team··10 min·Frontend builders

The challenge: React and imperative Shadow DOM

The Companion widget is implemented as an imperative, Shadow DOM-based UI. It manages its own internal state — conversation history, auth binding, animation — and doesn't want to be torn down and recreated every time a parent React component re-renders.

React components, by contrast, re-render constantly. Pass a new function reference as a prop and React considers the prop changed. Add a route change and Next.js might remount the layout entirely.

The naive approach — create the widget on mount, destroy on unmount, recreate on every route change — loses the conversation history every time the user navigates. That's a terrible experience.

The solution: mount once, rewire with refs

@human/companion-widget/react solves this with a pattern we call mount once, rewire with refs:

// Simplified version of packages/companion-widget/src/react.tsx
export const CompanionWidget = forwardRef<CompanionWidgetRef, CompanionWidgetProps>(
  function CompanionWidget(
    { buildAgentInput, onExpand, onMinimize, onMessage, onAuthChange, ...options },
    ref
  ) {
    const containerRef = useRef<HTMLDivElement>(null);
    const handleRef = useRef<CompanionHandle | null>(null);

    // Stable refs for all callbacks — never cause the widget to reinit
    const buildAgentInputRef = useRef(buildAgentInput);
    buildAgentInputRef.current = buildAgentInput;
    const onExpandRef = useRef(onExpand);
    onExpandRef.current = onExpand;
    // ... same pattern for onMinimize, onMessage, onAuthChange

    // Mount ONCE — empty deps array
    useEffect(() => {
      handleRef.current = initCompanion({
        ...options,
        container: containerRef.current!,
        // All callbacks forward through their ref — always call the latest version
        buildAgentInput: buildAgentInputRef.current
          ? () => buildAgentInputRef.current!()
          : undefined,
        onExpand: () => onExpandRef.current?.(),
        // ...
      });
      return () => {
        handleRef.current?.destroy();
        handleRef.current = null;
      };
    }, []); // ← empty deps: widget lives exactly as long as this component

    // Rewire augmenter when buildAgentInput prop changes
    useEffect(() => {
      handleRef.current?.setInputAugmenter(
        buildAgentInput ? () => buildAgentInput() : null
      );
    }, [buildAgentInput]);

    return <div ref={containerRef} />;
  }
);

Three key decisions:

1. Empty deps on the mount effect

The widget is created exactly once when the component mounts and destroyed exactly once when it unmounts. React never recreates it mid-session, so conversation history is preserved across re-renders, prop changes, and SPA route navigations.

2. All callbacks stored in refs

The callbacks (buildAgentInput, onExpand, etc.) are stored in mutable refs that are always kept current:

const buildAgentInputRef = useRef(buildAgentInput);
buildAgentInputRef.current = buildAgentInput;

The widget is initialized with stable wrapper functions that read from these refs at call time. This means:

  • The widget always calls the latest version of each callback
  • Changing a callback prop doesn't cause a remount
  • React's useCallback memoization is not required on the consumer side

3. setInputAugmenter for explicit rewiring

For SPAs, buildAgentInput typically includes window.location.pathname. When the route changes, the ref update alone would be enough for new messages — but there's a subtle edge case: the first message after a route change should carry the new path even if buildAgentInput was captured in a closure before the navigation.

The second useEffect handles this:

useEffect(() => {
  handleRef.current?.setInputAugmenter(
    buildAgentInput ? () => buildAgentInput() : null
  );
}, [buildAgentInput]);

When buildAgentInput changes (a new function reference due to route-dependent closure), the widget's internal augmenter is updated immediately. The next message carries the correct context.

The CompanionWidgetRef handle

The component accepts a ref that exposes an imperative handle:

const widgetRef = useRef<CompanionWidgetRef>(null);

// Later:
widgetRef.current?.expand();        // open the panel
widgetRef.current?.minimize();      // close the panel
widgetRef.current?.setInputAugmenter(fn);  // update context augmenter

This is useful when you want a custom button or keyboard shortcut to open the Companion:

<button onClick={() => widgetRef.current?.expand()}>
  Ask Companion
</button>
<CompanionWidget ref={widgetRef} ... />

The Shadow DOM boundary

The widget renders into a closed Shadow DOM root appended to the container <div>. This means:

  • No CSS leakage — widget styles don't affect the host app; host app styles don't affect the widget
  • No JS leakage — React's event system doesn't reach inside the Shadow DOM
  • Host app can't query widget internalsdocument.querySelector stops at the Shadow boundary

From the React component's perspective, the <div ref={containerRef} /> is an opaque mounting point. Everything inside it is managed by the vanilla widget.

Usage pattern: global widget in Next.js

The canonical pattern for a Next.js app:

// app/layout.tsx or components/RootLayoutClient.tsx
'use client';

import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { CompanionWidget } from '@human/companion-widget/react';

export function RootLayout({ children }) {
  const pathname = usePathname();
  const [companionOpen, setCompanionOpen] = useState(false);

  return (
    <>
      <Header companionActive={companionOpen} />
      <main>{children}</main>
      <CompanionWidget
        humanApiUrl={process.env.NEXT_PUBLIC_API_URL}
        agentsCallUrl="/api/companion/ask"
        buildAgentInput={() => ({
          deployment_id: process.env.NEXT_PUBLIC_COMPANION_DEPLOYMENT_ID,
          surface_context: {
            surface: 'my-app',
            page: pathname,
            page_title: typeof document !== 'undefined' ? document.title : '',
          },
        })}
        onExpand={() => setCompanionOpen(true)}
        onMinimize={() => setCompanionOpen(false)}
        ui={{ theme: 'dark', position: 'bottom-right' }}
      />
    </>
  );
}

The widget mounts once when the layout mounts and lives for the entire session. buildAgentInput re-captures pathname on every render but the widget only calls it on message send — so each message carries the current route, not a stale closure.

What the @human/companion-widget/react package exports

// @human/companion-widget/react
export { CompanionWidget };
export type { CompanionWidgetProps, CompanionWidgetRef };

CompanionWidgetProps is CompanionInitOptions plus className, style, buildAgentInput, onExpand, onMinimize, onMessage, onAuthChange. Options that are object literals (ui, container) are passed directly to initCompanion once on mount.

The container option is handled automatically — the component creates its own <div> ref and passes it.

Build setup

The React wrapper is compiled as a separate entry:

// packages/companion-widget/package.json
"exports": {
  ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
  "./react": { "import": "./dist/react.js", "types": "./dist/react.d.ts" }
}

React is a peer dependency (optional — the widget still works without it if you use the vanilla initCompanion API):

"peerDependencies": { "react": ">=17.0.0" },
"peerDependenciesMeta": { "react": { "optional": true } }

The react.tsx file is compiled by Vite (with esbuild.jsx: 'automatic') into dist/react.js, and TypeScript emits dist/react.d.ts separately. React and react/jsx-runtime are externalized — they're not bundled into dist/react.js.

The key invariant

The entire design reduces to one invariant:

The widget is created exactly once per React component mount. It is never recreated due to prop changes, re-renders, or route navigations. Conversation history is always preserved.

Everything else — stable refs, the setInputAugmenter second effect, the imperative handle — is in service of this invariant.