Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions __tests__/html2/hooks/useCapabilities.html
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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
Expand All @@ -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
Expand Down
24 changes: 12 additions & 12 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
66 changes: 20 additions & 46 deletions packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<readonly ReducerInput[]>(
() => 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<Capabilities>(() => 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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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().
},
Expand Down
Loading