From b4068b36dff1e34ec8dddd7cf11c4aa6c21b0667 Mon Sep 17 00:00:00 2001 From: Pranav Joshi Date: Wed, 4 Feb 2026 10:04:21 +0000 Subject: [PATCH 1/4] capabilities using event target --- __tests__/html2/hooks/useCapabilities.html | 12 ++-- docs/CAPABILITIES.md | 24 +++---- .../Capabilities/CapabilitiesComposer.tsx | 67 +++++++------------ .../testHelpers/createDirectLineEmulator.js | 15 ++--- 4 files changed, 48 insertions(+), 70 deletions(-) diff --git a/__tests__/html2/hooks/useCapabilities.html b/__tests__/html2/hooks/useCapabilities.html index 2955275abe..5f5155a5f2 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..80a23fda79 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..3748713304 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,44 @@ 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(() => { + const { capabilities } = fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES); + return 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); - - if (!shouldFetch) { - return prevCapabilities; - } + const [capabilities, setCapabilities] = useState(() => getAllCapabilities()); + useEffect(() => { + const handleCapabilitiesChange = () => { + setCapabilities(prevCapabilities => { const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter( directLine, prevCapabilities ); - return hasChanged ? newCapabilities : prevCapabilities; - }, - [directLine] - ), - EMPTY_CAPABILITIES - ); + }); + }; + + if (typeof directLine?.addEventListener === 'function') { + directLine.addEventListener('capabilitiesChanged', handleCapabilitiesChange); + + 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..2d8a67e25e 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(). }, From f2ab50f4e7e663f303c0d1c21801f1058848173b Mon Sep 17 00:00:00 2001 From: William Wong Date: Thu, 5 Feb 2026 22:54:03 -0800 Subject: [PATCH 2/4] Cleaned up --- .../Capabilities/CapabilitiesComposer.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx index 3748713304..04307ae102 100644 --- a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx +++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx @@ -21,30 +21,28 @@ const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); const CapabilitiesComposer = memo(({ children }: Props) => { const { directLine } = useWebChatAPIContext(); - const getAllCapabilities = useCallback(() => { - const { capabilities } = fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES); - return capabilities; - }, [directLine]); + const getAllCapabilities = useCallback(() => + fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES).capabilities, + [directLine] + ); const [capabilities, setCapabilities] = useState(() => getAllCapabilities()); useEffect(() => { const handleCapabilitiesChange = () => { setCapabilities(prevCapabilities => { - const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter( + const { capabilities, hasChanged } = fetchCapabilitiesFromAdapter( directLine, prevCapabilities ); - return hasChanged ? newCapabilities : prevCapabilities; + return hasChanged ? capabilities : prevCapabilities; }); }; if (typeof directLine?.addEventListener === 'function') { directLine.addEventListener('capabilitiesChanged', handleCapabilitiesChange); - return () => { - directLine.removeEventListener('capabilitiesChanged', handleCapabilitiesChange); - }; + return () => directLine.removeEventListener('capabilitiesChanged', handleCapabilitiesChange); } }, [directLine]); From 76a42e50a89e73a7bb5f1d4e632428da11404a99 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 6 Feb 2026 07:08:47 +0000 Subject: [PATCH 3/4] Fix Prettier --- .../src/providers/Capabilities/CapabilitiesComposer.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx index 04307ae102..39a3a3381f 100644 --- a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx +++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx @@ -21,8 +21,8 @@ const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); const CapabilitiesComposer = memo(({ children }: Props) => { const { directLine } = useWebChatAPIContext(); - const getAllCapabilities = useCallback(() => - fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES).capabilities, + const getAllCapabilities = useCallback( + () => fetchCapabilitiesFromAdapter(directLine, EMPTY_CAPABILITIES).capabilities, [directLine] ); @@ -31,10 +31,7 @@ const CapabilitiesComposer = memo(({ children }: Props) => { useEffect(() => { const handleCapabilitiesChange = () => { setCapabilities(prevCapabilities => { - const { capabilities, hasChanged } = fetchCapabilitiesFromAdapter( - directLine, - prevCapabilities - ); + const { capabilities, hasChanged } = fetchCapabilitiesFromAdapter(directLine, prevCapabilities); return hasChanged ? capabilities : prevCapabilities; }); }; From f1b33f13e43e44b1efa2ad3be2c9e97bff9bb2e0 Mon Sep 17 00:00:00 2001 From: Pranav Joshi Date: Fri, 6 Feb 2026 12:23:50 +0000 Subject: [PATCH 4/4] comment resolved --- __tests__/html2/hooks/useCapabilities.html | 4 ++-- docs/CAPABILITIES.md | 6 +++--- .../api/src/providers/Capabilities/CapabilitiesComposer.tsx | 6 +++--- .../src/globals/testHelpers/createDirectLineEmulator.js | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/__tests__/html2/hooks/useCapabilities.html b/__tests__/html2/hooks/useCapabilities.html index 5f5155a5f2..9d5e5e6f61 100644 --- a/__tests__/html2/hooks/useCapabilities.html +++ b/__tests__/html2/hooks/useCapabilities.html @@ -72,10 +72,10 @@ // Reference should be the same (activities don't trigger capability re-fetch) expect(postActivityVoiceConfig).toBe(preActivityVoiceConfig); - // TEST 3: capabilitiesChanged event SHOULD trigger re-fetch + // TEST 3: capabilitieschanged event SHOULD trigger re-fetch const preChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); - // Update capability and dispatch capabilitiesChanged event via EventTarget + // 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 diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 80a23fda79..af0b672d9f 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -29,7 +29,7 @@ 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 dispatches 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 @@ -57,11 +57,11 @@ const adapter = { ### 2. Dispatch change events internally -When capability values change, dispatch a `capabilitiesChanged` event using the internal EventTarget: +When capability values change, dispatch a `capabilitieschanged` event using the internal EventTarget: ```js // When configuration changes, dispatch the event internally -eventTarget.dispatchEvent(new Event('capabilitiesChanged')); +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 39a3a3381f..99f34bfcae 100644 --- a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx +++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx @@ -14,7 +14,7 @@ const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); * * Design principles: * 1. Initial fetch: Pulls capabilities from adapter on mount - * 2. Event-driven updates: Re-fetches when adapter dispatches 'capabilitiesChanged' event + * 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 */ @@ -37,9 +37,9 @@ const CapabilitiesComposer = memo(({ children }: Props) => { }; if (typeof directLine?.addEventListener === 'function') { - directLine.addEventListener('capabilitiesChanged', handleCapabilitiesChange); + directLine.addEventListener('capabilitieschanged', handleCapabilitiesChange); - return () => directLine.removeEventListener('capabilitiesChanged', handleCapabilitiesChange); + return () => directLine.removeEventListener('capabilitieschanged', handleCapabilitiesChange); } }, [directLine]); diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js index 2d8a67e25e..c29e9bdc27 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js @@ -124,9 +124,9 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill // EventTarget for capability change notifications const eventTarget = new EventTarget(); - // Helper to dispatch capabilitiesChanged event via EventTarget + // Helper to dispatch capabilitieschanged event via EventTarget const emitCapabilitiesChangedEvent = () => { - eventTarget.dispatchEvent(new Event('capabilitiesChanged')); + eventTarget.dispatchEvent(new Event('capabilitieschanged')); }; const directLine = {