diff --git a/__tests__/html2/hooks/useCapabilities.html b/__tests__/html2/hooks/useCapabilities.html index 2955275abe..9d5e5e6f61 100644 --- a/__tests__/html2/hooks/useCapabilities.html +++ b/__tests__/html2/hooks/useCapabilities.html @@ -52,11 +52,11 @@ expect(initialVoiceConfig).toEqual({ voice: 'en-US', speed: 1.0 }); - // TEST 2: Regular activity should NOT trigger capability re-calculation + // TEST 2: Regular activity should NOT trigger capability re-fetch // Store reference to current voiceConfiguration const preActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); - // Send a regular message (not capabilitiesChanged event) + // Send a regular message (capabilities only update via EventTarget, not activities) await directLine.emulateIncomingActivity({ type: 'message', text: 'Hello! This is a regular message.', @@ -69,13 +69,13 @@ // Get voiceConfiguration after regular activity const postActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); - // Reference should be the same (no re-calculation for regular activities) + // Reference should be the same (activities don't trigger capability re-fetch) expect(postActivityVoiceConfig).toBe(preActivityVoiceConfig); - // TEST 3: capabilitiesChanged event SHOULD trigger re-calculation + // TEST 3: capabilitieschanged event SHOULD trigger re-fetch const preChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); - // Update capability and emit event + // Update capability and dispatch capabilitieschanged event via EventTarget directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true }); // Wait for event to be processed @@ -92,7 +92,7 @@ // TEST 4: Same value should reuse reference (shallow equality check) const preNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); - // Set same value and emit event + // Set same value and dispatch event directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true }); // Wait for event to be processed diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 99f9d6c60a..af0b672d9f 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -29,16 +29,18 @@ if (voiceConfig) { ## How it works 1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values -2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter +2. **Event-driven updates** - When the adapter dispatches a `capabilitieschanged` event, WebChat re-fetches all capabilities from the adapter 3. **Optimized re-renders** - Only components consuming changed capabilities will re-render ## For adapter implementers -To expose capabilities from your adapter: +To expose capabilities from your adapter, implement event listener methods and provide getter functions. -### 1. Implement getter functions +### 1. Create an EventTarget and implement getter functions ```js +const eventTarget = new EventTarget(); + const adapter = { // ... other adapter methods @@ -47,21 +49,19 @@ const adapter = { sampleRate: 16000, chunkIntervalMs: 100 }; - } + }, + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget) }; ``` -### 2. Emit change events +### 2. Dispatch change events internally -When capability values change, emit a `capabilitiesChanged` event activity: +When capability values change, dispatch a `capabilitieschanged` event using the internal EventTarget: ```js -// When configuration changes, emit the nudge event -adapter.activity$.next({ - type: 'event', - name: 'capabilitiesChanged', - from: { id: 'bot', role: 'bot' } -}); +// When configuration changes, dispatch the event internally +eventTarget.dispatchEvent(new Event('capabilitieschanged')); ``` WebChat will then call all capability getter functions and update consumers if values changed. diff --git a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx index 71b500cf1a..99f34bfcae 100644 --- a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx +++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx @@ -1,9 +1,5 @@ -import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; -import { useReduceMemo } from 'use-reduce-memo'; -import type { WebChatActivity } from 'botframework-webchat-core'; -import { literal, object, safeParse } from 'valibot'; +import React, { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import useActivities from '../../hooks/useActivities'; import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext'; import CapabilitiesContext from './private/Context'; import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter'; @@ -13,61 +9,39 @@ type Props = Readonly<{ children?: ReactNode | undefined }>; const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); -// Synthetic marker to trigger initial fetch - must be a stable reference -const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const }); -type InitMarker = typeof INIT_MARKER; -type ReducerInput = WebChatActivity | InitMarker; - -const CapabilitiesChangedEventSchema = object({ - type: literal('event'), - name: literal('capabilitiesChanged') -}); - -const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER; - -const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean => - safeParse(CapabilitiesChangedEventSchema, activity).success; - /** - * Composer that derives capabilities from the adapter using a pure derivation pattern. + * Composer that provides capabilities from the adapter via EventTarget pattern. * * Design principles: - * 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker - * 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected + * 1. Initial fetch: Pulls capabilities from adapter on mount + * 2. Event-driven updates: Re-fetches when adapter dispatches 'capabilitieschanged' event * 3. Stable references: Individual capability objects maintain reference equality if unchanged * - This ensures consumers using selectors only re-render when their capability changes */ const CapabilitiesComposer = memo(({ children }: Props) => { - const [activities] = useActivities(); const { directLine } = useWebChatAPIContext(); - const activitiesWithInit = useMemo( - () => Object.freeze([INIT_MARKER, ...activities]), - [activities] + const getAllCapabilities = useCallback( + () => fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES).capabilities, + [directLine] ); - // TODO: [P1] update to use EventTarget than activity$. - const capabilities = useReduceMemo( - activitiesWithInit, - useCallback( - (prevCapabilities: Capabilities, item: ReducerInput): Capabilities => { - const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item); + const [capabilities, setCapabilities] = useState(() => getAllCapabilities()); - if (!shouldFetch) { - return prevCapabilities; - } + useEffect(() => { + const handleCapabilitiesChange = () => { + setCapabilities(prevCapabilities => { + const { capabilities, hasChanged } = fetchCapabilitiesFromAdapter(directLine, prevCapabilities); + return hasChanged ? capabilities : prevCapabilities; + }); + }; - const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter( - directLine, - prevCapabilities - ); + if (typeof directLine?.addEventListener === 'function') { + directLine.addEventListener('capabilitieschanged', handleCapabilitiesChange); - return hasChanged ? newCapabilities : prevCapabilities; - }, - [directLine] - ), - EMPTY_CAPABILITIES - ); + return () => directLine.removeEventListener('capabilitieschanged', handleCapabilitiesChange); + } + }, [directLine]); const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]); diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js index e3c902d464..c29e9bdc27 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js @@ -121,15 +121,12 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill // Generic capabilities storage const capabilities = new Map(); - // Helper to emit capabilitiesChanged event + // EventTarget for capability change notifications + const eventTarget = new EventTarget(); + + // Helper to dispatch capabilitieschanged event via EventTarget const emitCapabilitiesChangedEvent = () => { - activityDeferredObservable.next({ - from: { id: 'bot', role: 'bot' }, - id: uniqueId(), - name: 'capabilitiesChanged', - timestamp: getTimestamp(), - type: 'event' - }); + eventTarget.dispatchEvent(new Event('capabilitieschanged')); }; const directLine = { @@ -155,6 +152,8 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill emitCapabilitiesChangedEvent(); } }, + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), end: () => { // This is a mock and will no-op on dispatch(). },